chore: add project scaffolding and mockups

This commit is contained in:
root
2026-08-08 13:07:11 -04:00
parent 2f744811fc
commit 3b1b1cebc5
4 changed files with 2259 additions and 0 deletions
+988
View File
@@ -0,0 +1,988 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moore Sunny Daze — Admin Dashboard</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #06b6d4;
--accent2: #f59e0b;
--green: #10b981;
--red: #ef4444;
--sidebar-w: 240px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
min-height: 100vh;
}
/* ── SIDEBAR ── */
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
transition: transform .25s ease;
}
.sidebar-logo {
padding: 24px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.sidebar-logo .icon { width:36px; height:36px; border-radius:8px; background:var(--accent); display:flex; align-items:center; justify-content:center; color:#fff; font-weight:700; font-size:18px; }
.sidebar-logo h1 { font-size:15px; font-weight:600; color:var(--text); line-height:1.3; }
.sidebar-logo span { font-size:11px; color:var(--muted); display:block; }
.sidebar-nav { flex:1; padding:12px 8px; }
.nav-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; border-radius: 8px;
color: var(--muted); cursor: pointer; font-size: 14px;
transition: all .15s; margin-bottom: 2px;
border: none; background: none; width: 100%; text-align: left;
}
.nav-item:hover { background: rgba(255,255,255,.04); color: var(--text); }
.nav-item.active { background: rgba(6,182,212,.12); color: var(--accent); }
.nav-item svg { width:20px; height:20px; flex-shrink:0; }
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
}
/* ── MOBILE TOGGLE ── */
.mobile-toggle {
display: none;
position: fixed; top: 12px; left: 12px; z-index: 200;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); border-radius: 8px; padding: 8px 10px;
cursor: pointer;
}
.mobile-overlay {
display: none;
position: fixed; inset: 0; background: rgba(0,0,0,.5);
z-index: 99;
}
/* ── MAIN ── */
.main {
margin-left: var(--sidebar-w);
flex: 1;
padding: 32px;
min-width: 0;
}
.page { display:none; }
.page.active { display:block; }
.page-header { margin-bottom: 28px; }
.page-header h2 { font-size:22px; font-weight:700; margin-bottom:4px; }
.page-header p { color:var(--muted); font-size:14px; }
/* ── STATS BAR ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.stat-card .label { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; }
.stat-card .value { font-size:28px; font-weight:700; }
.stat-card .value.accent { color:var(--accent); }
.stat-card .value.green { color:var(--green); }
.stat-card .value.amber { color:var(--accent2); }
.stat-card .sub { font-size:12px; color:var(--muted); margin-top:4px; }
/* ── FILTER BAR ── */
.filter-bar {
display: flex; gap: 8px; margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-btn {
padding: 6px 16px; border-radius: 20px; font-size: 13px;
border: 1px solid var(--border); background: transparent;
color: var(--muted); cursor: pointer; transition: all .15s;
}
.filter-btn:hover { border-color: var(--accent); color: var(--text); }
.filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* ── TABLE ── */
.table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow-x: auto;
}
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 12px 16px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing:.05em; border-bottom: 1px solid var(--border); font-weight: 600; }
td { padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,.02); }
.badge {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 12px; font-weight: 600; text-transform: capitalize;
}
.badge.confirmed { background: rgba(16,185,129,.15); color: var(--green); }
.badge.pending { background: rgba(245,158,11,.15); color: var(--accent2); }
.badge.cancelled { background: rgba(148,163,184,.15); color: var(--muted); }
.badge.completed { background: rgba(148,163,184,.1); color: var(--muted); }
.btn {
padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
border: none; cursor: pointer; transition: all .15s;
}
.btn-sm { padding:4px 10px; font-size:11px; }
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger { background: rgba(239,68,68,.15); color: var(--red); }
.btn-danger:hover { background: rgba(239,68,68,.25); }
.btn-green { background: rgba(16,185,129,.15); color: var(--green); }
.btn-green:hover { background: rgba(16,185,129,.25); }
.btn-accent { background: var(--accent); color: #fff; }
.btn-accent:hover { opacity:.85; }
.actions { display:flex; gap:6px; flex-wrap:wrap; }
/* ── CALENDAR ── */
.calendar-container {
display: grid;
grid-template-columns: 1fr 280px;
gap: 24px;
}
.calendar-nav {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.calendar-nav h3 { font-size: 18px; font-weight: 600; }
.calendar-nav button {
background: var(--surface); border: 1px solid var(--border);
color: var(--text); border-radius: 8px; padding: 6px 14px;
cursor: pointer; font-size: 14px;
}
.calendar-nav button:hover { border-color: var(--accent); }
.calendar-grid {
display: grid; grid-template-columns: repeat(7, 1fr);
border: 1px solid var(--border); border-radius: 12px;
overflow: hidden;
}
.calendar-day-header {
background: var(--surface); padding: 10px;
text-align: center; font-size: 12px; color: var(--muted);
font-weight: 600; text-transform: uppercase;
}
.calendar-cell {
background: var(--bg); border-top: 1px solid var(--border);
border-right: 1px solid var(--border);
min-height: 80px; padding: 6px; cursor: pointer;
transition: all .15s; position: relative;
}
.calendar-cell:nth-child(7n) { border-right: none; }
.calendar-cell:hover { background: rgba(6,182,212,.08); }
.calendar-cell.empty { cursor: default; background: var(--bg); }
.calendar-cell.empty:hover { background: var(--bg); }
.calendar-cell .day-num { font-size: 13px; font-weight: 600; color: var(--muted); }
.calendar-cell.today .day-num { color: var(--accent); background:rgba(6,182,212,.15); border-radius:50%; width:26px; height:26px; display:flex; align-items:center; justify-content:center; }
.calendar-cell.booked { background: rgba(239,68,68,.08); }
.calendar-cell.blocked { background: rgba(245,158,11,.08); }
.calendar-cell .tag { font-size:10px; font-weight:600; padding:1px 6px; border-radius:10px; margin-top:4px; display:inline-block; }
.calendar-cell .tag.booked { background: rgba(239,68,68,.2); color: var(--red); }
.calendar-cell .tag.blocked { background: rgba(245,158,11,.2); color: var(--accent2); }
.calendar-cell.selected { outline:2px solid var(--accent); outline-offset:-2px; }
/* ── SIDE PANEL ── */
.side-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.side-panel h4 { font-size:15px; font-weight:600; margin-bottom:16px; }
.side-panel p { font-size:13px; color:var(--muted); margin-bottom:16px; line-height:1.6; }
.selected-dates { margin-bottom: 16px; }
.selected-dates .chip {
display: inline-flex; align-items: center; gap: 6px;
background: rgba(6,182,212,.12); color: var(--accent);
padding: 4px 10px; border-radius: 16px; font-size: 12px;
margin: 2px 4px 2px 0;
}
.selected-dates .chip .remove {
cursor: pointer; font-weight: 700; font-size: 14px; line-height:1;
}
.side-panel .legend { margin-top:20px; }
.side-panel .legend-item { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); margin-bottom:6px; }
.side-panel .legend-dot { width:8px; height:8px; border-radius:50%; }
.side-panel .legend-dot.booked { background:var(--red); }
.side-panel .legend-dot.blocked { background:var(--accent2); }
.side-panel .legend-dot.available { background:var(--border); }
/* ── GUEST LIST ── */
.guest-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 12px;
display: flex; justify-content: space-between; align-items: flex-start;
gap: 16px;
}
.guest-card .guest-info h4 { font-size:15px; font-weight:600; margin-bottom:4px; }
.guest-card .guest-info .email { font-size:13px; color:var(--muted); }
.guest-card .guest-info .phone { font-size:13px; color:var(--muted); }
.guest-card .guest-info .notes { font-size:13px; color:var(--muted); margin-top:6px; font-style:italic; }
.guest-card .guest-meta { text-align:right; font-size:12px; color:var(--muted); }
/* ── SETTINGS ── */
.settings-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
.settings-section h3 { font-size:16px; font-weight:600; margin-bottom:16px; }
.form-group { margin-bottom: 16px; }
.form-group label { display:block; font-size:13px; color:var(--muted); margin-bottom:6px; }
.form-group input, .form-group textarea {
width: 100%; padding: 10px 14px; border-radius: 8px;
background: var(--bg); border: 1px solid var(--border);
color: var(--text); font-size: 14px;
}
.form-group input:focus, .form-group textarea:focus {
outline: none; border-color: var(--accent);
}
/* ── LOADING & EMPTY ── */
.loading { text-align:center; padding:60px 20px; color:var(--muted); }
.loading .spinner { width:32px; height:32px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; margin:0 auto 12px; }
@keyframes spin { to { transform:rotate(360deg); } }
.empty { text-align:center; padding:60px 20px; color:var(--muted); }
.empty svg { width:48px; height:48px; margin-bottom:12px; opacity:.3; }
.toast {
position: fixed; bottom: 24px; right: 24px;
padding: 12px 24px; border-radius: 8px;
font-size: 14px; font-weight: 600; z-index: 300;
animation: slideUp .3s ease;
}
.toast.success { background: var(--green); color: #fff; }
.toast.error { background: var(--red); color: #fff; }
@keyframes slideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
/* ── RESPONSIVE ── */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); }
.main { margin-left: 0; padding: 16px; padding-top: 60px; }
.mobile-toggle { display: block; }
.mobile-overlay.open { display: block; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.calendar-container { grid-template-columns: 1fr; }
.calendar-cell { min-height: 52px; }
.guest-card { flex-direction: column; }
.guest-card .guest-meta { text-align: left; }
}
</style>
</head>
<body>
<!-- MOBILE -->
<button class="mobile-toggle" onclick="toggleSidebar()" aria-label="Menu">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h14M3 10h14M3 15h14"/></svg>
</button>
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleSidebar()"></div>
<!-- SIDEBAR -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<div class="icon">MS</div>
<div>
<h1>Moore Sunny Daze</h1>
<span>Admin Dashboard</span>
</div>
</div>
<nav class="sidebar-nav">
<button class="nav-item active" data-page="dashboard" onclick="showPage('dashboard', this)">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="7" height="7" rx="1"/><rect x="11" y="2" width="7" height="7" rx="1"/><rect x="2" y="11" width="7" height="7" rx="1"/><rect x="11" y="11" width="7" height="7" rx="1"/></svg>
Dashboard
</button>
<button class="nav-item" data-page="bookings" onclick="showPage('bookings', this)">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="15" height="14" rx="2"/><path d="M2.5 7.5h15M6.5 1.5v3M13.5 1.5v3"/><circle cx="6.5" cy="11" r="1"/><circle cx="10" cy="11" r="1"/><circle cx="13.5" cy="11" r="1"/></svg>
Bookings
</button>
<button class="nav-item" data-page="calendar" onclick="showPage('calendar', this)">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="15" height="14" rx="2"/><path d="M2.5 7.5h15M6.5 1.5v3M13.5 1.5v3"/></svg>
Calendar
</button>
<button class="nav-item" data-page="guests" onclick="showPage('guests', this)">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="6" r="2.5"/><path d="M1.5 18c0-3 2.5-5.5 5.5-5.5 1.1 0 2.1.3 3 .9M13 8.5a3 3 0 100-6 3 3 0 000 6zM11 12h4c2.2 0 4 1.8 4 4v.5"/></svg>
Guests
</button>
<button class="nav-item" data-page="settings" onclick="showPage('settings', this)">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="10" cy="10" r="2.5"/><path d="M10 1.5v2M10 16.5v2M1.5 10h2M16.5 10h2M3.4 3.4l1.4 1.4M15.2 15.2l1.4 1.4M3.4 16.6l1.4-1.4M15.2 4.8l1.4-1.4"/></svg>
Settings
</button>
</nav>
<div class="sidebar-footer">Moore Sunny Daze v1.0</div>
</aside>
<!-- MAIN CONTENT -->
<main class="main">
<!-- DASHBOARD PAGE -->
<section class="page active" id="page-dashboard">
<div class="page-header">
<h2>Dashboard Overview</h2>
<p>Key metrics at a glance for your vacation rental.</p>
</div>
<div class="stats-grid" id="statsGrid">
<div class="loading"><div class="spinner"></div>Loading stats…</div>
</div>
<div style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;">
<h3 style="font-size:14px;color:var(--muted);margin-bottom:12px;">Quick Actions</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-accent" onclick="showPage('bookings', document.querySelector('[data-page=bookings]'))">View All Bookings</button>
<button class="btn btn-outline" onclick="showPage('calendar', document.querySelector('[data-page=calendar]'))">Check Availability</button>
<button class="btn btn-outline" onclick="showPage('guests', document.querySelector('[data-page=guests]'))">Guest Directory</button>
</div>
</div>
</section>
<!-- BOOKINGS PAGE -->
<section class="page" id="page-bookings">
<div class="page-header">
<h2>Bookings</h2>
<p>Manage all reservations for Moore Sunny Daze.</p>
</div>
<div class="filter-bar" id="bookingFilter">
<button class="filter-btn active" data-status="all" onclick="filterBookings('all', this)">All</button>
<button class="filter-btn" data-status="confirmed" onclick="filterBookings('confirmed', this)">Confirmed</button>
<button class="filter-btn" data-status="pending" onclick="filterBookings('pending', this)">Pending</button>
<button class="filter-btn" data-status="completed" onclick="filterBookings('completed', this)">Completed</button>
<button class="filter-btn" data-status="cancelled" onclick="filterBookings('cancelled', this)">Cancelled</button>
</div>
<div class="table-wrap" id="bookingsTable">
<div class="loading"><div class="spinner"></div>Loading bookings…</div>
</div>
</section>
<!-- CALENDAR PAGE -->
<section class="page" id="page-calendar">
<div class="page-header">
<h2>Availability Calendar</h2>
<p>View and manage blocked dates.</p>
</div>
<div class="calendar-container">
<div>
<div class="calendar-nav" id="calendarNav"></div>
<div class="calendar-grid" id="calendarGrid">
<div class="loading"><div class="spinner"></div>Loading calendar…</div>
</div>
</div>
<div class="side-panel">
<h4>Block Dates</h4>
<p>Click dates on the calendar to select them, then block them below. Blocked dates prevent bookings.</p>
<div class="selected-dates" id="selectedDates"><span style="font-size:12px;color:var(--muted);">No dates selected</span></div>
<button class="btn btn-accent" style="width:100%;margin-bottom:8px;" onclick="blockSelectedDates()" id="blockDatesBtn" disabled>
Block Selected Dates
</button>
<button class="btn btn-outline" style="width:100%;" onclick="clearDateSelection()" id="clearSelectionBtn" disabled>
Clear Selection
</button>
<div class="legend">
<div class="legend-item"><span class="legend-dot available"></span> Available</div>
<div class="legend-item"><span class="legend-dot booked"></span> Booked</div>
<div class="legend-item"><span class="legend-dot blocked"></span> Blocked</div>
</div>
</div>
</div>
</section>
<!-- GUESTS PAGE -->
<section class="page" id="page-guests">
<div class="page-header">
<h2>Guest Directory</h2>
<p>All guests who have stayed at Moore Sunny Daze.</p>
</div>
<div id="guestsList">
<div class="loading"><div class="spinner"></div>Loading guests…</div>
</div>
</section>
<!-- SETTINGS PAGE -->
<section class="page" id="page-settings">
<div class="page-header">
<h2>Settings</h2>
<p>Configure your rental property details.</p>
</div>
<div class="settings-section">
<h3>Property Information</h3>
<div class="form-group">
<label>Property Name</label>
<input type="text" value="Moore Sunny Daze" disabled>
</div>
<div class="form-group">
<label>Nightly Rate ($)</label>
<input type="number" value="199" id="settingNightlyRate">
</div>
<div class="form-group">
<label>Cleaning Fee ($)</label>
<input type="number" value="120" id="settingCleaningFee">
</div>
<div class="form-group">
<label>Pet Fee ($)</label>
<input type="number" value="50" id="settingPetFee">
</div>
<div class="form-group">
<label>Max Guests</label>
<input type="number" value="6" id="settingMaxGuests">
</div>
<button class="btn btn-accent" onclick="saveSettings()">Save Settings</button>
</div>
</section>
</main>
<script>
// ── CONFIG ──
const API_BASE = '/api/admin';
const AUTH_HEADER = { 'Authorization': 'Bearer msd-admin-LoveMyBoys.1520', 'Content-Type': 'application/json' };
// ── STATE ──
let allBookings = [];
let filteredBookings = [];
let currentBookingFilter = 'all';
let calendarYear, calendarMonth;
let bookedDates = {};
let selectedDates = new Set();
let allGuests = [];
// ── UTILS ──
function $(sel) { return document.querySelector(sel); }
function $$(sel) { return document.querySelectorAll(sel); }
function formatDate(iso) {
if (!iso) return '—';
const d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' });
}
function formatCurrency(n) {
return '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function toast(msg, type) {
const el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
// ── SIDEBAR ──
function toggleSidebar() {
const sidebar = $('#sidebar');
const overlay = $('#mobileOverlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('open');
}
function showPage(name, navBtn) {
$$('.page').forEach(p => p.classList.remove('active'));
$$('.nav-item').forEach(n => n.classList.remove('active'));
const page = $('#page-' + name);
if (page) page.classList.add('active');
if (navBtn) navBtn.classList.add('active');
// close mobile sidebar
$('#sidebar').classList.remove('open');
$('#mobileOverlay').classList.remove('open');
// load data on demand
if (name === 'dashboard') loadDashboard();
if (name === 'bookings') loadBookings();
if (name === 'calendar') loadCalendar();
if (name === 'guests') loadGuests();
}
// ── API HELPERS ──
async function apiFetch(path) {
try {
const res = await fetch(API_BASE + path, { headers: AUTH_HEADER });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.warn('API call failed, using mock data:', path, e.message);
return null;
}
}
async function apiPatch(path, body) {
const res = await fetch(API_BASE + path, {
method: 'PATCH',
headers: AUTH_HEADER,
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function apiPost(path, body) {
const res = await fetch(API_BASE + path, {
method: 'POST',
headers: AUTH_HEADER,
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function apiDelete(path, body) {
const res = await fetch(API_BASE + path, {
method: 'DELETE',
headers: AUTH_HEADER,
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// ── MOCK DATA ──
function getMockStats() {
return {
confirmed_bookings: 12,
upcoming_bookings: 3,
total_revenue: 18450.00,
monthly_revenue: 2850.00,
total_guests: 38,
occupancy_30d: 0.67
};
}
function getMockBookings() {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
function dateStr(yy, mm, dd) {
return `${yy}-${String(mm).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
}
return [
{ id: 1, guest_name: 'Sarah Johnson', guest_email: 'sarah@example.com', guest_phone: '(512) 555-0101', check_in: dateStr(y, m+1, d+2), check_out: dateStr(y, m+1, d+6), guests_count: 4, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 916.00, status: 'confirmed', stripe_session_id: 'cs_test_a1b2', notes: 'Anniversary trip', created_at: dateStr(y, m, d-10) },
{ id: 2, guest_name: 'Michael Chen', guest_email: 'mchen@example.com', guest_phone: '(512) 555-0102', check_in: dateStr(y, m+1, d+10), check_out: dateStr(y, m+1, d+13), guests_count: 2, has_pet: true, nightly_rate: 199, cleaning_fee: 120, pet_fee: 50, total: 767.00, status: 'confirmed', stripe_session_id: 'cs_test_c3d4', notes: 'Bringing small dog', created_at: dateStr(y, m, d-8) },
{ id: 3, guest_name: 'Emily Rodriguez', guest_email: 'emily.r@example.com', guest_phone: '(512) 555-0103', check_in: dateStr(y, m+1, d+18), check_out: dateStr(y, m+1, d+22), guests_count: 6, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 916.00, status: 'pending', stripe_session_id: null, notes: 'Family reunion', created_at: dateStr(y, m, d-3) },
{ id: 4, guest_name: 'David Kim', guest_email: 'dkim@example.com', guest_phone: '(512) 555-0104', check_in: dateStr(y, m-1, 5), check_out: dateStr(y, m-1, 8), guests_count: 3, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'completed', stripe_session_id: 'cs_test_e5f6', notes: '', created_at: dateStr(y, m-2, 15) },
{ id: 5, guest_name: 'Jessica Williams', guest_email: 'jwill@example.com', guest_phone: '(512) 555-0105', check_in: dateStr(y, m-1, 20), check_out: dateStr(y, m-1, 23), guests_count: 2, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'completed', stripe_session_id: 'cs_test_g7h8', notes: 'Weekend getaway', created_at: dateStr(y, m-2, 20) },
{ id: 6, guest_name: 'Robert Taylor', guest_email: 'rtaylor@example.com', guest_phone: '(512) 555-0106', check_in: dateStr(y, m+1, d+25), check_out: dateStr(y, m+1, d+28), guests_count: 5, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'pending', stripe_session_id: null, notes: 'Requested early check-in', created_at: dateStr(y, m, d-1) },
{ id: 7, guest_name: 'Amanda Brown', guest_email: 'abrown@example.com', guest_phone: '(512) 555-0107', check_in: dateStr(y, m-2, 10), check_out: dateStr(y, m-2, 14), guests_count: 4, has_pet: true, nightly_rate: 199, cleaning_fee: 120, pet_fee: 50, total: 966.00, status: 'cancelled', stripe_session_id: 'cs_test_i9j0', notes: 'Cancelled due to weather', created_at: dateStr(y, m-3, 1) },
{ id: 8, guest_name: 'James Wilson', guest_email: 'jwilson@example.com', guest_phone: '(512) 555-0108', check_in: dateStr(y, m-1, 12), check_out: dateStr(y, m-1, 15), guests_count: 3, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'completed', stripe_session_id: 'cs_test_k1l2', notes: '', created_at: dateStr(y, m-2, 10) },
];
}
function getMockGuests() {
return [
{ id: 1, name: 'Sarah Johnson', email: 'sarah@example.com', phone: '(512) 555-0101', notes: 'Returning guest, loves the lake view', created_at: '2026-05-10' },
{ id: 2, name: 'Michael Chen', email: 'mchen@example.com', phone: '(512) 555-0102', notes: 'Has a small dog, pet-friendly', created_at: '2026-06-02' },
{ id: 3, name: 'Emily Rodriguez', email: 'emily.r@example.com', phone: '(512) 555-0103', notes: 'Family of 6, needs pack-n-play', created_at: '2026-06-15' },
{ id: 4, name: 'David Kim', email: 'dkim@example.com', phone: '(512) 555-0104', notes: '', created_at: '2026-05-22' },
{ id: 5, name: 'Jessica Williams', email: 'jwill@example.com', phone: '(512) 555-0105', notes: 'Requested late checkout last time', created_at: '2026-05-28' },
{ id: 6, name: 'Robert Taylor', email: 'rtaylor@example.com', phone: '(512) 555-0106', notes: 'First time booking', created_at: '2026-07-01' },
{ id: 7, name: 'Amanda Brown', email: 'abrown@example.com', phone: '(512) 555-0107', notes: 'Cancelled reservation, may rebook', created_at: '2026-04-15' },
{ id: 8, name: 'James Wilson', email: 'jwilson@example.com', phone: '(512) 555-0108', notes: 'Repeat guest, 3rd stay', created_at: '2026-01-10' },
{ id: 9, name: 'Lisa Martinez', email: 'lmartinez@example.com', phone: '(512) 555-0109', notes: 'Preferred guest, no issues', created_at: '2026-03-20' },
{ id: 10, name: 'Thomas Anderson', email: 'tanderson@example.com', phone: '(512) 555-0110', notes: 'Stayed for business trip', created_at: '2026-02-14' },
];
}
function getMockAvailability(monthStr) {
// build some booked/blocked dates for the given month
const [y, m] = monthStr.split('-').map(Number);
const daysIn = new Date(y, m, 0).getDate();
const booked = [];
const blocked = [];
const now = new Date();
const todayY = now.getFullYear();
const todayM = now.getMonth() + 1;
const todayD = now.getDate();
// If viewing current month, add realistic booked/blocked
if (y === todayY && m === todayM) {
// Block some past dates
for (let i = 1; i <= Math.min(todayD, 5); i++) {
if (i < todayD - 1) blocked.push(i);
}
// Book some upcoming dates
const ranges = [[todayD + 2, todayD + 6], [todayD + 12, todayD + 14], [todayD + 20, todayD + 23]];
ranges.forEach(([s, e]) => {
for (let i = s; i <= Math.min(e, daysIn); i++) booked.push(i);
});
}
return {
month: monthStr,
booked_dates: booked.map(d => `${monthStr}-${String(d).padStart(2,'0')}`),
blocked_dates: blocked.map(d => `${monthStr}-${String(d).padStart(2,'0')}`)
};
}
// ── DASHBOARD ──
async function loadDashboard() {
const grid = $('#statsGrid');
let stats = await apiFetch('/stats');
if (!stats) stats = getMockStats();
grid.innerHTML = `
<div class="stat-card">
<div class="label">Upcoming Bookings</div>
<div class="value accent">${stats.upcoming_bookings}</div>
<div class="sub">${stats.confirmed_bookings} confirmed total</div>
</div>
<div class="stat-card">
<div class="label">Monthly Revenue</div>
<div class="value green">${formatCurrency(stats.monthly_revenue)}</div>
<div class="sub">Total: ${formatCurrency(stats.total_revenue)}</div>
</div>
<div class="stat-card">
<div class="label">Occupancy Rate (30d)</div>
<div class="value amber">${Math.round(stats.occupancy_30d * 100)}%</div>
</div>
<div class="stat-card">
<div class="label">Total Guests</div>
<div class="value">${stats.total_guests}</div>
<div class="sub">All-time guests hosted</div>
</div>
`;
}
// ── BOOKINGS ──
async function loadBookings() {
allBookings = await apiFetch('/bookings');
if (!allBookings || !Array.isArray(allBookings) || allBookings.length === 0) {
allBookings = getMockBookings();
}
filterBookings(currentBookingFilter);
}
function filterBookings(status, btn) {
currentBookingFilter = status;
// update filter buttons
$$('#bookingFilter .filter-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
else {
const fb = $(`#bookingFilter [data-status="${status}"]`);
if (fb) fb.classList.add('active');
}
filteredBookings = status === 'all'
? allBookings
: allBookings.filter(b => b.status === status);
renderBookingsTable(filteredBookings);
}
function renderBookingsTable(bookings) {
const container = $('#bookingsTable');
if (!bookings.length) {
container.innerHTML = `<div class="empty"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="15" height="14" rx="2"/><path d="M2.5 7.5h15M6.5 1.5v3M13.5 1.5v3"/></svg><p>No bookings found.</p></div>`;
return;
}
const rows = bookings.map(b => `
<tr>
<td><strong>${escapeHtml(b.guest_name)}</strong></td>
<td>${formatDate(b.check_in)}${formatDate(b.check_out)}</td>
<td>${b.guests_count} guest${b.guests_count !== 1 ? 's' : ''}${b.has_pet ? ' 🐾' : ''}</td>
<td><strong>${formatCurrency(b.total)}</strong></td>
<td><span class="badge ${b.status}">${b.status}</span></td>
<td>
<div class="actions">
${b.status === 'pending' ? `<button class="btn btn-green btn-sm" onclick="updateBookingStatus(${b.id},'confirmed')">Confirm</button>` : ''}
${b.status === 'confirmed' ? `<button class="btn btn-outline btn-sm" onclick="updateBookingStatus(${b.id},'completed')">Mark Completed</button>` : ''}
${(b.status === 'confirmed' || b.status === 'pending') ? `<button class="btn btn-danger btn-sm" onclick="updateBookingStatus(${b.id},'cancelled')">Cancel</button>` : ''}
${(b.status === 'completed' || b.status === 'cancelled') ? `<span style="font-size:11px;color:var(--muted);">—</span>` : ''}
</div>
</td>
</tr>
`).join('');
container.innerHTML = `<table>
<thead><tr>
<th>Guest</th><th>Dates</th><th>Guests</th><th>Total</th><th>Status</th><th>Actions</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
async function updateBookingStatus(id, newStatus) {
const verb = newStatus === 'completed' ? 'completed' : newStatus === 'cancelled' ? 'cancelled' : 'confirmed';
try {
await apiPatch('/booking/' + id, { status: newStatus });
toast(`Booking ${verb} successfully.`);
await loadBookings();
await loadDashboard();
} catch (e) {
// fallback: update locally
const booking = allBookings.find(b => b.id === id);
if (booking) booking.status = newStatus;
filterBookings(currentBookingFilter);
toast(`Booking ${verb} (offline mode).`, 'error');
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── CALENDAR ──
async function loadCalendar() {
const now = new Date();
if (!calendarYear) { calendarYear = now.getFullYear(); calendarMonth = now.getMonth() + 1; }
const monthStr = `${calendarYear}-${String(calendarMonth).padStart(2, '0')}`;
let data = await apiFetch('/availability?month=' + monthStr);
if (!data) data = getMockAvailability(monthStr);
// build lookup
bookedDates = {};
(data.booked_dates || []).forEach(d => bookedDates[d] = 'booked');
(data.blocked_dates || []).forEach(d => bookedDates[d] = 'blocked');
renderCalendar();
}
function renderCalendar() {
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const monthStr = `${calendarYear}-${String(calendarMonth).padStart(2, '0')}`;
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
// nav
$('#calendarNav').innerHTML = `
<button onclick="calendarPrevMonth()">←</button>
<h3>${monthNames[calendarMonth-1]} ${calendarYear}</h3>
<button onclick="calendarNextMonth()">→</button>
`;
// grid
const firstDay = new Date(calendarYear, calendarMonth - 1, 1).getDay(); // 0=Sun
const daysInMonth = new Date(calendarYear, calendarMonth, 0).getDate();
const dayHeaders = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
let html = dayHeaders.map(h => `<div class="calendar-day-header">${h}</div>`).join('');
// empty cells before first day
for (let i = 0; i < firstDay; i++) {
html += `<div class="calendar-cell empty"></div>`;
}
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`;
const isToday = dateStr === todayStr;
const status = bookedDates[dateStr] || '';
const isSelected = selectedDates.has(dateStr);
let classes = ['calendar-cell'];
if (isToday) classes.push('today');
if (status === 'booked') classes.push('booked');
if (status === 'blocked') classes.push('blocked');
if (isSelected) classes.push('selected');
let tagHtml = status
? `<div class="tag ${status}">${status}</div>`
: '';
html += `<div class="${classes.join(' ')}" onclick="toggleDate('${dateStr}', '${status}')" data-date="${dateStr}">
<div class="day-num">${day}</div>${tagHtml}
</div>`;
}
$('#calendarGrid').innerHTML = html;
updateSidePanel();
}
function toggleDate(dateStr, status) {
// Don't allow selecting already booked dates
if (status === 'booked') return;
if (selectedDates.has(dateStr)) {
selectedDates.delete(dateStr);
} else {
selectedDates.add(dateStr);
}
// re-render just the cells
document.querySelectorAll('.calendar-cell[data-date]').forEach(cell => {
const d = cell.dataset.date;
if (selectedDates.has(d)) {
cell.classList.add('selected');
} else {
cell.classList.remove('selected');
}
});
updateSidePanel();
}
function updateSidePanel() {
const container = $('#selectedDates');
const blockBtn = $('#blockDatesBtn');
const clearBtn = $('#clearSelectionBtn');
if (selectedDates.size === 0) {
container.innerHTML = '<span style="font-size:12px;color:var(--muted);">No dates selected</span>';
blockBtn.disabled = true;
clearBtn.disabled = true;
} else {
const sorted = [...selectedDates].sort();
container.innerHTML = sorted.map(d =>
`<span class="chip">${d} <span class="remove" onclick="event.stopPropagation(); removeSelectedDate('${d}')">×</span></span>`
).join('');
blockBtn.disabled = false;
clearBtn.disabled = false;
}
}
function removeSelectedDate(dateStr) {
selectedDates.delete(dateStr);
renderCalendar();
}
function clearDateSelection() {
selectedDates.clear();
renderCalendar();
}
async function blockSelectedDates() {
if (selectedDates.size === 0) return;
const dates = [...selectedDates];
let success = 0;
let failed = 0;
for (const date of dates) {
try {
await apiPost('/block-date', { date, reason: 'admin_block' });
bookedDates[date] = 'blocked';
success++;
} catch (e) {
failed++;
// still mark locally as fallback
bookedDates[date] = 'blocked';
}
}
selectedDates.clear();
renderCalendar();
if (failed === 0) {
toast(`${success} date(s) blocked successfully.`);
} else {
toast(`${success} blocked, ${failed} failed (offline mode).`, 'error');
}
}
function calendarPrevMonth() {
calendarMonth--;
if (calendarMonth < 1) { calendarMonth = 12; calendarYear--; }
selectedDates.clear();
loadCalendar();
}
function calendarNextMonth() {
calendarMonth++;
if (calendarMonth > 12) { calendarMonth = 1; calendarYear++; }
selectedDates.clear();
loadCalendar();
}
// ── GUESTS ──
async function loadGuests() {
let guests = await apiFetch('/guests');
if (!guests || !Array.isArray(guests) || guests.length === 0) {
guests = getMockGuests();
}
allGuests = guests;
const container = $('#guestsList');
if (!guests.length) {
container.innerHTML = `<div class="empty"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="6" r="2.5"/><path d="M1.5 18c0-3 2.5-5.5 5.5-5.5"/></svg><p>No guests yet.</p></div>`;
return;
}
container.innerHTML = guests.map(g => `
<div class="guest-card">
<div class="guest-info">
<h4>${escapeHtml(g.name)}</h4>
<div class="email">${escapeHtml(g.email)}</div>
${g.phone ? `<div class="phone">${escapeHtml(g.phone)}</div>` : ''}
${g.notes ? `<div class="notes">"${escapeHtml(g.notes)}"</div>` : ''}
</div>
<div class="guest-meta">
Guest since ${g.created_at ? formatDate(g.created_at) : '—'}
</div>
</div>
`).join('');
}
// ── SETTINGS ──
function saveSettings() {
const data = {
nightly_rate: parseInt($('#settingNightlyRate').value) || 199,
cleaning_fee: parseInt($('#settingCleaningFee').value) || 120,
pet_fee: parseInt($('#settingPetFee').value) || 50,
max_guests: parseInt($('#settingMaxGuests').value) || 6,
};
localStorage.setItem('msd_settings', JSON.stringify(data));
toast('Settings saved locally.');
}
function loadSettings() {
try {
const saved = JSON.parse(localStorage.getItem('msd_settings'));
if (saved) {
if (saved.nightly_rate) $('#settingNightlyRate').value = saved.nightly_rate;
if (saved.cleaning_fee) $('#settingCleaningFee').value = saved.cleaning_fee;
if (saved.pet_fee) $('#settingPetFee').value = saved.pet_fee;
if (saved.max_guests) $('#settingMaxGuests').value = saved.max_guests;
}
} catch (e) { /* ignore */ }
}
// ── INIT ──
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
loadSettings();
});
</script>
</body>
</html>