/* Sea La Vie — data + helpers (production v3, backed by api.php) */ // Photo URLs const PHOTO = { villasAerial: "assets/villas-aerial.jpg", villaTerrace: "assets/villa-terrace.jpg", logoOnSea: "assets/logo-on-sea.jpg", beachPalms: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1400&auto=format&fit=crop&q=70", beachAerial: "https://images.unsplash.com/photo-1582979512210-99b6a53386f9?w=1400&auto=format&fit=crop&q=70", whiteSand: "https://images.unsplash.com/photo-1506953823976-52e1fdc0149a?w=1200&auto=format&fit=crop&q=70", palmsDusk: "https://images.unsplash.com/photo-1519046904884-53103b34b206?w=1200&auto=format&fit=crop&q=70", oceanDawn: "https://images.unsplash.com/photo-1473116763249-2faaef81ccda?w=1200&auto=format&fit=crop&q=70", kitesurf: "https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200&auto=format&fit=crop&q=70", skydive: "https://images.unsplash.com/photo-1521673461164-de300ebcfb17?w=1200&auto=format&fit=crop&q=70", dhowSail: "https://images.unsplash.com/photo-1535262971475-851ba45e85dd?w=1200&auto=format&fit=crop&q=70", snorkel: "https://images.unsplash.com/photo-1582967788606-a171c1080cb0?w=1200&auto=format&fit=crop&q=70", elephants: "https://images.unsplash.com/photo-1547471080-7cc2caa01a7e?w=1200&auto=format&fit=crop&q=70", monkey: "https://images.unsplash.com/photo-1606567595334-d39972c85dbe?w=1200&auto=format&fit=crop&q=70", scuba: "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1200&auto=format&fit=crop&q=70", camelBeach: "https://images.unsplash.com/photo-1568689263617-c87e7d2d8d1c?w=1200&auto=format&fit=crop&q=70", spaTable: "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=1200&auto=format&fit=crop&q=70", yogaSunrise: "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=1200&auto=format&fit=crop&q=70", forestPath: "https://images.unsplash.com/photo-1502082553048-f009c37129b9?w=1200&auto=format&fit=crop&q=70", // Activities fishingBoat: "https://images.unsplash.com/photo-1545173168-9f1947eebb7f?w=1200&auto=format&fit=crop&q=70", jetski: "https://images.unsplash.com/photo-1530039251581-3c5cbb70e62b?w=1200&auto=format&fit=crop&q=70", parasail: "https://images.unsplash.com/photo-1599058917212-d750089bc07e?w=1200&auto=format&fit=crop&q=70", paddleboard: "https://images.unsplash.com/photo-1502680390469-be75c86b636f?w=1200&auto=format&fit=crop&q=70", glassBoat: "https://images.unsplash.com/photo-1533893057045-6e34dad2069a?w=1200&auto=format&fit=crop&q=70", sunsetCruise: "https://images.unsplash.com/photo-1559535332-db9971090158?w=1200&auto=format&fit=crop&q=70", horseBeach: "https://images.unsplash.com/photo-1553284965-83fd3e82fa5a?w=1200&auto=format&fit=crop&q=70", kayakMangrove:"https://images.unsplash.com/photo-1463694562125-94d20ce80b3a?w=1200&auto=format&fit=crop&q=70", quadBike: "https://images.unsplash.com/photo-1551958219-acbc608c6377?w=1200&auto=format&fit=crop&q=70", // Cuisine — specific food photos swahiliFood: "https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?w=1200&auto=format&fit=crop&q=70", beachDining: "https://images.unsplash.com/photo-1535320903710-d993d3d77d29?w=1200&auto=format&fit=crop&q=70", spices: "https://images.unsplash.com/photo-1532336414038-cf19250c5757?w=1200&auto=format&fit=crop&q=70", seafood: "https://images.unsplash.com/photo-1559847844-5315695dadae?w=1200&auto=format&fit=crop&q=70", cocktail: "https://images.unsplash.com/photo-1546171753-97d7676e4602?w=1200&auto=format&fit=crop&q=70", caveDining: "https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1200&auto=format&fit=crop&q=70", market: "https://images.unsplash.com/photo-1488459716781-31db52582fe9?w=1200&auto=format&fit=crop&q=70", bonfire: "https://images.unsplash.com/photo-1504113888839-1c8eb50233d3?w=1200&auto=format&fit=crop&q=70", // Food-specific pizzaWood: "https://images.unsplash.com/photo-1513104890138-7c749659a591?w=1200&auto=format&fit=crop&q=70", pasta: "https://images.unsplash.com/photo-1551183053-bf91a1d81141?w=1200&auto=format&fit=crop&q=70", grilledFish: "https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=1200&auto=format&fit=crop&q=70", squid: "https://images.unsplash.com/photo-1574484284002-952d92456975?w=1200&auto=format&fit=crop&q=70", fineDining: "https://images.unsplash.com/photo-1559339352-11d035aa65de?w=1200&auto=format&fit=crop&q=70", pilau: "https://images.unsplash.com/photo-1596797038530-2c107229654b?w=1200&auto=format&fit=crop&q=70", prawns: "https://images.unsplash.com/photo-1625944525533-473f1a3d54e7?w=1200&auto=format&fit=crop&q=70", indianFood: "https://images.unsplash.com/photo-1585937421612-70a008356fbe?w=1200&auto=format&fit=crop&q=70", beachCocktail:"https://images.unsplash.com/photo-1551024601-bec78aea704b?w=1200&auto=format&fit=crop&q=70", sushi: "https://images.unsplash.com/photo-1579871494447-9811cf80d66c?w=1200&auto=format&fit=crop&q=70", stoneTown: "https://images.unsplash.com/photo-1547471080-7cc2caa01a7e?w=1200&auto=format&fit=crop&q=70", dhowSunset: "https://images.unsplash.com/photo-1530541930197-ff16ac917b0e?w=1200&auto=format&fit=crop&q=70", village: "https://images.unsplash.com/photo-1571406384350-3b1eb91be0e1?w=1200&auto=format&fit=crop&q=70", fishMarket: "https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200&auto=format&fit=crop&q=70", baobab: "https://images.unsplash.com/photo-1547471080-7cc2caa01a7e?w=1200&auto=format&fit=crop&q=70", tideline: "https://images.unsplash.com/photo-1473116763249-2faaef81ccda?w=1200&auto=format&fit=crop&q=70" }; // All prices in USD (base). Currency switcher converts on display. // Max 4 guests per residence — privacy is part of the brief. const VILLAS = [ { id: "pwani", name: ["Pwani","Villa"], badge: "Forest · 3 BR", priceUSD: 380, period: "per night", bedrooms: 3, baths: 3, sqft: "4,200", sleeps: 4, desc: "Set deeper into the indigenous canopy, with a private courtyard plunge pool and an outdoor shower. Colobus monkeys at dawn; the sound of the surf, ninety paces away, at night.", image: PHOTO.villasAerial }, { id: "bahari", name: ["Bahari","Villa"], badge: "Pool Deck · 3 BR", priceUSD: 440, period: "per night", bedrooms: 3, baths: 3, sqft: "4,600", sleeps: 4, desc: "An open-plan living pavilion that spills onto a long pool deck and shaded loungers. Lime-washed walls, mvuli timber ceilings, and the wind off the reef threading through every room.", image: PHOTO.villaTerrace }, { id: "kasi", name: ["Kasi","Villa"], badge: "Garden · 4 BR", priceUSD: 520, period: "per night", bedrooms: 4, baths: 4, sqft: "5,800", sleeps: 4, desc: "Four en-suite bedrooms wrapped around a generous courtyard and twelve-metre pool. A short barefoot path to the Indian Ocean, framed by ancient palms and indigenous coastal forest.", image: PHOTO.villasAerial }, { id: "samaki", name: ["Samaki","Villa"], badge: "Headland · 4 BR", priceUSD: 600, period: "per night", bedrooms: 4, baths: 4, sqft: "6,000", sleeps: 4, desc: "The flagship, set on the rise of the property with the longest view down the coast. A reflecting pool, a roof terrace for sundowners, and a private path through palms to the water.", image: PHOTO.villaTerrace } ]; const EXPERIENCES = { Ocean: [ { title: ["Kite","Atelier"], cat: "Watersport", provider: "H2O Extreme", meta: ["2 hours","Private instructor","Galu Beach"], desc: "Diani's southern reach is one of the world's finest kitesurf coastlines, with reliable Kaskazi and Kusi winds. IKO-certified instructors, equipment included; lessons run from absolute beginner to advanced jumping.", from: "$160", ph: PHOTO.kitesurf }, { title: ["Jet","Ski"], cat: "Watersport", provider: "Galu Beach Watersports", meta: ["30 minutes","Up to 2 riders"], desc: "Powerful two-up jet skis on the long, flat inshore lagoon — the reef breaks the surf so the water is calm and almost empty for most of the day. Instruction on the sand before you head out.", from: "$90", ph: PHOTO.jetski }, { title: ["Reef","Snorkel"], cat: "Marine", provider: "Diani Marine", meta: ["Half day","Up to 6 guests"], desc: "A skippered traditional dhow drifts the inner reef at slack tide. Mask, fins, and a marine guide who can name everything on the coral; finished with chilled coconut water on the foredeck.", from: "$140", ph: PHOTO.snorkel }, { title: ["Glass-Bottom","Boat"], cat: "Marine", provider: "Pilli Pipa Dhow", meta: ["2 hours","Family-friendly"], desc: "For non-swimmers and small children — a glass-bottomed dhow drifts over the inner reef so you can watch parrotfish, lionfish and rays without ever getting wet. A favourite of grandparents.", from: "$70", ph: PHOTO.glassBoat }, { title: ["Paddle","Board"], cat: "Watersport", provider: "Tribe Watersports", meta: ["90 minutes","All levels"], desc: "Stand-up paddleboarding on the long, glassy inshore lagoon — best at dawn before the wind comes up. Boards and instruction included; an easy way to spend a quiet morning on the water.", from: "$45", ph: PHOTO.paddleboard }, { title: ["Parasail","Flight"], cat: "Watersport", provider: "Galu Beach Watersports", meta: ["15 minutes","Solo or tandem"], desc: "A slow, quiet drift two hundred metres above the reef — the best aerial view of the south coast and a soft splashdown at the end. Solo or tandem; not as scary as it sounds.", from: "$80", ph: PHOTO.parasail }, { title: ["Sundowner","Dhow"], cat: "Marine", provider: "Pilli Pipa", meta: ["3 hours","Sunset","Champagne"], desc: "A skippered sailing dhow heads out at five, anchors over the reef, and pours sundowners as the sun drops into the channel. The most photographed two hours in Diani.", from: "$110", ph: PHOTO.sunsetCruise }, { title: ["Wasini","Channel"], cat: "Excursion", provider: "Charlie Claw's", meta: ["Full day","Snorkel · Lunch"], desc: "South to Kisite-Mpunguti Marine Park: spinner dolphins in the channel, snorkelling at the reef wall, and a Swahili seafood lunch on Wasini Island under the baobabs.", from: "$210", ph: PHOTO.dhowSail }, { title: ["Deep","Reef Dive"], cat: "Diving", provider: "Diving the Crab", meta: ["2 dives","PADI"], desc: "Two-tank dive trips to the outer wall and the wreck of the MV Funguo. Turtles, eagle rays, and on the right day, the dolphin pod that lives off Galu Point.", from: "$185", ph: PHOTO.scuba } ], Adrenaline: [ { title: ["Tandem","Skydive"], cat: "Air", provider: "Skydive Diani", meta: ["10,000 ft","Tandem","Beach drop"], desc: "Diani's beach drop is among the most photographed skydives on the African continent — a free-fall over the reef, then a slow drift down onto white sand. Filmed by your jumpmaster.", from: "$425", ph: PHOTO.skydive }, { title: ["Sport","Fishing"], cat: "Marine", provider: "Sea Adventures", meta: ["Half day","Up to 4"], desc: "A 31-foot Bertram out of Shimoni for sailfish, marlin and yellowfin during the September–March season. Tag-and-release; catch on board for the kitchen on request.", from: "$520", ph: PHOTO.fishingBoat }, { title: ["Quad","Bike Forest"], cat: "Land", provider: "Diani Quad Adventures", meta: ["3 hours","All levels"], desc: "An off-road circuit through the coastal forest behind Diani — sand tracks, river crossings, and a stop at a Digo village where the route originated.", from: "$140", ph: PHOTO.quadBike }, { title: ["Sunrise","Camel Ride"], cat: "Beach", provider: "Diani Beach Camels", meta: ["60 minutes","Private"], desc: "A gentle escort along the low-tide shoreline at first light, when the beach belongs to fishermen and gulls. A long-standing Diani tradition, run by a third-generation Mijikenda family.", from: "$60", ph: PHOTO.camelBeach }, { title: ["Horse","Beach Ride"], cat: "Beach", provider: "Diani Horse Riding", meta: ["75 minutes","Beginner / Intermediate"], desc: "Trained Somali ponies along the empty Galu sands at low tide. Cantering on hard sand below the high-tide line; for confident riders, a gallop down to the rocks at Mwamba.", from: "$120", ph: PHOTO.horseBeach }, { title: ["Kayak","Mangroves"], cat: "Water", provider: "Funzi Eco Adventures", meta: ["Half day","Calm water"], desc: "A short drive south to Funzi, then sit-on-top kayaks through the Ramisi mangrove channels — herons, fiddler crabs, and the occasional saltwater crocodile sunning on a mudbank.", from: "$95", ph: PHOTO.kayakMangrove } ], Wellness: [ { title: ["Baobab","Spa"], cat: "Spa", provider: "In-villa · Sanctuary at the Sands", meta: ["90 minutes"], desc: "A signature massage with baobab seed oil and locally pressed coconut, performed in the privacy of your own pavilion. Therapists trained at the Sanctuary at the Sands at Nomad.", from: "$160", ph: PHOTO.spaTable }, { title: ["Sunrise","Yoga"], cat: "Movement", provider: "Diani Yoga Shala", meta: ["75 minutes","Up to 6"], desc: "Vinyasa flow on the southern deck as the sun lifts over the reef, led by Mwajuma — a Diani-raised teacher with a fifteen-year practice. Mats, props and a coconut elixir provided.", from: "$45", ph: PHOTO.yogaSunrise }, { title: ["Colobus","Conservancy"], cat: "Nature", provider: "Colobus Conservation", meta: ["2 hours","Family-friendly"], desc: "A guided walk through the canopy bridges and rehabilitation centre that protects Diani's endangered Angolan colobus. A meaningful, hands-off encounter with the coast's rarest neighbour.", from: "$35", ph: PHOTO.monkey } ], Excursions: [ { title: ["Shimba","Hills"], cat: "Safari", provider: "Private guide", meta: ["Full day","Land Cruiser"], desc: "Forty minutes inland: a coastal forest reserve with sable antelope and elephant herds. A walk to Sheldrick Falls and a picnic on the elephant lookout above the canopy.", from: "$340", ph: PHOTO.elephants }, { title: ["Funzi","Island"], cat: "Marine", provider: "Funzi Keys", meta: ["Full day","Private boat"], desc: "South to Funzi by speedboat — mangrove channels, sandbank lunch, and the chance to spot crocodiles in the Ramisi estuary. A long, slow, perfect day.", from: "$380", ph: PHOTO.beachAerial }, { title: ["Mombasa","Old Town"], cat: "Cultural", provider: "Tribe Watersports", meta: ["Half day","Curator-led"], desc: "A guided walk through Mombasa's UNESCO-listed Old Town: Fort Jesus, the spice quarter, the ornate Swahili doors, and a private terrace lunch above the dhow harbour.", from: "$210", ph: PHOTO.stoneTown } ] }; // Real Diani / South Coast restaurants — currently operating (2026) const CUISINE = [ { title: ["Ali Barbour's","Cave"], cat: "Fine Dining", provider: "10 minutes from villa", meta: ["Seafood","Tasting menu"], desc: "Dinner inside a 180,000-year-old coral cave with the night sky as a roof — one of East Africa's most celebrated dining rooms. Local seafood, French technique, an exceptional wine cellar.", from: "Reservation", ph: PHOTO.caveDining }, { title: ["Nomad","Beach Bar"], cat: "Beach Dining", provider: "The Sands at Nomad", meta: ["Lunch","Sunset"], desc: "Tables on the sand, an open kitchen, and Diani's most loved wood-fired thin-crust pizzas alongside the day's catch. The sundowner ritual of the south coast.", from: "Daily", ph: PHOTO.pizzaWood }, { title: ["The Salty","Squid"], cat: "Seafood", provider: "Diani Beach Road", meta: ["Lunch","Dinner"], desc: "A relaxed, Mediterranean-leaning seafood kitchen on Diani Beach Road — grilled calamari, garlic prawns, whole catch of the day. Casual lunches, candlelit dinners, generous portions.", from: "Daily", ph: PHOTO.squid }, { title: ["Leonardo's","Restaurant"], cat: "Italian", provider: "Diani Beach Road", meta: ["Italian classics","Pasta"], desc: "A Diani institution for thirty years — Italian classics in a quiet garden setting. Hand-rolled pasta, wood-fired pizza, an Italian wine list, and a kitchen run by the original family.", from: "Daily", ph: PHOTO.pasta }, { title: ["Sails","Beach Bar"], cat: "Beach Dining", provider: "Almanara Resort", meta: ["Lunch","Dinner"], desc: "Mediterranean plates on a long deck above the high-tide line. House-cured tuna, grilled langoustine, crisp coastal whites — and the long view south to the reef.", from: "Reservation", ph: PHOTO.grilledFish }, { title: ["Ozi's","Bar & Bistro"], cat: "Bistro", provider: "Diani Beach Road", meta: ["Bar","Live music"], desc: "A long-running Diani spot — sea-facing bistro tables, a small daily menu of well-cooked seafood and Indian-leaning curries, and one of the most reliably good cocktail lists on the coast.", from: "Daily", ph: PHOTO.cocktail }, { title: ["Shan-e-","Punjab"], cat: "Indian", provider: "Diani Beach Road", meta: ["Dinner","Tandoor"], desc: "The south coast's best-loved Indian kitchen — tandoor-blackened paneer, dal makhani, lobster masala, and biryanis served on copper. Twenty-five years of regulars; book a few nights ahead.", from: "Daily", ph: PHOTO.indianFood }, { title: ["Kokkos","Beach Bar"], cat: "Beach Bar", provider: "Baobab Beach Resort", meta: ["Sunset","Cocktails"], desc: "A long curving bar set on the sand at the northern end of Diani, under one of the largest baobabs on the coast. Cocktails are properly mixed, the seafood platter is generous, and the live band plays Thursdays through Sundays.", from: "Daily", ph: PHOTO.beachCocktail }, { title: ["Swahili Pot","Kitchen"], cat: "Local", provider: "Ukunda", meta: ["Lunch","Hands-on"], desc: "Coconut-braised fish, pilau, biryani and viazi vya nazi cooked in a family kitchen in Ukunda — and, if you like, a market visit at dawn with the cook to choose the catch.", from: "$55", ph: PHOTO.pilau }, { title: ["Bonfire","Table"], cat: "Private", provider: "Sea La Vie in-villa", meta: ["By the water","Up to 12"], desc: "A driftwood bonfire on the sand, a long candlelit table, an acoustic guitarist, whole fish on the coals and your party alone with the stars. Booked through your villa host.", from: "$480", ph: PHOTO.bonfire } ]; const JOURNAL = [ { title: ["A guide to","Kaskazi season"], cat: "Coast Knowledge", date: "06 · 2026", img: PHOTO.beachPalms, feature: true, excerpt: "From October to March the Kaskazi wind blows down the coast from the north-east, the water turns glassy at dawn, and Diani is at its quietest. A field guide to choosing the right week." }, { title: ["The dhows of","Shimoni"], cat: "Coast Craft", date: "05 · 2026", img: PHOTO.dhowSunset, excerpt: "Two hours south of the property, the last working dhow yard on the coast still cuts and ribs each hull by hand. We spent a week with the master builders." }, { title: ["Twelve plates","to know"], cat: "Coast Cuisine", date: "04 · 2026", img: PHOTO.swahiliFood, excerpt: "Swahili cuisine is the Indian Ocean recorded in food — Persian, Indian, Portuguese, Omani, African. Twelve dishes our chef returns to, season after season." }, { title: ["The Colobus","comeback"], cat: "Conservation", date: "03 · 2026", img: PHOTO.monkey, excerpt: "Diani's endangered Angolan colobus has rebounded from 250 individuals to over 500 in a decade — thanks largely to one small conservancy and a network of canopy bridges." }, { title: ["Three days","on the coast"], cat: "Itinerary", date: "02 · 2026", img: PHOTO.beachAerial, excerpt: "A long-weekend itinerary for first-time guests: morning yoga, a dhow north to Kilifi creek, dinner inside Ali Barbour's cave, and the slow Sunday that earns it all." } ]; const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"]; const DOW = ["S","M","T","W","T","F","S"]; function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); } function firstDow(y, m) { return new Date(y, m, 1).getDay(); } function fmtDate(d) { if (!d) return null; return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } function fmtShort(d) { if (!d) return null; return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } function dateKey(y,m,d){ return `${y}-${m+1}-${d}`; } function dateKeyFor(date) { return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`; } const CONTACT = { email: "marketing@sealaviediani.com", phone: "+254 712 000 000", phoneRaw: "+254712000000", whatsapp: "+254712000000", address: ["Sea La Vie Residences", "Galu Beach Road, Ukunda 80400", "Kwale County, Kenya"], instagram: "@sealaviediani", hours: "Daily · 08:00 – 20:00 EAT", airport: "7 minutes from Ukunda Airport (UKA)", airportNote: "45 minutes from Moi International, Mombasa (MBA)", // Approximate property coordinates (Galu Beach, Diani) geo: { lat: -4.3412, lng: 39.5670 } }; // ─── CURRENCY ───────────────────────────────────────────────────────────── // Prices are stored in USD. Rates updated periodically by the team. const CURRENCIES = { USD: { code: "USD", symbol: "$", rate: 1, decimals: 0 }, GBP: { code: "GBP", symbol: "£", rate: 0.79, decimals: 0 }, KES: { code: "KES", symbol: "KSh ", rate: 129, decimals: 0 } }; const CURRENCY_KEY = "slv_currency"; function getStoredCurrency() { try { return localStorage.getItem(CURRENCY_KEY) || "USD"; } catch(e) { return "USD"; } } function setStoredCurrency(code) { try { localStorage.setItem(CURRENCY_KEY, code); } catch(e) {} window.dispatchEvent(new CustomEvent("slv:currency", { detail: code })); } function formatPrice(usd, code) { const c = CURRENCIES[code] || CURRENCIES.USD; const v = Math.round(usd * c.rate); return c.symbol + v.toLocaleString(undefined, { maximumFractionDigits: c.decimals }); } function useCurrency() { const [code, setCode] = React.useState(() => getStoredCurrency()); React.useEffect(() => { function h(e) { setCode(e.detail); } window.addEventListener("slv:currency", h); return () => window.removeEventListener("slv:currency", h); }, []); return [code, (c) => { setCode(c); setStoredCurrency(c); }]; } // ─── AVAILABILITY BACKEND ─────────────────────────────────────────────────── // Tries api.php first, falls back to localStorage for offline / preview use. const API_URL = "api.php"; const STORAGE_KEY = "slv_availability_v5"; const SEED_AVAILABILITY = { kasi: ["2026-5-21","2026-5-22","2026-5-23","2026-5-24","2026-5-25","2026-5-26","2026-5-27","2026-5-28","2026-5-29","2026-5-30","2026-5-31","2026-6-1","2026-6-2","2026-6-3","2026-6-4","2026-6-5","2026-6-6","2026-6-7","2026-6-8","2026-6-9","2026-6-10","2026-6-11","2026-6-12","2026-6-13","2026-6-14","2026-6-15","2026-6-16","2026-6-17","2026-6-18","2026-6-19","2026-6-20","2026-6-21","2026-6-22","2026-6-23","2026-6-24","2026-6-25","2026-6-26","2026-6-27","2026-6-28","2026-6-29","2026-6-30","2026-7-1","2026-7-2","2026-7-3","2026-7-4","2026-7-5","2026-7-6","2026-7-7","2026-7-8","2026-7-9","2026-7-10","2026-7-11","2026-7-12","2026-7-13","2026-7-14","2026-7-15","2026-7-16","2026-7-17","2026-7-18","2026-7-19","2026-7-20","2026-7-21","2026-7-22","2026-7-23","2026-7-24","2026-7-25","2026-7-26","2026-7-27","2026-7-28","2026-7-29","2026-7-30","2026-7-31","2026-8-1","2026-8-2","2026-8-3","2026-8-4","2026-8-5","2026-8-6","2026-8-7","2026-8-8","2026-8-9","2026-8-10","2026-8-11","2026-8-12","2026-8-13","2026-8-14","2026-8-15","2026-8-16","2026-8-17","2026-8-18","2026-8-19","2026-8-20","2026-8-21","2026-8-22","2026-8-23","2026-8-24","2026-8-25","2026-8-26","2026-8-27","2026-8-28","2026-8-29","2026-8-30","2026-8-31","2026-9-1","2026-9-2","2026-9-3","2026-9-4","2026-9-5","2026-9-6","2026-9-7","2026-9-8","2026-9-9","2026-9-10","2026-9-11","2026-9-12","2026-9-13","2026-9-14","2026-9-15","2026-9-16","2026-9-17","2026-9-18","2026-9-19","2026-9-20","2026-9-21","2026-9-22","2026-9-23","2026-9-24","2026-9-25","2026-9-26","2026-9-27","2026-9-28","2026-9-29","2026-9-30","2026-10-1","2026-10-2","2026-10-3","2026-10-4","2026-10-5","2026-10-6","2026-10-7","2026-10-8","2026-10-9","2026-10-10","2026-10-11","2026-10-12","2026-10-13","2026-10-14","2026-10-15","2026-10-16","2026-10-17","2026-10-18","2026-10-19","2026-10-20","2026-10-21","2026-10-22","2026-10-23","2026-10-24","2026-10-25","2026-10-26","2026-10-27","2026-10-28","2026-10-29","2026-10-30","2026-10-31"], bahari: ["2026-5-21","2026-5-22","2026-5-23","2026-5-24","2026-5-25","2026-5-26","2026-5-27","2026-5-28","2026-5-29","2026-5-30","2026-5-31","2026-6-1","2026-6-2","2026-6-3","2026-6-4","2026-6-5","2026-6-6","2026-6-7","2026-6-8","2026-6-9","2026-6-10","2026-6-11","2026-6-12","2026-6-13","2026-6-14","2026-6-15","2026-6-16","2026-6-17","2026-6-18","2026-6-19","2026-6-20","2026-6-21","2026-6-22","2026-6-23","2026-6-24","2026-6-25","2026-6-26","2026-6-27","2026-6-28","2026-6-29","2026-6-30","2026-7-1","2026-7-2","2026-7-3","2026-7-4","2026-7-5","2026-7-6","2026-7-7","2026-7-8","2026-7-9","2026-7-10","2026-7-11","2026-7-12","2026-7-13","2026-7-14","2026-7-15","2026-7-16","2026-7-17","2026-7-18","2026-7-19","2026-7-20","2026-7-21","2026-7-22","2026-7-23","2026-7-24","2026-7-25","2026-7-26","2026-7-27","2026-7-28","2026-7-29","2026-7-30","2026-7-31","2026-8-1","2026-8-2","2026-8-3","2026-8-4","2026-8-5","2026-8-6","2026-8-7","2026-8-8","2026-8-9","2026-8-10","2026-8-11","2026-8-12","2026-8-13","2026-8-14","2026-8-15","2026-8-16","2026-8-17","2026-8-18","2026-8-19","2026-8-20","2026-8-21","2026-8-22","2026-8-23","2026-8-24","2026-8-25","2026-8-26","2026-8-27","2026-8-28","2026-8-29","2026-8-30","2026-8-31","2026-9-1","2026-9-2","2026-9-3","2026-9-4","2026-9-5","2026-9-6","2026-9-7","2026-9-8","2026-9-9","2026-9-10","2026-9-11","2026-9-12","2026-9-13","2026-9-14","2026-9-15","2026-9-16","2026-9-17","2026-9-18","2026-9-19","2026-9-20","2026-9-21","2026-9-22","2026-9-23","2026-9-24","2026-9-25","2026-9-26","2026-9-27","2026-9-28","2026-9-29","2026-9-30","2026-10-1","2026-10-2","2026-10-3","2026-10-4","2026-10-5","2026-10-6","2026-10-7","2026-10-8","2026-10-9","2026-10-10","2026-10-11","2026-10-12","2026-10-13","2026-10-14","2026-10-15","2026-10-16","2026-10-17","2026-10-18","2026-10-19","2026-10-20","2026-10-21","2026-10-22","2026-10-23","2026-10-24","2026-10-25","2026-10-26","2026-10-27","2026-10-28","2026-10-29","2026-10-30","2026-10-31"], pwani: ["2026-5-21","2026-5-22","2026-5-23","2026-5-24","2026-5-25","2026-5-26","2026-5-27","2026-5-28","2026-5-29","2026-5-30","2026-5-31","2026-6-1","2026-6-2","2026-6-3","2026-6-4","2026-6-5","2026-6-6","2026-6-7","2026-6-8","2026-6-9","2026-6-10","2026-6-11","2026-6-12","2026-6-13","2026-6-14","2026-6-15","2026-6-16","2026-6-17","2026-6-18","2026-6-19","2026-6-20","2026-6-21","2026-6-22","2026-6-23","2026-6-24","2026-6-25","2026-6-26","2026-6-27","2026-6-28","2026-6-29","2026-6-30","2026-7-1","2026-7-2","2026-7-3","2026-7-4","2026-7-5","2026-7-6","2026-7-7","2026-7-8","2026-7-9","2026-7-10","2026-7-11","2026-7-12","2026-7-13","2026-7-14","2026-7-15","2026-7-16","2026-7-17","2026-7-18","2026-7-19","2026-7-20","2026-7-21","2026-7-22","2026-7-23","2026-7-24","2026-7-25","2026-7-26","2026-7-27","2026-7-28","2026-7-29","2026-7-30","2026-7-31","2026-8-1","2026-8-2","2026-8-3","2026-8-4","2026-8-5","2026-8-6","2026-8-7","2026-8-8","2026-8-9","2026-8-10","2026-8-11","2026-8-12","2026-8-13","2026-8-14","2026-8-15","2026-8-16","2026-8-17","2026-8-18","2026-8-19","2026-8-20","2026-8-21","2026-8-22","2026-8-23","2026-8-24","2026-8-25","2026-8-26","2026-8-27","2026-8-28","2026-8-29","2026-8-30","2026-8-31","2026-9-1","2026-9-2","2026-9-3","2026-9-4","2026-9-5","2026-9-6","2026-9-7","2026-9-8","2026-9-9","2026-9-10","2026-9-11","2026-9-12","2026-9-13","2026-9-14","2026-9-15","2026-9-16","2026-9-17","2026-9-18","2026-9-19","2026-9-20","2026-9-21","2026-9-22","2026-9-23","2026-9-24","2026-9-25","2026-9-26","2026-9-27","2026-9-28","2026-9-29","2026-9-30","2026-10-1","2026-10-2","2026-10-3","2026-10-4","2026-10-5","2026-10-6","2026-10-7","2026-10-8","2026-10-9","2026-10-10","2026-10-11","2026-10-12","2026-10-13","2026-10-14","2026-10-15","2026-10-16","2026-10-17","2026-10-18","2026-10-19","2026-10-20","2026-10-21","2026-10-22","2026-10-23","2026-10-24","2026-10-25","2026-10-26","2026-10-27","2026-10-28","2026-10-29","2026-10-30","2026-10-31"], samaki: ["2026-5-21","2026-5-22","2026-5-23","2026-5-24","2026-5-25","2026-5-26","2026-5-27","2026-5-28","2026-5-29","2026-5-30","2026-5-31","2026-6-1","2026-6-2","2026-6-3","2026-6-4","2026-6-5","2026-6-6","2026-6-7","2026-6-8","2026-6-9","2026-6-10","2026-6-11","2026-6-12","2026-6-13","2026-6-14","2026-6-15","2026-6-16","2026-6-17","2026-6-18","2026-6-19","2026-6-20","2026-6-21","2026-6-22","2026-6-23","2026-6-24","2026-6-25","2026-6-26","2026-6-27","2026-6-28","2026-6-29","2026-6-30","2026-7-1","2026-7-2","2026-7-3","2026-7-4","2026-7-5","2026-7-6","2026-7-7","2026-7-8","2026-7-9","2026-7-10","2026-7-11","2026-7-12","2026-7-13","2026-7-14","2026-7-15","2026-7-16","2026-7-17","2026-7-18","2026-7-19","2026-7-20","2026-7-21","2026-7-22","2026-7-23","2026-7-24","2026-7-25","2026-7-26","2026-7-27","2026-7-28","2026-7-29","2026-7-30","2026-7-31","2026-8-1","2026-8-2","2026-8-3","2026-8-4","2026-8-5","2026-8-6","2026-8-7","2026-8-8","2026-8-9","2026-8-10","2026-8-11","2026-8-12","2026-8-13","2026-8-14","2026-8-15","2026-8-16","2026-8-17","2026-8-18","2026-8-19","2026-8-20","2026-8-21","2026-8-22","2026-8-23","2026-8-24","2026-8-25","2026-8-26","2026-8-27","2026-8-28","2026-8-29","2026-8-30","2026-8-31","2026-9-1","2026-9-2","2026-9-3","2026-9-4","2026-9-5","2026-9-6","2026-9-7","2026-9-8","2026-9-9","2026-9-10","2026-9-11","2026-9-12","2026-9-13","2026-9-14","2026-9-15","2026-9-16","2026-9-17","2026-9-18","2026-9-19","2026-9-20","2026-9-21","2026-9-22","2026-9-23","2026-9-24","2026-9-25","2026-9-26","2026-9-27","2026-9-28","2026-9-29","2026-9-30","2026-10-1","2026-10-2","2026-10-3","2026-10-4","2026-10-5","2026-10-6","2026-10-7","2026-10-8","2026-10-9","2026-10-10","2026-10-11","2026-10-12","2026-10-13","2026-10-14","2026-10-15","2026-10-16","2026-10-17","2026-10-18","2026-10-19","2026-10-20","2026-10-21","2026-10-22","2026-10-23","2026-10-24","2026-10-25","2026-10-26","2026-10-27","2026-10-28","2026-10-29","2026-10-30","2026-10-31"] }; async function apiFetchAvailability() { try { const res = await fetch(API_URL + "?action=availability", { cache: "no-store" }); if (!res.ok) throw new Error("api " + res.status); const j = await res.json(); if (j && j.ok && j.data) return j.data; throw new Error("bad payload"); } catch (e) { return null; } } // Try the real api.php; if unreachable (preview / offline), fall back to a // localStorage-backed mock so the experience still works end-to-end. const ADMIN_MOCK_PW = "SeaLaVie2026!"; const MOCK_BOOKINGS_KEY = "slv_bookings_v1"; function mockLoadBookings() { try { return JSON.parse(localStorage.getItem(MOCK_BOOKINGS_KEY) || "[]"); } catch(e) { return []; } } function mockSaveBookings(arr) { try { localStorage.setItem(MOCK_BOOKINGS_KEY, JSON.stringify(arr)); } catch(e) {} } function mockRef() { return "SLV-" + Math.random().toString(36).toUpperCase().replace(/[^A-Z0-9]/g,"").slice(0, 6); } // If the visitor is signed in, attach this booking to their account on the // client (so they see it in their portal even when there's no backend). function attachBookingToAccount(booking) { try { const s = JSON.parse(localStorage.getItem("slv_session_v1") || "null"); if (!s || !s.email) return; const accs = JSON.parse(localStorage.getItem("slv_accounts_v1") || "{}"); const acc = accs[s.email]; if (!acc) return; acc.bookings = acc.bookings || []; // Avoid duplicates if (!acc.bookings.find(b => b.ref === booking.ref)) { acc.bookings.push({ ref: booking.ref, villaId: booking.villaId, arrival: booking.arrival, departure: booking.departure, nights: booking.nights, guests: booking.guests, status: booking.status || "held", totalUSD: booking.totalUSD || null, createdAt: booking.createdAt || new Date().toISOString() }); localStorage.setItem("slv_accounts_v1", JSON.stringify(accs)); } } catch(e) {} } async function apiBook(payload) { try { const res = await fetch(API_URL + "?action=book", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (res.ok) { const j = await res.json(); if (j && j.ok) attachBookingToAccount({ ...payload, ref: j.ref, status: j.status }); return j; } } catch(e) { /* fall through to mock */ } // ── MOCK FALLBACK ─────────────────────────────────────── // Reserve the dates locally (front-end already does this optimistically // via useAvailability.addBooking) and record a "held" booking. const ref = mockRef(); const row = { ref, villaId: payload.villaId, arrival: payload.arrival, departure: payload.departure, nights: payload.nights, guests: payload.guests, dateKeys: payload.dateKeys, name: payload.name, email: payload.email, phone: payload.phone, message: payload.message, status: payload.villaId === "any" ? "enquiry" : "held", createdAt: new Date().toISOString() }; const all = mockLoadBookings(); all.push(row); mockSaveBookings(all); attachBookingToAccount(row); return { ok: true, ref, status: row.status, mock: true }; } async function apiEnquiry(payload) { try { const res = await fetch(API_URL + "?action=enquiry", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (res.ok) return res.json(); } catch(e) {} // Mock: just acknowledge (a real send would go via the user's mail client). return { ok: true, mock: true }; } async function apiAdmin(payload) { try { const res = await fetch(API_URL + "?action=admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (res.ok) return res.json(); } catch(e) {} // ── MOCK FALLBACK ─────────────────────────────────────── if (payload.password !== ADMIN_MOCK_PW) { return { ok: false, error: "Wrong password (demo: use " + ADMIN_MOCK_PW + ")" }; } const data = loadLocal(); const bookings = mockLoadBookings(); switch (payload.op) { case "login": return { ok: true, data, bookings, mock: true }; case "toggle": { const v = payload.villaId, k = payload.dateKey; const list = new Set(data[v] || []); if (list.has(k)) list.delete(k); else list.add(k); data[v] = [...list]; saveLocal(data); window.dispatchEvent(new CustomEvent("slv:availability", { detail: data })); return { ok: true, data }; } case "set": { data[payload.villaId] = [...new Set(payload.dates || [])]; saveLocal(data); window.dispatchEvent(new CustomEvent("slv:availability", { detail: data })); return { ok: true, data }; } case "reset": { const fresh = JSON.parse(JSON.stringify(SEED_AVAILABILITY)); saveLocal(fresh); window.dispatchEvent(new CustomEvent("slv:availability", { detail: fresh })); return { ok: true, data: fresh }; } case "clear": { Object.keys(data).forEach(k => data[k] = []); saveLocal(data); window.dispatchEvent(new CustomEvent("slv:availability", { detail: data })); return { ok: true, data }; } case "confirmBooking": { const next = bookings.map(b => b.ref === payload.ref ? { ...b, status: "confirmed" } : b); mockSaveBookings(next); return { ok: true, bookings: next }; } case "cancelBooking": { const removed = bookings.find(b => b.ref === payload.ref); const kept = bookings.filter(b => b.ref !== payload.ref); if (removed && removed.villaId && removed.villaId !== "any" && Array.isArray(removed.dateKeys)) { const cur = new Set(data[removed.villaId] || []); removed.dateKeys.forEach(k => cur.delete(k)); data[removed.villaId] = [...cur]; saveLocal(data); window.dispatchEvent(new CustomEvent("slv:availability", { detail: data })); } mockSaveBookings(kept); return { ok: true, data, bookings: kept }; } default: return { ok: false, error: "Unknown op" }; } } function loadLocal() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return JSON.parse(JSON.stringify(SEED_AVAILABILITY)); return JSON.parse(raw); } catch(e) { return JSON.parse(JSON.stringify(SEED_AVAILABILITY)); } } function saveLocal(data) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch(e) {} } // React hook — live availability with backend sync function useAvailability() { const [data, setData] = React.useState(() => loadLocal()); React.useEffect(() => { let alive = true; apiFetchAvailability().then(remote => { if (alive && remote) { setData(remote); saveLocal(remote); } }); // Refresh every 30 seconds while page is open const t = setInterval(() => { apiFetchAvailability().then(remote => { if (alive && remote) { setData(remote); saveLocal(remote); } }); }, 30000); return () => { alive = false; clearInterval(t); }; }, []); React.useEffect(() => { function handle(e) { setData(e.detail); saveLocal(e.detail); } window.addEventListener("slv:availability", handle); return () => window.removeEventListener("slv:availability", handle); }, []); const isBooked = React.useCallback((villaId, key) => { return (data[villaId] || []).includes(key); }, [data]); const anyBooked = React.useCallback((key) => { const villas = Object.values(data); if (villas.length === 0) return false; return villas.every(arr => arr.includes(key)); }, [data]); // Optimistically update locally; the server is the source of truth on reload. const addBooking = React.useCallback((villaId, dateKeys) => { if (villaId === "any") return; const next = { ...data }; const list = new Set(next[villaId] || []); dateKeys.forEach(k => list.add(k)); next[villaId] = [...list]; setData(next); saveLocal(next); window.dispatchEvent(new CustomEvent("slv:availability", { detail: next })); }, [data]); return { data, isBooked, addBooking, anyBooked }; } Object.assign(window, { VILLAS, EXPERIENCES, CUISINE, JOURNAL, PHOTO, CONTACT, MONTHS, DOW, daysInMonth, firstDow, fmtDate, fmtShort, dateKey, dateKeyFor, useAvailability, apiFetchAvailability, apiBook, apiEnquiry, apiAdmin, SEED_AVAILABILITY, API_URL, CURRENCIES, useCurrency, formatPrice, getStoredCurrency, setStoredCurrency }); // ─── ACCOUNTS · LOYALTY ──────────────────────────────────────────────────── // Backed by api.php?action=account on Hostinger; falls back to localStorage // for preview / offline. Three tiers, unlocked at 0 / 10 / 25 nights. const TIERS = [ { id: "karibu", name: "Karibu", sub: "welcome", minNights: 0, accent: "var(--sand)", accentInk: "var(--ink)", summary: "The opening chapter. From your first night on the coast.", perks: [ { l: "Personal travel curator", s: "One named human, from booking through departure." }, { l: "Welcome basket on arrival", s: "Tropical fruit, mineral water, fresh coconut, a hand-written note." }, { l: "Early check-in when available", s: "We hold your residence from 13:00 whenever the calendar allows." }, { l: "Daily breakfast for two", s: "Plated on your verandah, or on the sand if you prefer." } ], hiddenCount: 1 }, { id: "rafiki", name: "Rafiki", sub: "friend", minNights: 10, accent: "var(--sky)", accentInk: "var(--ink)", summary: "Unlocked at ten nights with us — across one stay or many.", perks: [ { l: "All Karibu benefits", s: "Carried forward, always." }, { l: "Complimentary sunset dhow", s: "Once per stay. Two hours on the water, with crew." }, { l: "Pre-arrival shopping", s: "Send us a list — your kitchen is stocked before you walk in." }, { l: "Residence upgrade", s: "Subject to availability, applied automatically at check-in." }, { l: "Late check-out", s: "Until 16:00 on departure day, every stay." } ], hiddenCount: 1 }, { id: "mwenyeji", name: "Mwenyeji", sub: "one of us", minNights: 25, accent: "var(--ink)", accentInk: "var(--ivory)", summary: "Twenty-five nights and you have stopped being a guest.", perks: [ { l: "All Rafiki benefits", s: "Compounding, naturally." }, { l: "Private half-day dhow charter", s: "Once a year. Captain, lunch, snorkel kit aboard." }, { l: "Helicopter transfer credit", s: "USD 600 toward arrival from Mombasa or Wilson." }, { l: "Off-menu wine cellar access", s: "Open invitation to walk the cellar with our sommelier." }, { l: "A tree planted in your name", s: "In the residences' indigenous forest, with co-ordinates." }, { l: "Lock-in pricing for life", s: "Today's nightly rate, frozen on your first Mwenyeji stay." } ], hiddenCount: 2 } ]; function tierForNights(n) { let cur = TIERS[0]; for (const t of TIERS) if (n >= t.minNights) cur = t; return cur; } function nextTierFor(n) { for (const t of TIERS) if (n < t.minNights) return t; return null; } const ACC_KEY = "slv_accounts_v1"; const SESSION_KEY = "slv_session_v1"; function loadAccounts() { try { return JSON.parse(localStorage.getItem(ACC_KEY) || "{}"); } catch(e) { return {}; } } function saveAccounts(a) { try { localStorage.setItem(ACC_KEY, JSON.stringify(a)); } catch(e) {} } function currentSession() { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch(e) { return null; } } function setSession(s) { if (s) localStorage.setItem(SESSION_KEY, JSON.stringify(s)); else localStorage.removeItem(SESSION_KEY); window.dispatchEvent(new CustomEvent("slv:session", { detail: s })); } // Seed a demo account so the preview is immediately legible. (function seedDemo() { const a = loadAccounts(); if (a["demo@sealaviediani.com"]) return; a["demo@sealaviediani.com"] = { email: "demo@sealaviediani.com", name: "Amara Otieno", password: "demo", joinedAt: "2025-03-12T10:00:00Z", bookings: [ { ref: "SLV-A91B22", villaId: "bahari", arrival: "2025-04-09", departure: "2025-04-14", nights: 5, guests: 2, status: "completed", totalUSD: 2640, createdAt: "2025-03-12T10:14:00Z" }, { ref: "SLV-C04D17", villaId: "pwani", arrival: "2025-09-18", departure: "2025-09-22", nights: 4, guests: 2, status: "completed", totalUSD: 2112, createdAt: "2025-08-01T12:00:00Z" }, { ref: "SLV-E2207F", villaId: "kasi", arrival: "2026-12-14", departure: "2026-12-17", nights: 3, guests: 4, status: "confirmed", totalUSD: 1980, createdAt: "2026-04-30T09:22:00Z" } ] }; saveAccounts(a); })(); async function acctRegister({ name, email, password }) { email = (email || "").trim().toLowerCase(); if (!name || !email || !password) return { ok: false, error: "All fields are required." }; if (password.length < 4) return { ok: false, error: "Password should be at least 4 characters." }; try { const r = await fetch(API_URL + "?action=account", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "register", name, email, password }) }); if (r.ok) { const j = await r.json(); if (j && j.ok) { setSession({ email, name: j.name || name }); return { ok: true }; } if (j && j.error) return { ok: false, error: j.error }; } } catch(e) {} const a = loadAccounts(); if (a[email]) return { ok: false, error: "An account with that email already exists. Sign in instead." }; a[email] = { email, name: name.trim(), password, joinedAt: new Date().toISOString(), bookings: [] }; saveAccounts(a); setSession({ email, name: name.trim() }); return { ok: true }; } async function acctLogin({ email, password }) { email = (email || "").trim().toLowerCase(); if (!email || !password) return { ok: false, error: "Enter your email and password." }; try { const r = await fetch(API_URL + "?action=account", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "login", email, password }) }); if (r.ok) { const j = await r.json(); if (j && j.ok) { setSession({ email, name: j.name || email }); return { ok: true }; } if (j && j.error) return { ok: false, error: j.error }; } } catch(e) {} const a = loadAccounts(); const acc = a[email]; if (!acc || acc.password !== password) return { ok: false, error: "Email or password not recognised." }; setSession({ email, name: acc.name }); return { ok: true }; } async function acctFetchMe() { const s = currentSession(); if (!s) return null; try { const r = await fetch(API_URL + "?action=account", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "me", email: s.email }) }); if (r.ok) { const j = await r.json(); if (j && j.ok && j.account) return j.account; } } catch(e) {} const a = loadAccounts(); return a[s.email] || null; } function acctSignOut() { setSession(null); } // Cancel a guest's own booking — tries the server, falls back to localStorage. // Frees the dates in availability either way. async function acctCancelBooking({ ref }) { const s = currentSession(); if (!s) return { ok: false, error: "Not signed in." }; try { const r = await fetch(API_URL + "?action=account", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "cancelBooking", email: s.email, ref }) }); if (r.ok) { const j = await r.json(); if (j && j.ok) { // Sync availability + local account cache if (j.availability) { saveLocal(j.availability); window.dispatchEvent(new CustomEvent("slv:availability", { detail: j.availability })); } markBookingCancelledLocal(s.email, ref); return { ok: true }; } if (j && j.error) return { ok: false, error: j.error }; } } catch(e) {} // ── Local fallback ───────────────────────────────────── const accs = loadAccounts(); const acc = accs[s.email]; if (!acc) return { ok: false, error: "Account not found." }; const b = (acc.bookings || []).find(x => x.ref === ref); if (!b) return { ok: false, error: "Booking not found." }; if (b.status === "cancelled") return { ok: false, error: "Already cancelled." }; // Free its dates in the local availability store if (b.villaId && b.villaId !== "any") { const avail = loadLocal(); const cur = new Set(avail[b.villaId] || []); // Reconstruct date keys between arrival and departure if (Array.isArray(b.dateKeys) && b.dateKeys.length) { b.dateKeys.forEach(k => cur.delete(k)); } else { const d = new Date(b.arrival), end = new Date(b.departure); while (d < end) { cur.delete(`${d.getFullYear()}-${d.getMonth()+1}-${d.getDate()}`); d.setDate(d.getDate() + 1); } } avail[b.villaId] = [...cur]; saveLocal(avail); window.dispatchEvent(new CustomEvent("slv:availability", { detail: avail })); } // Also remove from the mock bookings ledger if present const ledger = mockLoadBookings(); const idx = ledger.findIndex(x => x.ref === ref); if (idx >= 0) { ledger[idx] = { ...ledger[idx], status: "cancelled" }; mockSaveBookings(ledger); } b.status = "cancelled"; b.cancelledAt = new Date().toISOString(); saveAccounts(accs); return { ok: true }; } function markBookingCancelledLocal(email, ref) { const accs = loadAccounts(); const acc = accs[email]; if (!acc) return; const b = (acc.bookings || []).find(x => x.ref === ref); if (b) { b.status = "cancelled"; b.cancelledAt = new Date().toISOString(); saveAccounts(accs); } } function useAccount() { const [session, setS] = React.useState(currentSession); const [account, setAcct] = React.useState(null); const [loading, setLoading] = React.useState(true); const [tick, setTick] = React.useState(0); React.useEffect(() => { function onSess(e) { setS(e.detail); } window.addEventListener("slv:session", onSess); return () => window.removeEventListener("slv:session", onSess); }, []); React.useEffect(() => { let alive = true; if (!session) { setAcct(null); setLoading(false); return; } setLoading(true); acctFetchMe().then(a => { if (alive) { setAcct(a); setLoading(false); } }); return () => { alive = false; }; }, [session && session.email, tick]); const refresh = React.useCallback(() => setTick(t => t + 1), []); return { session, account, loading, refresh }; } function deriveStats(bookings) { const list = bookings || []; let nights = 0, stays = 0, upcoming = 0; const today = new Date(); today.setHours(0,0,0,0); for (const b of list) { if (b.status === "cancelled") continue; if (b.status === "completed" || (b.departure && new Date(b.departure) < today)) { nights += b.nights || 0; stays += 1; } else if (b.status === "confirmed" || b.status === "held") { upcoming += 1; } } return { nights, stays, upcoming }; } Object.assign(window, { TIERS, tierForNights, nextTierFor, acctRegister, acctLogin, acctSignOut, acctFetchMe, acctCancelBooking, useAccount, currentSession, loadAccounts, saveAccounts, deriveStats });