chore: add project scaffolding and mockups
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.sqlite
|
||||
.env
|
||||
venv/
|
||||
@@ -0,0 +1,361 @@
|
||||
# Beach Direct — Product Project
|
||||
|
||||
**White-label direct-booking platform for vacation rental owners escaping platform fees.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Status** | 🚧 Pre-launch — reference implementation in progress |
|
||||
| **Reference** | Moore Sunny Daze (Maravilla 1403, Miramar Beach FL) |
|
||||
| **Domain** | beachdirect.io |
|
||||
| **Repo** | git.itpropartner.com/ippadmin/mooresunnydaze |
|
||||
| **Pricing** | $997–$3,997 setup + $47–$197/mo |
|
||||
|
||||
---
|
||||
|
||||
## 1. Product Overview
|
||||
|
||||
### The Problem
|
||||
|
||||
Vacation rental owners on VRBO and Airbnb lose 10-15% of every booking to platform fees. A property booked 150 nights/year at $295/night loses **$4,425-$6,637 annually** to platforms — money that goes to Expedia Group or Airbnb, not the owner.
|
||||
|
||||
Platforms also own the guest relationship. Owners can't email past guests, build a mailing list, or create repeat-direct bookings without violating platform terms.
|
||||
|
||||
### The Solution
|
||||
|
||||
Beach Direct gives owners a professional direct-booking website with Stripe payments, an admin dashboard, and guest CRM — for a flat monthly fee with **zero per-booking charges**.
|
||||
|
||||
### Value Proposition
|
||||
|
||||
| | VRBO/Airbnb | Beach Direct |
|
||||
|---|---|---|
|
||||
| **Booking fee** | 10-15% | $0 |
|
||||
| **Guest data** | Platform-owned | Owner-owned |
|
||||
| **Branding** | Platform-branded | Owner's brand |
|
||||
| **Email marketing** | Prohibited | Built-in |
|
||||
| **Pricing control** | Algorithm-influenced | Full control |
|
||||
| **Annual cost** (150 nights @ $295) | ~$5,500 | $564–$2,364 flat |
|
||||
|
||||
---
|
||||
|
||||
## 2. Target Market
|
||||
|
||||
### Primary: Destin / Miramar Beach / 30A Corridor
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Vacation rentals in area | 15,000+ |
|
||||
| Average nightly rate | $250-$500 |
|
||||
| Platform dependency | ~90% listed on VRBO or Airbnb |
|
||||
| Owner density | High — owners talk to neighbors |
|
||||
| Seasonality | Strong peak (May-Aug), shoulder (Mar-Apr/Sep-Oct) |
|
||||
|
||||
### Ideal Customer Profile
|
||||
|
||||
- Owns 1-3 vacation rental properties
|
||||
- Currently listed on VRBO and/or Airbnb
|
||||
- Frustrated with platform fees (aware they exist)
|
||||
- Has some returning guests or social following
|
||||
- Values professionalism but doesn't want to manage tech
|
||||
- Annual platform fees > $2,000 (the crossover point where Beach Direct saves money)
|
||||
|
||||
### Expansion Path
|
||||
|
||||
1. Destin/Miramar Beach (initial — 2026)
|
||||
2. 30A corridor (Rosemary Beach, Seaside, Alys Beach)
|
||||
3. Panama City Beach
|
||||
4. Gulf Shores / Orange Beach, AL
|
||||
5. Coastal expansion (Florida, Alabama, South Carolina, Texas)
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Architecture
|
||||
|
||||
### System Design
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Core Server │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Property │ │ Property │ │ Property │ ... │
|
||||
│ │ A (8911) │ │ B (8912) │ │ C (8913) │ │
|
||||
│ │ FastAPI │ │ FastAPI │ │ FastAPI │ │
|
||||
│ │ SQLite │ │ SQLite │ │ SQLite │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ Caddy Reverse Proxy │ │
|
||||
│ │ propertyA.com → :8911 │ │
|
||||
│ │ propertyB.com → :8912 │ │
|
||||
│ │ admin.propertyA.com → :8911/admin │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Per-Property Isolation
|
||||
|
||||
Each property runs its own FastAPI instance on a dedicated port with its own SQLite database. This gives:
|
||||
|
||||
- **Data isolation** — one owner's data never touches another's
|
||||
- **Independent scaling** — a high-traffic property doesn't affect others
|
||||
- **Simple backup** — one `.sqlite` file per property
|
||||
- **Zero tenant logic** — no multi-tenancy code, no customer_id columns
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology | Rationale |
|
||||
|---|---|---|
|
||||
| **Frontend** | Single-file HTML/CSS/JS | No build step, instant deploy, any host |
|
||||
| **Backend** | FastAPI (Python) + uvicorn | Fast, async, well-documented |
|
||||
| **Database** | SQLite per property | Zero maintenance, portable, backup = file copy |
|
||||
| **Payments** | Stripe Checkout (hosted) | PCI-compliant, no card data on our servers |
|
||||
| **Proxy** | Caddy | Auto-SSL via Let's Encrypt, simple config |
|
||||
| **Process** | systemd | Built-in, reliable, zero-dependency |
|
||||
| **Email** | Resend or Postmark | Transactional (confirmations, pre-arrival) |
|
||||
|
||||
### Single-File Frontend Philosophy
|
||||
|
||||
No React, no webpack, no npm. Each property's landing page is a single `.html` file with inline CSS and JS. This is intentional:
|
||||
|
||||
- **Instant deploy** — `cp index.html /var/www/property/`
|
||||
- **No build failures** — what you write is what ships
|
||||
- **Any developer can edit** — no framework knowledge needed
|
||||
- **Fast loads** — single HTTP request, no bundle
|
||||
- **Easy to theme** — clone and customize per property
|
||||
|
||||
---
|
||||
|
||||
## 4. Pricing Model
|
||||
|
||||
### Tiers
|
||||
|
||||
| Tier | Setup | Monthly | Includes |
|
||||
|---|---|---|---|
|
||||
| **Starter** | $997 | $47/mo | Landing page, booking widget, admin dashboard, Stripe integration |
|
||||
| **Pro** | $2,497 | $97/mo | + Email automation, guest CRM, seasonal pricing, custom domain setup |
|
||||
| **Full Service** | $3,997 | $197/mo | + Channel sync, analytics dashboard, priority support, photo sourcing |
|
||||
|
||||
### No Per-Booking Fees — Ever
|
||||
|
||||
This is the core differentiator. VRBO charges 8% + $0-499 host fee. Airbnb charges 3% host + 14% guest. Beach Direct charges zero per booking. The owner pays Stripe's 2.9% + $0.30 — same as any online business — and nothing more.
|
||||
|
||||
At 150 booked nights/year at $295/night:
|
||||
|
||||
| | VRBO | Airbnb | Beach Direct (Pro) |
|
||||
|---|---|---|---|
|
||||
| Annual platform fees | $3,540 | $1,328 (host) | $0 |
|
||||
| Guest-paid fees | $1,770 | $6,195 | $0 |
|
||||
| Owner annual cost | $3,540 | $1,328 | $1,164 (Starter) |
|
||||
| Owner savings | — | — | **$164-$2,376/year** |
|
||||
|
||||
### Revenue Projections
|
||||
|
||||
| Clients | Setup (one-time) | Monthly Recurring | Annual Total |
|
||||
|---|---|---|---|
|
||||
| 5 (Pro) | $12,485 | $485/mo | $18,305 |
|
||||
| 10 (Pro) | $24,970 | $970/mo | $36,610 |
|
||||
| 25 (mix) | $56,000 | $2,150/mo | $81,800 |
|
||||
| 50 (mix) | $112,000 | $4,300/mo | $163,600 |
|
||||
| 100 (mix) | $224,000 | $8,600/mo | $327,200 |
|
||||
|
||||
*Mix = 60% Starter, 30% Pro, 10% Full Service.*
|
||||
|
||||
---
|
||||
|
||||
## 5. Go-to-Market
|
||||
|
||||
### Phase 1: Case Study (Current)
|
||||
|
||||
Moore Sunny Daze — Tim and Ashley Moore's Maravilla 1403 in Miramar Beach. This is the reference implementation:
|
||||
|
||||
- Produces real booking data and revenue comparisons
|
||||
- Generates testimonials and social proof
|
||||
- Proves the concept before any cold outreach
|
||||
|
||||
### Phase 2: Neighbor Outreach
|
||||
|
||||
Tim knows other owners in Maravilla. Once his site generates direct bookings:
|
||||
|
||||
1. Tim refers neighbors → neighbors see his site → they want one
|
||||
2. Offer 2 months free for every successful referral
|
||||
3. Target: 5 properties in Maravilla alone (28-unit building)
|
||||
|
||||
### Phase 3: Direct Outreach
|
||||
|
||||
- Scrape VRBO listings in Destin/Miramar Beach
|
||||
- Identify owners with: 4+ star rating, 10+ reviews, active calendar
|
||||
- Cold email: "You paid ~$3,500 in VRBO fees last year. Here's what that looks like without them."
|
||||
- Include real savings calculation based on their listed rate
|
||||
|
||||
### Phase 4: Beach Direct Marketing Site
|
||||
|
||||
- `beachdirect.io` — landing page with savings calculator
|
||||
- Input: nightly rate, nights/year → Output: annual savings vs VRBO
|
||||
- Case study section featuring Moore Sunny Daze
|
||||
- Pricing page with clear tiers
|
||||
- Facebook ads targeting "Vacation Rental Owners" interest group
|
||||
|
||||
### Phase 5: Facebook Groups
|
||||
|
||||
- Join "Destin Vacation Rental Owners" and similar groups
|
||||
- Don't pitch — answer questions about direct booking
|
||||
- Share Moore Sunny Daze as "what we built for a friend"
|
||||
- Let the savings math do the selling
|
||||
|
||||
---
|
||||
|
||||
## 6. Competitive Landscape
|
||||
|
||||
| Competitor | Model | Price | Our Advantage |
|
||||
|---|---|---|---|
|
||||
| **VRBO** | 8% host + 5-12% guest fee | Variable | No booking fees, owner owns data |
|
||||
| **Airbnb** | 3% host + ~14% guest | Variable | No guest fees, direct relationship |
|
||||
| **Lodgify** | SaaS + channel manager | $36-289/mo | Simpler, no channel bloat, lower price |
|
||||
| **OwnerRez** | SaaS + PM tools | $40-130/mo | Less complex, single-file sites |
|
||||
| **Houfy** | Free direct booking | Free | Professional design, Stripe integration |
|
||||
| **Wix/WordPress** | DIY site builder | $17-50/mo | Purpose-built for rentals, booking built in |
|
||||
|
||||
### Our Position
|
||||
|
||||
Beach Direct occupies the space between "DIY website builder" (too much work) and "full property management software" (too complex, too expensive). We're the **done-for-you direct booking solution** — professional site, booking system, admin dashboard, zero per-booking fees.
|
||||
|
||||
---
|
||||
|
||||
## 7. Technical Differentiators
|
||||
|
||||
1. **Zero per-booking fees** — flat monthly pricing only. No competitor offers this with a full booking system.
|
||||
|
||||
2. **Owner owns guest data** — email, phone, stay history. This is the asset platforms hoard.
|
||||
|
||||
3. **Single-file deployable sites** — no JavaScript build step. The landing page is one HTML file. Anyone can host it, anywhere.
|
||||
|
||||
4. **SQLite simplicity** — no managed database costs. The database is a file. Backup is `cp db.sqlite backup.sqlite`.
|
||||
|
||||
5. **Stripe Connect** — payments go directly to the owner's bank account. We never touch the money.
|
||||
|
||||
6. **Per-property isolation** — one FastAPI instance per property. No multi-tenant complexity. One property goes down, others stay up.
|
||||
|
||||
7. **Caddy auto-SSL** — Let's Encrypt certificates with zero configuration. No certbot, no renewal cron jobs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk Analysis
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **Double-booking** | Medium | High | Channel sync (v3), real-time availability checks |
|
||||
| **Payment disputes** | Low | Medium | Stripe handles chargebacks; clear cancellation policy |
|
||||
| **SEO competition** | Medium | Medium | Content strategy, area guides, backlinks from local businesses |
|
||||
| **Owner tech literacy** | High | Low | Dead-simple admin, phone support, setup included in fee |
|
||||
| **Stripe account issues** | Low | High | Owners connect their own Stripe; we guide setup |
|
||||
| **Platform retaliation** | Low | Low | No platform integration means no TOS to violate |
|
||||
| **Domain/email migration** | Medium | Medium | Documented migration playbook; included in Pro tier |
|
||||
| **Seasonality** | Low | Medium | Monthly recurring smooths seasonal dips |
|
||||
|
||||
---
|
||||
|
||||
## 9. Brand & Voice
|
||||
|
||||
### Domain
|
||||
|
||||
`beachdirect.io` — short, clear, says what it does.
|
||||
|
||||
### Brand Voice
|
||||
|
||||
- **Direct** — "Stop paying platform fees. Book direct."
|
||||
- **Owner-focused** — the hero is the property owner, not us
|
||||
- **Math-driven** — lead with savings, not features
|
||||
- **Professional** — coastal aesthetic, not beach-bum casual
|
||||
|
||||
### Marketing Site Sections
|
||||
|
||||
1. **Hero** — Savings calculator (nightly rate × nights = your fee savings)
|
||||
2. **How It Works** — 3-step: We build → You approve → Guests book direct
|
||||
3. **Case Study** — Moore Sunny Daze with real numbers
|
||||
4. **Pricing** — Three tiers, clear inclusions
|
||||
5. **FAQ** — "Will my VRBO guests find my direct site?" (yes, with strategy)
|
||||
|
||||
---
|
||||
|
||||
## 10. Development Roadmap
|
||||
|
||||
### v1 — Single Property (Q3 2026)
|
||||
|
||||
- [x] Moore Sunny Daze backend API (FastAPI + SQLite + Stripe)
|
||||
- [x] Landing page (Coastal Elegant theme)
|
||||
- [x] Admin dashboard (dark theme)
|
||||
- [ ] Domain migration (mooresunnydaze.com → Core)
|
||||
- [ ] Stripe live mode
|
||||
- [ ] First direct booking
|
||||
|
||||
### v2 — Multi-Property (Q4 2026)
|
||||
|
||||
- [ ] Property onboarding script (clone MSD, assign port, init DB)
|
||||
- [ ] Central admin overview (all properties on one dashboard)
|
||||
- [ ] Email automation (confirmation, pre-arrival, post-stay)
|
||||
- [ ] Guest CRM improvements (tags, notes, stay history)
|
||||
|
||||
### v3 — Channel Sync (Q1 2027)
|
||||
|
||||
- [ ] iCal import/export for VRBO/Airbnb availability sync
|
||||
- [ ] Prevent double-bookings across platforms
|
||||
- [ ] Rate parity monitoring
|
||||
|
||||
### v4 — Smart Pricing (Q2 2027)
|
||||
|
||||
- [ ] Dynamic pricing based on demand, season, local events
|
||||
- [ ] Competitor rate scraping
|
||||
- [ ] Occupancy optimization
|
||||
|
||||
### v5 — Marketplace (Q3 2027)
|
||||
|
||||
- [ ] Multi-property search across all Beach Direct clients
|
||||
- [ ] "More properties from this owner"
|
||||
- [ ] Area-based discovery (all Miramar Beach properties)
|
||||
|
||||
---
|
||||
|
||||
## 11. Reference Implementation
|
||||
|
||||
**Moore Sunny Daze** — the free project for Tim and Ashley Moore that proves the concept.
|
||||
|
||||
| Detail | Value |
|
||||
|---|---|
|
||||
| Property | Maravilla 1403, Miramar Beach FL |
|
||||
| Size | 3BR/2BA, sleeps 8 |
|
||||
| Rate | $295/night peak, $225 off-peak |
|
||||
| VRBO fees saved | ~$3,500-$5,000/year |
|
||||
| Site | mooresunnydaze.com (in development) |
|
||||
| Repo | git.itpropartner.com/ippadmin/mooresunnydaze |
|
||||
| Status | Backend live, frontend in development |
|
||||
|
||||
---
|
||||
|
||||
## 12. Operational Playbook
|
||||
|
||||
Every new Beach Direct client follows this process:
|
||||
|
||||
### Onboarding (3-5 days)
|
||||
|
||||
1. **Scrape existing listings** — Wix site, VRBO, Airbnb (photos, content, pricing, reviews)
|
||||
2. **Research area** — restaurants, attractions, shopping within 3 miles
|
||||
3. **Build 3 theme mockups** — Existing Style, Bold & Energetic, Coastal Elegant
|
||||
4. **Client selects theme** → build full landing page with booking widget
|
||||
5. **Build admin dashboard** — dark theme, booking management, guest directory
|
||||
6. **Domain migration** — GoDaddy → Cloudflare → Core
|
||||
7. **Email setup** — Gmail → MXroute with forwarding
|
||||
8. **Stripe setup** — guide owner through account creation
|
||||
9. **Go live** — DNS cutover, SSL, test booking
|
||||
|
||||
### Monthly Maintenance
|
||||
|
||||
- Server uptime monitoring (Uptime Kuma)
|
||||
- SSL auto-renewal (Caddy)
|
||||
- Database backups (S3, daily)
|
||||
- Stripe webhook health checks
|
||||
- Monthly report to owner (bookings, revenue, guest count)
|
||||
|
||||
---
|
||||
|
||||
*Document version: 1.0 — August 7, 2026*
|
||||
*Author: Sho'Nuff Brown, IT Pro Partner*
|
||||
@@ -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>
|
||||
@@ -0,0 +1,905 @@
|
||||
<!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 — Book Direct & Save 13% | Maravilla 1403, Miramar Beach FL</title>
|
||||
<meta name="description" content="Book Moore Sunny Daze direct and save 13% vs platform fees. 3BR/2BA Gulf-view condo at Maravilla 1403, Miramar Beach FL. Sleeps 8. Owned by Tim & Ashley Moore.">
|
||||
<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=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ===== RESET & BASE ===== */
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
html{scroll-behavior:smooth;font-size:16px}
|
||||
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;color:#2c3a4b;background:#faf8f5;line-height:1.7;overflow-x:hidden}
|
||||
h1,h2,h3,h4,h5,h6{font-family:'Playfair Display',Georgia,serif;font-weight:600;line-height:1.25;color:#1a2744}
|
||||
h2{font-size:clamp(1.75rem,4vw,2.5rem);margin-bottom:0.5rem}
|
||||
h3{font-size:clamp(1.25rem,3vw,1.6rem)}
|
||||
a{color:#1a2744;text-decoration:none;transition:color .3s}
|
||||
a:hover{color:#c9a84c}
|
||||
img{max-width:100%;height:auto;display:block}
|
||||
section{padding:5rem 1.5rem}
|
||||
.container{max-width:1200px;margin:0 auto}
|
||||
|
||||
/* ===== GOLD ACCENT HELPER ===== */
|
||||
.gold{color:#c9a84c}
|
||||
.gold-bg{background:#c9a84c}
|
||||
.gold-border{border-color:#c9a84c}
|
||||
|
||||
/* ===== NAVIGATION ===== */
|
||||
header{position:fixed;top:0;left:0;right:0;z-index:1000;padding:1rem 1.5rem;transition:background .4s,padding .4s,box-shadow .4s}
|
||||
header.scrolled{background:rgba(250,248,245,.97);box-shadow:0 2px 20px rgba(0,0,0,.08);padding:.6rem 1.5rem}
|
||||
header nav{max-width:1200px;margin:0 auto;display:flex;align-items:center;justify-content:space-between}
|
||||
header .logo{font-family:'Playfair Display',Georgia,serif;font-size:1.3rem;font-weight:700;color:#fff;transition:color .4s;letter-spacing:.5px}
|
||||
header.scrolled .logo{color:#1a2744}
|
||||
header .logo span{color:#c9a84c}
|
||||
header .nav-links{display:flex;gap:2rem;list-style:none}
|
||||
header .nav-links a{color:#fff;font-size:.85rem;font-weight:500;letter-spacing:.8px;text-transform:uppercase;transition:color .3s}
|
||||
header.scrolled .nav-links a{color:#1a2744}
|
||||
header .nav-links a:hover{color:#c9a84c}
|
||||
.hamburger{display:none;flex-direction:column;gap:5px;cursor:pointer;z-index:1001;background:none;border:none;padding:4px}
|
||||
.hamburger span{display:block;width:26px;height:2px;background:#fff;transition:all .3s}
|
||||
header.scrolled .hamburger span{background:#1a2744}
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero{position:relative;height:100vh;min-height:600px;display:flex;align-items:center;justify-content:center;text-align:center;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;background:url('https://static.wixstatic.com/media/0a1b51_e07cc4dbb6c74815812ad594235b011e~mv2.jpg') center/cover no-repeat;will-change:transform;transform:translateZ(0)}
|
||||
.hero-overlay{position:absolute;inset:0;background:linear-gradient(to bottom,rgba(26,39,68,.55),rgba(26,39,68,.35),rgba(26,39,68,.05))}
|
||||
.hero-content{position:relative;z-index:2;max-width:800px;padding:0 1.5rem}
|
||||
.hero-content .badge{display:inline-block;background:rgba(201,168,76,.9);color:#fff;font-size:.75rem;font-weight:600;letter-spacing:2px;text-transform:uppercase;padding:.5rem 1.2rem;border-radius:50px;margin-bottom:1.5rem}
|
||||
.hero-content h1{font-size:clamp(2.5rem,7vw,5rem);font-weight:700;color:#fff;margin-bottom:.5rem;line-height:1.1}
|
||||
.hero-content h1 span{color:#c9a84c}
|
||||
.hero-content .subtitle{font-size:clamp(1rem,2.5vw,1.25rem);color:rgba(255,255,255,.9);margin-bottom:.5rem;font-weight:300;letter-spacing:1px}
|
||||
.hero-content .location{font-size:.9rem;color:rgba(255,255,255,.75);margin-bottom:2rem;letter-spacing:.5px}
|
||||
.hero-content .cta{display:inline-block;background:#c9a84c;color:#fff;padding:1rem 2.5rem;font-size:1rem;font-weight:600;letter-spacing:1px;text-transform:uppercase;border-radius:4px;transition:all .3s;border:none;cursor:pointer}
|
||||
.hero-content .cta:hover{background:#b8943f;transform:translateY(-2px);box-shadow:0 8px 25px rgba(201,168,76,.3);color:#fff}
|
||||
.hero-content .cta-outline{display:inline-block;border:2px solid #fff;color:#fff;padding:.85rem 2.2rem;font-size:.9rem;font-weight:500;letter-spacing:1px;text-transform:uppercase;border-radius:4px;margin-left:1rem;transition:all .3s;cursor:pointer;background:transparent}
|
||||
.hero-content .cta-outline:hover{background:#fff;color:#1a2744}
|
||||
.hero-scroll{position:absolute;bottom:2rem;left:50%;transform:translateX(-50%);z-index:2;animation:bounce 2s infinite}
|
||||
.hero-scroll svg{width:30px;height:30px;stroke:#fff;fill:none;stroke-width:2}
|
||||
|
||||
@keyframes bounce{0%,20%,50%,80%,100%{transform:translateX(-50%) translateY(0)}40%{transform:translateX(-50%) translateY(-8px)}60%{transform:translateX(-50%) translateY(-4px)}}
|
||||
|
||||
/* ===== SECTION HEADERS ===== */
|
||||
.section-header{text-align:center;margin-bottom:3.5rem}
|
||||
.section-header .tag{display:inline-block;color:#c9a84c;font-size:.75rem;font-weight:600;letter-spacing:2px;text-transform:uppercase;margin-bottom:.5rem}
|
||||
.section-header h2{position:relative;padding-bottom:1rem}
|
||||
.section-header h2::after{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:60px;height:2px;background:#c9a84c}
|
||||
|
||||
/* ===== PROPERTY DESCRIPTION ===== */
|
||||
.property-desc{background:#fff}
|
||||
.property-desc .container{display:grid;grid-template-columns:1fr 1fr;gap:4rem;align-items:center}
|
||||
.property-desc .desc-content p{font-size:1.05rem;color:#5a6b7a;margin-bottom:1.2rem;line-height:1.8}
|
||||
.property-desc .desc-content .highlights{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:1.5rem}
|
||||
.property-desc .desc-content .highlight-item{display:flex;align-items:center;gap:.6rem;font-size:.9rem;color:#1a2744;padding:.5rem 0}
|
||||
.property-desc .desc-content .highlight-item svg{width:18px;height:18px;color:#c9a84c;flex-shrink:0}
|
||||
.property-desc .desc-image{position:relative;border-radius:4px;overflow:hidden;box-shadow:0 20px 40px rgba(0,0,0,.1)}
|
||||
.property-desc .desc-image img{width:100%;height:400px;object-fit:cover}
|
||||
|
||||
/* ===== BEDROOMS ===== */
|
||||
.bedrooms{background:#faf8f5}
|
||||
.bedrooms-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:2rem}
|
||||
.bedroom-card{background:#fff;border-radius:4px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.06);transition:transform .3s,box-shadow .3s}
|
||||
.bedroom-card:hover{transform:translateY(-6px);box-shadow:0 12px 30px rgba(0,0,0,.1)}
|
||||
.bedroom-card img{width:100%;height:220px;object-fit:cover}
|
||||
.bedroom-card .card-body{padding:1.5rem}
|
||||
.bedroom-card h3{margin-bottom:.25rem}
|
||||
.bedroom-card .room-type{color:#c9a84c;font-size:.85rem;font-weight:500;margin-bottom:.5rem}
|
||||
|
||||
/* ===== PHOTO GALLERY ===== */
|
||||
.gallery{background:#fff;padding:5rem 0}
|
||||
.gallery .gallery-scroll{display:flex;gap:1rem;overflow-x:auto;padding:0 1.5rem;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none}
|
||||
.gallery .gallery-scroll::-webkit-scrollbar{display:none}
|
||||
.gallery .gallery-item{flex:0 0 auto;width:320px;height:240px;border-radius:4px;overflow:hidden;scroll-snap-align:start;transition:transform .3s}
|
||||
.gallery .gallery-item:hover{transform:scale(1.03)}
|
||||
.gallery .gallery-item img{width:100%;height:100%;object-fit:cover}
|
||||
|
||||
/* ===== AMENITIES ===== */
|
||||
.amenities{background:#f6f3ee}
|
||||
.amenities-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1.2rem}
|
||||
.amenity-item{background:#fff;padding:1.5rem 1rem;text-align:center;border-radius:4px;transition:transform .3s,box-shadow .3s;border:1px solid transparent}
|
||||
.amenity-item:hover{transform:translateY(-3px);box-shadow:0 8px 25px rgba(0,0,0,.08);border-color:#c9a84c}
|
||||
.amenity-item svg{width:32px;height:32px;color:#c9a84c;margin-bottom:.75rem}
|
||||
.amenity-item .amenity-label{font-size:.8rem;font-weight:500;color:#1a2744;line-height:1.3}
|
||||
|
||||
/* ===== AREA GUIDE ===== */
|
||||
.area-guide{background:#fff}
|
||||
.area-tabs{display:flex;justify-content:center;gap:.5rem;margin-bottom:2.5rem;flex-wrap:wrap}
|
||||
.area-tab{padding:.6rem 1.5rem;border:1px solid #d4d9de;background:transparent;font-size:.85rem;font-weight:500;cursor:pointer;border-radius:4px;transition:all .3s;color:#5a6b7a;font-family:'Inter',sans-serif}
|
||||
.area-tab.active,.area-tab:hover{background:#c9a84c;border-color:#c9a84c;color:#fff}
|
||||
.area-content{display:none;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1.2rem}
|
||||
.area-content.active{display:grid}
|
||||
.area-card{background:#faf8f5;padding:1.5rem;border-radius:4px;border-left:3px solid #c9a84c;transition:transform .3s}
|
||||
.area-card:hover{transform:translateX(4px)}
|
||||
.area-card h4{font-family:'Playfair Display',serif;margin-bottom:.3rem;color:#1a2744}
|
||||
.area-card .area-type{font-size:.75rem;color:#c9a84c;text-transform:uppercase;letter-spacing:1px;font-weight:600;margin-bottom:.3rem}
|
||||
.area-card p{font-size:.85rem;color:#5a6b7a}
|
||||
|
||||
/* ===== OWNERS ===== */
|
||||
.owners{background:#1a2744;color:#fff}
|
||||
.owners .container{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center}
|
||||
.owners .owner-image{border-radius:4px;overflow:hidden;box-shadow:0 20px 40px rgba(0,0,0,.3)}
|
||||
.owners .owner-image img{width:100%;height:450px;object-fit:cover}
|
||||
.owners .owner-content h2{color:#fff;margin-bottom:1rem}
|
||||
.owners .owner-content h2::after{background:#c9a84c;left:0;transform:none}
|
||||
.owners .owner-content p{color:rgba(255,255,255,.8);margin-bottom:1rem;line-height:1.8}
|
||||
.owners .signature{font-family:'Playfair Display',serif;font-style:italic;color:#c9a84c;font-size:1.3rem;margin-top:1.5rem}
|
||||
|
||||
/* ===== BOOKING WIDGET ===== */
|
||||
.booking{background:linear-gradient(to bottom,#faf8f5,#f0ece5)}
|
||||
.booking-widget{max-width:650px;margin:0 auto;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.1);overflow:hidden}
|
||||
.booking-widget-header{background:#1a2744;padding:1.5rem 2rem;text-align:center}
|
||||
.booking-widget-header h3{color:#fff;font-size:1.4rem;margin-bottom:.25rem}
|
||||
.booking-widget-header .save-badge{display:inline-block;background:#c9a84c;color:#fff;font-size:.75rem;font-weight:600;letter-spacing:1px;padding:.3rem 1rem;border-radius:50px;margin-top:.5rem}
|
||||
.booking-widget-body{padding:2rem}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
|
||||
.form-group{display:flex;flex-direction:column;gap:.35rem}
|
||||
.form-group.full{grid-column:1/-1}
|
||||
.form-group label{font-size:.8rem;font-weight:600;color:#1a2744;letter-spacing:.5px;text-transform:uppercase}
|
||||
.form-group input,.form-group select{border:1px solid #d4d9de;border-radius:4px;padding:.75rem 1rem;font-size:.95rem;color:#2c3a4b;background:#faf8f5;font-family:'Inter',sans-serif;transition:border-color .3s,box-shadow .3s;width:100%}
|
||||
.form-group input:focus,.form-group select:focus{outline:none;border-color:#c9a84c;box-shadow:0 0 0 3px rgba(201,168,76,.15)}
|
||||
.form-group input::placeholder{color:#a0aab4}
|
||||
.checkbox-group{display:flex;align-items:center;gap:.6rem;margin:1rem 0}
|
||||
.checkbox-group input[type="checkbox"]{width:18px;height:18px;accent-color:#c9a84c;cursor:pointer}
|
||||
.checkbox-group label{font-size:.9rem;color:#5a6b7a;cursor:pointer}
|
||||
.checkbox-group .pet-note{font-size:.75rem;color:#a0aab4;margin-left:.25rem}
|
||||
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.9rem 2rem;font-size:.95rem;font-weight:600;border:none;border-radius:4px;cursor:pointer;transition:all .3s;font-family:'Inter',sans-serif;letter-spacing:.5px;width:100%}
|
||||
.btn-primary{background:#c9a84c;color:#fff;text-transform:uppercase;letter-spacing:1px}
|
||||
.btn-primary:hover{background:#b8943f;transform:translateY(-2px);box-shadow:0 6px 20px rgba(201,168,76,.3)}
|
||||
.btn-primary:disabled{background:#ccc;cursor:not-allowed;transform:none;box-shadow:none}
|
||||
.btn-book{background:#1a2744;color:#fff;text-transform:uppercase;letter-spacing:1px;margin-top:.75rem}
|
||||
.btn-book:hover{background:#2a3d5a;transform:translateY(-2px);box-shadow:0 6px 20px rgba(26,39,68,.3)}
|
||||
|
||||
.quote-result{background:#f6f3ee;border-radius:4px;padding:1.5rem;margin-top:1.5rem;display:none;animation:fadeInUp .4s ease}
|
||||
.quote-result.show{display:block}
|
||||
.quote-result h4{color:#1a2744;margin-bottom:1rem;font-size:1.1rem}
|
||||
.quote-row{display:flex;justify-content:space-between;padding:.5rem 0;font-size:.9rem;color:#5a6b7a;border-bottom:1px solid rgba(0,0,0,.06)}
|
||||
.quote-row.total{font-weight:700;color:#1a2744;font-size:1.1rem;border-bottom:none;padding-top:.75rem;margin-top:.25rem;border-top:2px solid #c9a84c}
|
||||
.quote-nights{font-size:.8rem;color:#c9a84c;font-weight:500}
|
||||
.booking-step2{display:none;animation:fadeInUp .4s ease}
|
||||
.booking-step2.show{display:block}
|
||||
.booking-divider{display:flex;align-items:center;gap:1rem;margin:1.5rem 0;color:#a0aab4;font-size:.8rem;text-transform:uppercase;letter-spacing:1px}
|
||||
.booking-divider::before,.booking-divider::after{content:'';flex:1;height:1px;background:#d4d9de}
|
||||
|
||||
#booking-error,#quote-error{color:#e74c3c;font-size:.85rem;margin-top:.5rem;display:none}
|
||||
#booking-error.show,#quote-error.show{display:block}
|
||||
.spinner{display:none;width:20px;height:20px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
|
||||
.btn.loading .spinner{display:inline-block}
|
||||
.btn.loading .btn-text{display:none}
|
||||
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@keyframes fadeInUp{from{opacity:0;transform:translateY(15px)}to{opacity:1;transform:translateY(0)}}
|
||||
|
||||
/* ===== FADE-IN ON SCROLL ===== */
|
||||
.fade-in{opacity:0;transform:translateY(30px);transition:opacity .7s ease,transform .7s ease}
|
||||
.fade-in.visible{opacity:1;transform:translateY(0)}
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
footer{background:#1a2744;color:rgba(255,255,255,.7);padding:3rem 1.5rem;text-align:center}
|
||||
footer .container{max-width:600px}
|
||||
footer .footer-logo{font-family:'Playfair Display',serif;font-size:1.5rem;color:#fff;margin-bottom:.5rem}
|
||||
footer .footer-logo span{color:#c9a84c}
|
||||
footer p{font-size:.85rem;line-height:1.8;margin-bottom:.5rem}
|
||||
footer .footer-links{display:flex;justify-content:center;gap:1.5rem;list-style:none;margin-top:1.5rem;flex-wrap:wrap}
|
||||
footer .footer-links a{color:rgba(255,255,255,.6);font-size:.8rem;text-transform:uppercase;letter-spacing:1px}
|
||||
footer .footer-links a:hover{color:#c9a84c}
|
||||
footer .copyright{margin-top:2rem;font-size:.75rem;color:rgba(255,255,255,.4)}
|
||||
|
||||
/* ===== MOBILE NAV ===== */
|
||||
@media(max-width:768px){
|
||||
.hamburger{display:flex}
|
||||
header .nav-links{position:fixed;inset:0;background:rgba(26,39,68,.98);flex-direction:column;align-items:center;justify-content:center;gap:2rem;opacity:0;pointer-events:none;transition:opacity .4s}
|
||||
header .nav-links.open{opacity:1;pointer-events:auto}
|
||||
header .nav-links a{color:#fff;font-size:1.1rem}
|
||||
header.scrolled .nav-links a{color:#fff}
|
||||
section{padding:3.5rem 1.2rem}
|
||||
.property-desc .container{grid-template-columns:1fr;gap:2rem}
|
||||
.owners .container{grid-template-columns:1fr}
|
||||
.owners .owner-image{order:-1}
|
||||
.owners .owner-image img{height:300px}
|
||||
.form-row{grid-template-columns:1fr}
|
||||
.hero-content .cta-outline{margin-left:0;margin-top:.75rem;display:block}
|
||||
.gallery .gallery-item{width:260px;height:200px}
|
||||
}
|
||||
|
||||
@media(max-width:480px){
|
||||
.amenities-grid{grid-template-columns:repeat(2,1fr)}
|
||||
.bedrooms-grid{grid-template-columns:1fr}
|
||||
.area-content{grid-template-columns:1fr}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ===== NAVIGATION ===== -->
|
||||
<header id="header">
|
||||
<nav>
|
||||
<a href="#" class="logo">Moore <span>Sunny Daze</span></a>
|
||||
<button class="hamburger" id="hamburger" aria-label="Menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<ul class="nav-links" id="navLinks">
|
||||
<li><a href="#property">The Property</a></li>
|
||||
<li><a href="#bedrooms">Bedrooms</a></li>
|
||||
<li><a href="#amenities">Amenities</a></li>
|
||||
<li><a href="#area">Area Guide</a></li>
|
||||
<li><a href="#owners">Owners</a></li>
|
||||
<li><a href="#book" class="gold">Book Now</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- ===== HERO ===== -->
|
||||
<section class="hero" id="hero">
|
||||
<div class="hero-bg" id="heroBg"></div>
|
||||
<div class="hero-overlay"></div>
|
||||
<div class="hero-content fade-in visible">
|
||||
<div class="badge">Book Direct & Save 13%</div>
|
||||
<h1>Moore <span>Sunny Daze</span></h1>
|
||||
<p class="subtitle">Maravilla 1403 — Gulf-View Luxury in Miramar Beach</p>
|
||||
<p class="location">3 Bedrooms • 2 Bathrooms • Sleeps 8 • Destin, Florida</p>
|
||||
<a href="#book" class="cta">Check Availability</a>
|
||||
<a href="#property" class="cta-outline">Explore the Property</a>
|
||||
</div>
|
||||
<div class="hero-scroll">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PROPERTY DESCRIPTION ===== -->
|
||||
<section class="property-desc" id="property">
|
||||
<div class="container">
|
||||
<div class="desc-content fade-in">
|
||||
<div class="section-header" style="text-align:left">
|
||||
<span class="tag">Welcome To</span>
|
||||
<h2>Your Beach Retreat Awaits</h2>
|
||||
</div>
|
||||
<p>Welcome to <strong>Moore Sunny Daze</strong> — a beautifully appointed 3-bedroom, 2-bathroom Gulf-view condo perched on the 14th floor of Maravilla Resort. From the moment you step inside, floor-to-ceiling windows flood the open-concept living space with natural light and frame sweeping views of the Emerald Coast.</p>
|
||||
<p>Every detail has been curated for comfort: a fully equipped kitchen with granite countertops, a private balcony for sunrise coffee and sunset cocktails, and beach-inspired décor that instantly puts you in vacation mode. Whether you’re gathering for a family meal at the dining table for six or unwinding in the king en-suite with its own balcony access, Moore Sunny Daze is designed to be your home away from home.</p>
|
||||
<p>When you book direct with owners Tim & Ashley Moore, you skip the 13% platform service fees charged by the big listing sites — which means more money in your pocket and a personal connection to the people who care most about your stay.</p>
|
||||
<div class="highlights">
|
||||
<div class="highlight-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>3-Night Minimum Stay</span>
|
||||
</div>
|
||||
<div class="highlight-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Sleeps 8 Comfortably</span>
|
||||
</div>
|
||||
<div class="highlight-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span>Miramar Beach, FL 32550</span>
|
||||
</div>
|
||||
<div class="highlight-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||
<span>Owner-Operated</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc-image fade-in">
|
||||
<img src="https://static.wixstatic.com/media/0a1b51_6e0326ddac2349b9a62a2a416dcb49e5~mv2.jpg" alt="Moore Sunny Daze beach view from Maravilla" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== BEDROOMS ===== -->
|
||||
<section class="bedrooms" id="bedrooms">
|
||||
<div class="container">
|
||||
<div class="section-header fade-in">
|
||||
<span class="tag">Sleeping Arrangements</span>
|
||||
<h2>Room to Unwind</h2>
|
||||
<p style="color:#5a6b7a;max-width:500px;margin:1rem auto 0">Three thoughtfully designed bedrooms plus a queen sleeper sofa ensure everyone has their own space.</p>
|
||||
</div>
|
||||
<div class="bedrooms-grid">
|
||||
<div class="bedroom-card fade-in">
|
||||
<img src="https://static.wixstatic.com/media/0a1b51_f0ab02b0263d4d9f8deb071af33bf414~mv2.jpg" alt="Primary King Bedroom" loading="lazy">
|
||||
<div class="card-body">
|
||||
<span class="room-type">Primary Suite</span>
|
||||
<h3>King En-Suite</h3>
|
||||
<p style="font-size:.9rem;color:#5a6b7a">Private balcony access, en-suite bathroom with walk-in shower, flat-screen TV, and Gulf views from bed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bedroom-card fade-in">
|
||||
<img src="https://static.wixstatic.com/media/0a1b51_f0ab02b0263d4d9f8deb071af33bf414~mv2.jpg" alt="Second King Bedroom" loading="lazy">
|
||||
<div class="card-body">
|
||||
<span class="room-type">Guest Room</span>
|
||||
<h3>King Bedroom</h3>
|
||||
<p style="font-size:.9rem;color:#5a6b7a">Plush king bed with premium linens, spacious closet, and easy access to the second full bathroom.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bedroom-card fade-in">
|
||||
<img src="https://static.wixstatic.com/media/0a1b51_badbf4f136c741a1b2eae378e1d92787~mv2.jpg" alt="Bunk Room" loading="lazy">
|
||||
<div class="card-body">
|
||||
<span class="room-type">Kids’ Favorite</span>
|
||||
<h3>Bunk Room</h3>
|
||||
<p style="font-size:.9rem;color:#5a6b7a">Twin-over-twin bunks — perfect for kids or extra guests. Fun, cozy, and steps from the hallway bath.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bedroom-card fade-in">
|
||||
<div style="background:#f6f3ee;height:220px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#c9a84c" stroke-width="1.5"><path d="M3 7v11a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7"/><path d="M21 7l-9-5L3 7"/><rect x="7" y="9" width="10" height="8" rx="1"/></svg>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span class="room-type">Living Room</span>
|
||||
<h3>Queen Sleeper</h3>
|
||||
<p style="font-size:.9rem;color:#5a6b7a">A comfortable queen-size sleeper sofa in the living room brings total capacity to 8 guests.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PHOTO GALLERY ===== -->
|
||||
<section class="gallery fade-in" id="gallery">
|
||||
<div class="section-header">
|
||||
<span class="tag">Photo Tour</span>
|
||||
<h2>See Inside Moore Sunny Daze</h2>
|
||||
</div>
|
||||
<div class="gallery-scroll">
|
||||
<div class="gallery-item"><img src="https://static.wixstatic.com/media/0a1b51_cc8d5a3847bf46ed90d4f801d63ccc47~mv2.jpg" alt="Living Room" loading="lazy"></div>
|
||||
<div class="gallery-item"><img src="https://static.wixstatic.com/media/0a1b51_fda7ac3fe55d429393a51b2c1148dbb5~mv2.jpg" alt="Kitchen" loading="lazy"></div>
|
||||
<div class="gallery-item"><img src="https://static.wixstatic.com/media/0a1b51_f0ab02b0263d4d9f8deb071af33bf414~mv2.jpg" alt="Bedroom" loading="lazy"></div>
|
||||
<div class="gallery-item"><img src="https://static.wixstatic.com/media/0a1b51_badbf4f136c741a1b2eae378e1d92787~mv2.jpg" alt="Bunk Room" loading="lazy"></div>
|
||||
<div class="gallery-item"><img src="https://static.wixstatic.com/media/0a1b51_aef0f37089e440f592219382b3759f52~mv2.jpg" alt="Balcony View" loading="lazy"></div>
|
||||
<div class="gallery-item"><img src="https://static.wixstatic.com/media/0a1b51_6fe99a65a950470bb4a760ce751d7631~mv2.jpg" alt="Pool" loading="lazy"></div>
|
||||
<div class="gallery-item"><img src="https://static.wixstatic.com/media/0a1b51_118bef8d63964005ba2c9cadf077a8a3~mv2.jpg" alt="Beach" loading="lazy"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== AMENITIES ===== -->
|
||||
<section class="amenities" id="amenities">
|
||||
<div class="container">
|
||||
<div class="section-header fade-in">
|
||||
<span class="tag">Amenities</span>
|
||||
<h2>Everything You Need</h2>
|
||||
<p style="color:#5a6b7a;max-width:500px;margin:1rem auto 0">From beach service to pickleball, Maravilla Resort has it all — and Moore Sunny Daze puts you right in the middle of it.</p>
|
||||
</div>
|
||||
<div class="amenities-grid fade-in">
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 20h20"/><path d="M4 20V10a8 8 0 1 1 16 0v10"/></svg>
|
||||
<span class="amenity-label">Beach Service<br><small style="color:#a0aab4">4 Chairs + 2 Umbrellas</small></span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span class="amenity-label">2 Resort Pools<br><small style="color:#a0aab4">1 Heated Seasonally</small></span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/><path d="M8.56 2.75c5.44 8 3.44 14 3.44 14s-2-6 3.44-14"/></svg>
|
||||
<span class="amenity-label">Hot Tub</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="12" y1="12" x2="16" y2="14"/></svg>
|
||||
<span class="amenity-label">Fitness Center</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<span class="amenity-label">Tennis Courts</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
|
||||
<span class="amenity-label">Pickleball Courts</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/></svg>
|
||||
<span class="amenity-label">Basketball Court</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 2v7.53a4 4 0 0 0 4 0V2"/><path d="M4 15l4 4-4 4"/><path d="M20 15l-4 4 4 4"/></svg>
|
||||
<span class="amenity-label">BBQ Grills</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
|
||||
<span class="amenity-label">Dog Friendly<br><small style="color:#a0aab4">$50 Pet Fee</small></span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M13.8 12H3"/></svg>
|
||||
<span class="amenity-label">Self Check-In</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>
|
||||
<span class="amenity-label">Free WiFi</span>
|
||||
</div>
|
||||
<div class="amenity-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16"/><circle cx="5.5" cy="18.5" r="1.5"/><circle cx="18.5" cy="18.5" r="1.5"/></svg>
|
||||
<span class="amenity-label">Free Parking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== AREA GUIDE ===== -->
|
||||
<section class="area-guide" id="area">
|
||||
<div class="container">
|
||||
<div class="section-header fade-in">
|
||||
<span class="tag">Explore</span>
|
||||
<h2>Miramar Beach & Beyond</h2>
|
||||
<p style="color:#5a6b7a;max-width:500px;margin:1rem auto 0">World-class dining, boutique shopping, and sugar-white sand — all minutes from your door.</p>
|
||||
</div>
|
||||
<div class="area-tabs fade-in">
|
||||
<button class="area-tab active" data-tab="dining">Dining</button>
|
||||
<button class="area-tab" data-tab="shopping">Shopping</button>
|
||||
<button class="area-tab" data-tab="beaches">Beaches & Activities</button>
|
||||
</div>
|
||||
<div class="area-content active" id="tab-dining">
|
||||
<div class="area-card">
|
||||
<span class="area-type">Casual Beachfront</span>
|
||||
<h4>Pompano Joe’s</h4>
|
||||
<p>Legendary beachfront seafood with Caribbean flair. Just a short walk down Scenic Gulf Drive. Famous for their coconut shrimp and frozen drinks.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Local Favorite</span>
|
||||
<h4>Captain Dave’s</h4>
|
||||
<p>No-frills seafood shack serving the freshest Gulf shrimp, oysters, and fish sandwiches. A Miramar Beach institution since 1978.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Family Dining</span>
|
||||
<h4>Surf Hut</h4>
|
||||
<p>Right-on-the-sand dining with a playground for the kids. Great burgers, seafood baskets, and sunset views over the Gulf.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Southern Comfort</span>
|
||||
<h4>Kenny D’s</h4>
|
||||
<p>Hearty Southern and New Orleans-inspired fare. Famous for their gumbo, po’boys, and live music on weekends.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Breakfast & Brunch</span>
|
||||
<h4>Mama Clemenza’s</h4>
|
||||
<p>European-style breakfast and brunch spot. Known for incredible pancakes, fresh pastries, and a charming courtyard setting.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Fine Dining</span>
|
||||
<h4>Bijoux</h4>
|
||||
<p>Upscale coastal cuisine in an intimate setting. Perfect for a romantic dinner or special occasion. Reservations recommended.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Wine & Small Plates</span>
|
||||
<h4>Vin’Tij</h4>
|
||||
<p>Wine bar and bistro with an impressive selection of wines by the glass and creative small plates. Great for a relaxed evening.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Island-Inspired</span>
|
||||
<h4>Tommy Bahama</h4>
|
||||
<p>Tropical dining and cocktails at Grand Boulevard. The coconut cloud martini and blackened fish tacos are must-tries.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="area-content" id="tab-shopping">
|
||||
<div class="area-card">
|
||||
<span class="area-type">Grocery</span>
|
||||
<h4>Winn-Dixie</h4>
|
||||
<p>Full-service grocery store just minutes away. Stock up on everything you need for the condo — fresh seafood, produce, wine, and more.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Supercenter</span>
|
||||
<h4>Wal-Mart</h4>
|
||||
<p>Conveniently located for beach gear, sunscreen, snacks, and anything you forgot to pack. Open late.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Outlet Shopping</span>
|
||||
<h4>Silver Sands Premium Outlets</h4>
|
||||
<p>The nation’s largest designer outlet center with 110+ stores including Coach, Michael Kors, Nike, and Tommy Hilfiger.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Luxury & Dining</span>
|
||||
<h4>Grand Boulevard Town Center</h4>
|
||||
<p>Upscale open-air shopping with Anthropologie, lululemon, Pottery Barn, plus a movie theater and fantastic restaurants.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="area-content" id="tab-beaches">
|
||||
<div class="area-card">
|
||||
<span class="area-type">Your Beach</span>
|
||||
<h4>Miramar Beach</h4>
|
||||
<p>Sugar-white sand and crystal-clear emerald water steps from Maravilla. Your beach service includes 4 chairs and 2 umbrellas.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Resort</span>
|
||||
<h4>Maravilla Pools</h4>
|
||||
<p>Two resort-style pools — one heated seasonally — plus a hot tub, all surrounded by lush tropical landscaping.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">State Park</span>
|
||||
<h4>Henderson Beach State Park</h4>
|
||||
<p>Pristine natural beach with nature trails, picnic pavilions, and some of the best shelling on the Emerald Coast.</p>
|
||||
</div>
|
||||
<div class="area-card">
|
||||
<span class="area-type">Family Fun</span>
|
||||
<h4>Destin Harbor Boardwalk</h4>
|
||||
<p>Deep-sea fishing charters, dolphin cruises, waterfront dining, and live entertainment — just a 10-minute drive away.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== OWNERS ===== -->
|
||||
<section class="owners" id="owners">
|
||||
<div class="container">
|
||||
<div class="owner-content fade-in">
|
||||
<div class="section-header" style="text-align:left">
|
||||
<span class="tag">Your Hosts</span>
|
||||
<h2>Meet Tim & Ashley Moore</h2>
|
||||
</div>
|
||||
<p>We fell in love with the Emerald Coast on our very first visit — the sugar-white sand, the warm Gulf waters, and the genuine hospitality of the Florida Panhandle. When we found Maravilla 1403, we knew it was the one: that view, that balcony, that feeling of being exactly where you’re supposed to be.</p>
|
||||
<p>Moore Sunny Daze isn’t just a rental property to us — it’s our family’s happy place, and we’ve poured our hearts into making it yours too. From the plush bedding to the fully stocked kitchen to the beach gear tucked away in the closet, every detail reflects how we like to travel ourselves.</p>
|
||||
<p>When you book direct with us, you’re not just saving the 13% platform fee — you’re getting a direct line to the people who know this condo inside and out. Have a question about the best restaurant for a sunset dinner? Need a recommendation for a fishing charter? We’re here for you.</p>
|
||||
<p>We can’t wait to welcome you to Moore Sunny Daze.</p>
|
||||
<p class="signature">— Tim & Ashley</p>
|
||||
</div>
|
||||
<div class="owner-image fade-in">
|
||||
<img src="https://static.wixstatic.com/media/0a1b51_5dca81e7c6984df68055636c3ab61015~mv2.jpg" alt="Tim & Ashley Moore, owners of Moore Sunny Daze" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== BOOKING WIDGET ===== -->
|
||||
<section class="booking" id="book">
|
||||
<div class="container">
|
||||
<div class="section-header fade-in">
|
||||
<span class="tag">Reservations</span>
|
||||
<h2>Book Your Stay</h2>
|
||||
</div>
|
||||
<div class="booking-widget fade-in">
|
||||
<div class="booking-widget-header">
|
||||
<h3>Check Availability</h3>
|
||||
<div class="save-badge">Book Direct & Save 13% vs Platform Fees</div>
|
||||
</div>
|
||||
<div class="booking-widget-body">
|
||||
|
||||
<!-- Step 1: Check Availability -->
|
||||
<div id="booking-step1">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="checkIn">Check-In</label>
|
||||
<input type="date" id="checkIn" min="" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="checkOut">Check-Out</label>
|
||||
<input type="date" id="checkOut" min="" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="guests">Guests</label>
|
||||
<select id="guests">
|
||||
<option value="1">1 Guest</option>
|
||||
<option value="2">2 Guests</option>
|
||||
<option value="3">3 Guests</option>
|
||||
<option value="4" selected>4 Guests</option>
|
||||
<option value="5">5 Guests</option>
|
||||
<option value="6">6 Guests</option>
|
||||
<option value="7">7 Guests</option>
|
||||
<option value="8">8 Guests</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="justify-content:flex-end">
|
||||
<div class="checkbox-group" style="margin:0;padding-top:1.4rem">
|
||||
<input type="checkbox" id="hasPet">
|
||||
<label for="hasPet">Bringing a pet? <span class="pet-note">(+$50 fee)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="quote-error"></p>
|
||||
<button class="btn btn-primary" id="checkAvailabilityBtn" onclick="checkAvailability()">
|
||||
<span class="btn-text">Check Availability</span>
|
||||
<span class="spinner"></span>
|
||||
</button>
|
||||
|
||||
<!-- Quote Result -->
|
||||
<div class="quote-result" id="quoteResult">
|
||||
<h4>Your Quote</h4>
|
||||
<div class="quote-row">
|
||||
<span id="quoteNightsLabel"></span>
|
||||
<span class="quote-nights" id="quoteNights"></span>
|
||||
</div>
|
||||
<div class="quote-row">
|
||||
<span>Nightly Rate</span>
|
||||
<span id="quoteRate"></span>
|
||||
</div>
|
||||
<div class="quote-row">
|
||||
<span>Season</span>
|
||||
<span id="quoteSeason"></span>
|
||||
</div>
|
||||
<div class="quote-row">
|
||||
<span>Subtotal</span>
|
||||
<span id="quoteSubtotal"></span>
|
||||
</div>
|
||||
<div class="quote-row" id="quoteCleaningRow">
|
||||
<span>Cleaning Fee</span>
|
||||
<span id="quoteCleaning"></span>
|
||||
</div>
|
||||
<div class="quote-row" id="quotePetRow" style="display:none">
|
||||
<span>Pet Fee</span>
|
||||
<span id="quotePetFee"></span>
|
||||
</div>
|
||||
<div class="quote-row total">
|
||||
<span>Total (USD)</span>
|
||||
<span id="quoteTotal"></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 trigger -->
|
||||
<div class="booking-divider">Guest Details</div>
|
||||
|
||||
<!-- Step 2: Guest info (shown after quote) -->
|
||||
<div class="booking-step2" id="bookingStep2">
|
||||
<div class="form-group full" style="margin-bottom:.75rem">
|
||||
<label for="guestName">Full Name</label>
|
||||
<input type="text" id="guestName" placeholder="Your full name" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="guestEmail">Email</label>
|
||||
<input type="email" id="guestEmail" placeholder="you@email.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="guestPhone">Phone</label>
|
||||
<input type="tel" id="guestPhone" placeholder="(555) 123-4567" required>
|
||||
</div>
|
||||
</div>
|
||||
<p id="booking-error"></p>
|
||||
<button class="btn btn-book" id="bookNowBtn" onclick="bookNow()">
|
||||
<span class="btn-text">Book Now — Secure Payment</span>
|
||||
<span class="spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== FOOTER ===== -->
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div class="footer-logo">Moore <span>Sunny Daze</span></div>
|
||||
<p>Maravilla 1403 • Miramar Beach, FL 32550</p>
|
||||
<p>3 Bedrooms • 2 Bathrooms • Sleeps 8</p>
|
||||
<ul class="footer-links">
|
||||
<li><a href="#property">Property</a></li>
|
||||
<li><a href="#bedrooms">Bedrooms</a></li>
|
||||
<li><a href="#amenities">Amenities</a></li>
|
||||
<li><a href="#area">Area Guide</a></li>
|
||||
<li><a href="#owners">Owners</a></li>
|
||||
<li><a href="#book">Book Now</a></li>
|
||||
</ul>
|
||||
<p class="copyright">© <span id="currentYear"></span> Moore Sunny Daze. All rights reserved. Owned & operated by Tim & Ashley Moore.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- ===== SCRIPTS ===== -->
|
||||
<script>
|
||||
/* ===== YEAR ===== */
|
||||
document.getElementById('currentYear').textContent = new Date().getFullYear();
|
||||
|
||||
/* ===== HEADER SCROLL ===== */
|
||||
const header = document.getElementById('header');
|
||||
window.addEventListener('scroll', () => {
|
||||
header.classList.toggle('scrolled', window.scrollY > 50);
|
||||
});
|
||||
|
||||
/* ===== MOBILE NAV ===== */
|
||||
const hamburger = document.getElementById('hamburger');
|
||||
const navLinks = document.getElementById('navLinks');
|
||||
hamburger.addEventListener('click', () => navLinks.classList.toggle('open'));
|
||||
document.querySelectorAll('.nav-links a').forEach(link => {
|
||||
link.addEventListener('click', () => navLinks.classList.remove('open'));
|
||||
});
|
||||
|
||||
/* ===== PARALLAX HERO ===== */
|
||||
const heroBg = document.getElementById('heroBg');
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrollY = window.scrollY;
|
||||
if (scrollY < window.innerHeight) {
|
||||
heroBg.style.transform = `translateY(${scrollY * 0.4}px)`;
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== SCROLL FADE-IN (Intersection Observer) ===== */
|
||||
const fadeEls = document.querySelectorAll('.fade-in');
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.15, rootMargin: '0px 0px -50px 0px' });
|
||||
fadeEls.forEach(el => observer.observe(el));
|
||||
|
||||
/* ===== AREA TABS ===== */
|
||||
document.querySelectorAll('.area-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.area-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.area-content').forEach(c => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== DATE INPUTS - SET MIN TO TODAY ===== */
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('checkIn').setAttribute('min', today);
|
||||
document.getElementById('checkOut').setAttribute('min', today);
|
||||
|
||||
/* ===== CHECK-OUT AUTO-MIN ===== */
|
||||
document.getElementById('checkIn').addEventListener('change', function() {
|
||||
const nextDay = new Date(this.value);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
document.getElementById('checkOut').setAttribute('min', nextDay.toISOString().split('T')[0]);
|
||||
});
|
||||
|
||||
/* ===== FORMAT CURRENCY ===== */
|
||||
function fmt(amount) {
|
||||
return '$' + Number(amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/* ===== LOADING STATE ===== */
|
||||
function setButtonLoading(btn, loading) {
|
||||
if (loading) {
|
||||
btn.classList.add('loading');
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
btn.classList.remove('loading');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== CHECK AVAILABILITY ===== */
|
||||
async function checkAvailability() {
|
||||
const checkIn = document.getElementById('checkIn').value;
|
||||
const checkOut = document.getElementById('checkOut').value;
|
||||
const guests = parseInt(document.getElementById('guests').value);
|
||||
const hasPet = document.getElementById('hasPet').checked;
|
||||
const quoteError = document.getElementById('quote-error');
|
||||
const quoteResult = document.getElementById('quoteResult');
|
||||
const bookingStep2 = document.getElementById('bookingStep2');
|
||||
const btn = document.getElementById('checkAvailabilityBtn');
|
||||
|
||||
// Reset
|
||||
quoteError.classList.remove('show');
|
||||
quoteError.textContent = '';
|
||||
quoteResult.classList.remove('show');
|
||||
bookingStep2.classList.remove('show');
|
||||
|
||||
// Validate
|
||||
if (!checkIn || !checkOut) {
|
||||
quoteError.textContent = 'Please select both check-in and check-out dates.';
|
||||
quoteError.classList.add('show');
|
||||
return;
|
||||
}
|
||||
if (checkOut <= checkIn) {
|
||||
quoteError.textContent = 'Check-out must be after check-in.';
|
||||
quoteError.classList.add('show');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(btn, true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/quote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ check_in: checkIn, check_out: checkOut, guests: guests, has_pet: hasPet })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.error || errData.detail || 'Unable to calculate rate. Please try again.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Populate quote
|
||||
document.getElementById('quoteNightsLabel').textContent = `${data.nights} Night${data.nights !== 1 ? 's' : ''}`;
|
||||
document.getElementById('quoteNights').textContent = `${data.nights} night${data.nights !== 1 ? 's' : ''}`;
|
||||
document.getElementById('quoteRate').textContent = fmt(data.nightly_rate) + '/night';
|
||||
document.getElementById('quoteSeason').textContent = data.season || 'Standard';
|
||||
document.getElementById('quoteSubtotal').textContent = fmt(data.subtotal);
|
||||
document.getElementById('quoteCleaning').textContent = fmt(data.cleaning_fee);
|
||||
|
||||
if (data.pet_fee > 0) {
|
||||
document.getElementById('quotePetRow').style.display = 'flex';
|
||||
document.getElementById('quotePetFee').textContent = fmt(data.pet_fee);
|
||||
} else {
|
||||
document.getElementById('quotePetRow').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('quoteTotal').textContent = fmt(data.total);
|
||||
quoteResult.classList.add('show');
|
||||
bookingStep2.classList.add('show');
|
||||
|
||||
// Scroll to quote
|
||||
quoteResult.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
} catch (err) {
|
||||
quoteError.textContent = err.message;
|
||||
quoteError.classList.add('show');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== BOOK NOW ===== */
|
||||
async function bookNow() {
|
||||
const checkIn = document.getElementById('checkIn').value;
|
||||
const checkOut = document.getElementById('checkOut').value;
|
||||
const guests = parseInt(document.getElementById('guests').value);
|
||||
const hasPet = document.getElementById('hasPet').checked;
|
||||
const name = document.getElementById('guestName').value.trim();
|
||||
const email = document.getElementById('guestEmail').value.trim();
|
||||
const phone = document.getElementById('guestPhone').value.trim();
|
||||
const bookingError = document.getElementById('booking-error');
|
||||
const btn = document.getElementById('bookNowBtn');
|
||||
|
||||
// Reset
|
||||
bookingError.classList.remove('show');
|
||||
bookingError.textContent = '';
|
||||
|
||||
// Validate
|
||||
if (!name) {
|
||||
bookingError.textContent = 'Please enter your full name.';
|
||||
bookingError.classList.add('show');
|
||||
return;
|
||||
}
|
||||
if (!email || !email.includes('@')) {
|
||||
bookingError.textContent = 'Please enter a valid email address.';
|
||||
bookingError.classList.add('show');
|
||||
return;
|
||||
}
|
||||
if (!phone) {
|
||||
bookingError.textContent = 'Please enter your phone number.';
|
||||
bookingError.classList.add('show');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(btn, true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/booking', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
check_in: checkIn,
|
||||
check_out: checkOut,
|
||||
guests: guests,
|
||||
has_pet: hasPet,
|
||||
name: name,
|
||||
email: email,
|
||||
phone: phone
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.error || errData.detail || 'Booking failed. Please try again.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
if (data.stripe_checkout_url) {
|
||||
window.location.href = data.stripe_checkout_url;
|
||||
} else {
|
||||
throw new Error('No checkout URL returned. Please try again or contact us directly.');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
bookingError.textContent = err.message;
|
||||
bookingError.classList.add('show');
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== SMOOTH SCROLL FOR ANCHOR LINKS ===== */
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user