/* global React, ReactDOM */ // ============================================================ // The Gamekeeper's Cottage — shared site chrome // Header, Footer, Photo, Booking modal, Lightbox, calendar. // Composes design-system components from the bundle namespace. // ============================================================ const NS = window.GamekeepersCottageDesignSystem_070bc7; const { Button, Input, Badge, Rating, Eyebrow, Card } = NS; const { useState, useEffect, useRef, createContext, useContext, useCallback } = React; const IMG = window.GK_IMG; const PHONE = '01767 261407'; const PHONE2 = '07915 615207'; const EMAIL = 'nickbomayne@tiscali.co.uk'; const NAV = [ ['/', 'The Cottage'], ['cottage', 'Cottage Details'], ['gallery', 'Gallery'], ['explore', 'Explore Suffolk'], ['reviews', 'Reviews'], ['offers', 'Offers'], ['blog', 'Blog'], ['contact', 'Contact'], ]; const EXPLORE_SUB = [ ['explore', 'All things to do'], ['explore?filter=' + encodeURIComponent('Food and Drink'), 'Food and Drink'], ['explore?filter=' + encodeURIComponent('Attractions'), 'Attractions'], ['explore?filter=' + encodeURIComponent('Museums and Galleries'), 'Museums and Galleries'], ['explore?filter=' + encodeURIComponent('Tours'), 'Tours'], ['explore?filter=' + encodeURIComponent('Theatres'), 'Theatres'], ]; // ---------- Shell context ---------- const ShellCtx = createContext({ openBooking: () => {}, openLightbox: () => {} }); const useShell = () => useContext(ShellCtx); // ---------- Photo ---------- const PHOTO_BG = { wood: 'radial-gradient(120% 100% at 25% 15%, #7d9a6b 0%, #4f7146 42%, #2f5d3a 100%)', canopy: 'linear-gradient(160deg, #6f8f5f 0%, #3c6340 60%, #24492d 100%)', interior: 'linear-gradient(155deg, #cdbb9c 0%, #a98a68 55%, #5b4636 100%)', beach: 'linear-gradient(165deg, #e8ddc6 0%, #c9b58e 50%, #8a6a4f 100%)', bird: 'radial-gradient(110% 100% at 70% 20%, #a8b99a 0%, #6f8f5f 55%, #3c6340 100%)', }; function Photo({ src, alt = '', label, ratio = '4 / 3', position = 'center', radius = 0, variant = 'wood', className = '', style = {}, onClick }) { const wrap = { position: 'relative', width: '100%', aspectRatio: ratio, borderRadius: radius, overflow: 'hidden', background: src ? '#3c6340' : (PHOTO_BG[variant] || PHOTO_BG.wood), cursor: onClick ? 'zoom-in' : undefined, ...style, }; const interactive = onClick ? { role: 'button', tabIndex: 0, 'aria-label': (label || alt) ? `View photo: ${label || alt}` : 'View photo', onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(e); } }, } : {}; return (
{src && {alt} {label && ( {label} )}
); } // ---------- Logo ---------- function Feather({ size = 38 }) { return ( ); } function Wordmark() { return ( The Gamekeeper'sCOTTAGE ); } // Explore categories derived from the places data, so the nav dropdown is dynamic. function gkUniqueCats(places) { const seen = []; (places || []).forEach(p => { if (p && p.category && seen.indexOf(p.category) === -1) seen.push(p.category); }); return seen; } let _exCatsCache = null, _exCatsPromise = null; function loadExploreCategories() { if (_exCatsCache) return Promise.resolve(_exCatsCache); if (window.GK_ATTRACTIONS && Array.isArray(window.GK_ATTRACTIONS.places)) { _exCatsCache = gkUniqueCats(window.GK_ATTRACTIONS.places); return Promise.resolve(_exCatsCache); } if (_exCatsPromise) return _exCatsPromise; _exCatsPromise = (async () => { try { const r = await fetch('assets/attractions.json', { cache: 'no-store' }); if (r.ok) { const d = await r.json(); if (d && Array.isArray(d.places)) { _exCatsCache = gkUniqueCats(d.places); return _exCatsCache; } } } catch (e) { /* keep fallback */ } return null; })(); return _exCatsPromise; } function useExploreCategories() { const [c, setC] = useState(_exCatsCache); useEffect(() => { let live = true; loadExploreCategories().then(x => { if (live && x) setC(x); }); return () => { live = false; }; }, []); return c; } // ---------- Header ---------- function Header({ page }) { const { openBooking } = useShell(); const [open, setOpen] = useState(false); const exCats = useExploreCategories(); const exItems = (exCats && exCats.length) ? [['explore', 'All things to do'], ...exCats.map(c => ['explore?filter=' + encodeURIComponent(c), c])] : EXPLORE_SUB; return (
{NAV.map(([href, label]) => {label})}
); } // ---------- Footer ---------- function Footer() { const { openBooking } = useShell(); const [sent, setSent] = useState(false); const [nlEmail, setNlEmail] = useState(''); const [nlBusy, setNlBusy] = useState(false); const [nlErr, setNlErr] = useState(''); const submitNewsletter = async (e) => { e.preventDefault(); if (nlBusy) return; setNlErr(''); setNlBusy(true); try { await submitEnquiry({ type: 'newsletter', email: nlEmail }); setSent(true); } catch (err) { setNlErr('Sorry — that didn’t go through. Please try again in a moment.'); } finally { setNlBusy(false); } }; return ( ); } // ---------- Availability calendar ---------- // Live booked dates come from RentalBell (the same feed as the old site), served // as JSON from /api/availability.php on the Hostinger host (same domain, no CORS). // Falls back to the bundled snapshot in assets/availability.json so the calendar // still shows real data when previewed locally (file://) or if a fetch fails. const AVAIL_ENDPOINTS = ['api/availability.php', 'assets/availability.json']; let _availCache = null; // { booked: Set, validTo: Date|null, updated: string|null } let _availPromise = null; function loadAvailability() { if (_availCache) return Promise.resolve(_availCache); if (_availPromise) return _availPromise; const toCache = (data) => ({ booked: new Set(data.booked), // solid / unavailable checkin: new Set(data.checkin || []), // half: free -> booked checkout: new Set(data.checkout || []), // half: booked -> free validTo: data.valid_to ? new Date(data.valid_to + 'T23:59:59') : null, updated: data.updated || null, }); _availPromise = (async () => { // 1. Live / served JSON (works on a real host such as Hostinger). for (const url of AVAIL_ENDPOINTS) { try { const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) continue; const data = await res.json(); if (data && Array.isArray(data.booked)) { _availCache = toCache(data); return _availCache; } } catch (e) { /* try next source */ } } // 2. Embedded snapshot — works under file:// where fetch() is blocked. if (window.GK_AVAILABILITY && Array.isArray(window.GK_AVAILABILITY.booked)) { _availCache = toCache(window.GK_AVAILABILITY); return _availCache; } // 3. Nothing available — show everything bookable rather than blocking. _availCache = { booked: new Set(), checkin: new Set(), checkout: new Set(), validTo: null, updated: null }; return _availCache; })(); return _availPromise; } function useAvailability() { const [avail, setAvail] = useState(_availCache); useEffect(() => { let live = true; loadAvailability().then(a => { if (live) setAvail(a); }); return () => { live = false; }; }, []); return avail; } function isoDate(y, m, d) { return y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); } // ---------- Rates ---------- // Read the rate card from assets/rates.json (which Decap CMS edits) on the live // host; fall back to the embedded window.GK_RATES for local file:// previews. let _ratesCache = null, _ratesPromise = null; function loadRates() { if (_ratesCache) return Promise.resolve(_ratesCache); if (_ratesPromise) return _ratesPromise; _ratesPromise = (async () => { try { const res = await fetch('assets/rates.json', { cache: 'no-store' }); if (res.ok) { const data = await res.json(); if (data && Array.isArray(data.periods)) { _ratesCache = data; return data; } } } catch (e) { /* fall through */ } if (window.GK_RATES && Array.isArray(window.GK_RATES.periods)) { _ratesCache = window.GK_RATES; return _ratesCache; } _ratesCache = { periods: [], shortBreakNightly: null, minNights: 3, currency: '£' }; return _ratesCache; })(); return _ratesPromise; } function useRates() { const [rates, setRates] = useState(_ratesCache); useEffect(() => { let live = true; loadRates().then(r => { if (live) setRates(r); }); return () => { live = false; }; }, []); return rates; } function nightsBetween(a, b) { return Math.round((new Date(b) - new Date(a)) / 86400000); } function findPeriod(iso, periods) { return (periods || []).find(p => iso >= p.start && iso < p.end) || null; } function money(cur, n) { return (cur || '£') + Number(n).toLocaleString('en-GB'); } // Send a form (booking enquiry / contact / newsletter) to the server mail handler. // Resolves on success; throws otherwise so callers can show an error + phone fallback. // On the local Python preview there's no PHP, so this rejects — expected; use live/staging to test. async function submitEnquiry(payload) { const res = await fetch('api/enquiry.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); let data = null; try { data = await res.json(); } catch (e) { /* non-JSON (e.g. PHP not running locally) */ } if (!res.ok || !data || !data.ok) { throw new Error((data && data.error) || 'send-failed'); } return data; } // Guide quote for the selected stay. Always indicative — Nick confirms the exact // total — and falls back to 'advise' whenever we can't be certain. function quoteStay(arrive, depart, rates) { if (!rates || !arrive || !depart) return { mode: 'none' }; const cur = rates.currency || '£'; const nights = nightsBetween(arrive, depart); if (nights <= 0) return { mode: 'none' }; const p = findPeriod(arrive, rates.periods); if (!p) return { mode: 'advise', nights }; // outside known ranges if (p.available === false) return { mode: 'unavailable', nights, period: p }; // Per-period minimum: high-season weeks require a full week (min 7); others use the site minimum. const minN = (p.minNights != null) ? p.minNights : (rates.minNights || 3); if (nights < minN) return { mode: 'min', nights, minN, period: p }; if (depart > p.end) return { mode: 'advise', nights, period: p }; // spans a rate change if (p.unit === 'stay' && p.price != null) { return { mode: 'stay', nights, total: p.price, currency: cur, period: p }; } if (p.price != null) { if (nights % 7 === 0) { const weeks = nights / 7; // Off-season offer: book two consecutive weeks and the second is half price. // Only applies to off-season weekly periods, and only when the whole stay // sits inside one period (depart <= p.end is already checked above). const offerOn = rates.secondWeekHalfPrice && rates.secondWeekHalfPrice.enabled && p.season === 'off' && p.unit === 'week'; if (offerOn && weeks === 2) { const total = p.price + Math.round(p.price / 2); return { mode: 'week', nights, weeks, total, weekly: p.price, offer: true, currency: cur, period: p }; } return { mode: 'week', nights, weeks, total: p.price * weeks, weekly: p.price, currency: cur, period: p }; } // Nightly short-break rate can vary by period/year (e.g. £130 in 2026, £140 in 2027). const shortNightly = (p.shortBreakNightly != null) ? p.shortBreakNightly : rates.shortBreakNightly; if (shortNightly) { const est = Math.min(nights * shortNightly, p.price); return { mode: 'short', nights, nightly: shortNightly, total: est, weekly: p.price, currency: cur, period: p }; } return { mode: 'advise', nights, weekly: p.price, period: p }; } return { mode: 'advise', nights, period: p }; } // Cross-check the chosen stay against the live availability calendar. // Returns 'free' | 'taken' | 'unknown' (beyond the data horizon) | null. function availabilityForStay(arrive, depart, avail) { if (!avail || !arrive || !depart) return null; const start = new Date(arrive + 'T00:00:00'); const end = new Date(depart + 'T00:00:00'); if (isNaN(start) || isNaN(end) || end <= start) return null; let taken = false, unknown = false; for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) { if (avail.validTo && d > avail.validTo) { unknown = true; continue; } const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); if (avail.booked.has(iso) || (avail.checkin && avail.checkin.has(iso))) taken = true; } if (taken) return 'taken'; if (unknown) return 'unknown'; return 'free'; } function AvailabilityNote({ status, quote }) { if (!status) return null; const underMin = quote && quote.mode === 'min'; const minN = (quote && quote.minN) || 3; const map = { free: { icon: 'calendar-check', bg: '#e7f1e3', color: '#2f5d3a', text: 'Good news — your dates look free. Send your enquiry and Nick will confirm and hold them for you.' }, taken: { icon: 'calendar-x', bg: '#fbeee0', color: '#8a5a1f', text: 'These dates look already taken — but do send an enquiry: cancellations happen, and Nick can suggest close-by dates.' }, unknown: { icon: 'calendar', bg: 'var(--surface-soft)', color: 'var(--text-body)', text: "That's a little beyond our published calendar — send an enquiry and Nick will confirm availability." }, }; // Dates are free but the stay is under our usual minimum — soften the promise to "hold". if (status === 'free' && underMin) { map.free = { icon: 'calendar-check', bg: '#e7f1e3', color: '#2f5d3a', text: 'Good news — your dates look free. They’re under our usual ' + minN + '-night minimum, but do send your enquiry: Nick can sometimes take shorter stays close to the date or in quieter seasons.' }; } const m = map[status]; return (
{m.text}
); } function RateGuide({ quote, rates }) { const cur = (quote && quote.currency) || (rates && rates.currency) || '£'; let body; if (!quote || quote.mode === 'none') { body = Choose your dates above for a guide price.; } else if (quote.mode === 'unavailable') { body = Those dates aren't available — please choose others.; } else if (quote.mode === 'min') { body = Minimum stay is {quote.minN} nights — add a night or two for a guide price.; } else if (quote.mode === 'advise') { body = (Owner to advise. We'll confirm the exact rate for your dates{quote.weekly ? ' (this period is around ' + money(cur, quote.weekly) + '/week)' : ''}.); } else { const label = quote.mode === 'week' ? (quote.weeks > 1 ? quote.weeks + ' weeks' : 'the week') : quote.mode === 'short' ? quote.nights + ' nights' : quote.nights + '-night stay'; body = (<>
{money(cur, quote.total)} guide for {label}
{quote.offer ? 'Second week half price applied. ' : ''} {quote.mode === 'short' ? 'Short break at ' + money(cur, quote.nightly) + '/night. ' : ''} Indicative only — Nick confirms the exact total. No payment now.
); } return
{body}
; } // Rate-card visibility: drop periods that have fully ended, and any period whose // every night is already booked (it reappears automatically once a date frees up, // since availability comes from the same live RentalBell feed as the calendar). function periodHasEnded(p, todayISO) { return !!p.end && p.end <= todayISO; } function periodFullyBooked(p, avail) { if (!avail || !avail.booked || !p.start || !p.end) return false; const end = new Date(p.end + 'T00:00:00'); const d = new Date(p.start + 'T00:00:00'); if (isNaN(end) || isNaN(d) || end <= d) return false; for (; d < end; d.setDate(d.getDate() + 1)) { if (avail.validTo && d > avail.validTo) return false; // beyond known data — keep the row const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); const taken = avail.booked.has(iso) || (avail.checkin && avail.checkin.has(iso)); if (!taken) return false; // a free night — keep the row } return true; // every night booked — hide the row } function visibleRatePeriods(rates, avail) { if (!rates || !rates.periods) return []; const t = new Date(); const todayISO = t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); return rates.periods.filter(p => !periodHasEnded(p, todayISO) && !periodFullyBooked(p, avail)); } function RateCard({ rates }) { const avail = useAvailability(); if (!rates || !rates.periods || !rates.periods.length) return null; const cur = rates.currency || '£'; const periods = visibleRatePeriods(rates, avail); if (!periods.length) return null; return (
See the full rate card {rates.intro ?

{rates.intro}

: null}
); } function MiniCalendar({ months = 1, onPick, focus, selStart, selEnd }) { const today = new Date(); const avail = useAvailability(); // Open on the month of `focus` (e.g. a date carried in from a booking bar), else today. const startMonth = (() => { if (focus && /^\d{4}-\d{2}-\d{2}$/.test(focus)) { const d = new Date(focus + 'T00:00:00'); if (!isNaN(d)) return { y: d.getFullYear(), m: d.getMonth() }; } return { y: today.getFullYear(), m: today.getMonth() }; })(); const [cursor, setCursor] = useState(startMonth); // Jump to the focused date's month when it changes (e.g. the date inputs are edited). // Manual prev/next navigation doesn't change `focus`, so it's preserved. useEffect(() => { if (focus && /^\d{4}-\d{2}-\d{2}$/.test(focus)) { const d = new Date(focus + 'T00:00:00'); if (!isNaN(d)) setCursor({ y: d.getFullYear(), m: d.getMonth() }); } }, [focus]); const monthName = (y, m) => new Date(y, m, 1).toLocaleString('en-GB', { month: 'long', year: 'numeric' }); const renderMonth = (y, m) => { const first = new Date(y, m, 1).getDay(); // 0 Sun const start = (first + 6) % 7; // make Monday-first const days = new Date(y, m + 1, 0).getDate(); const cells = []; for (let i = 0; i < start; i++) cells.push(); for (let d = 1; d <= days; d++) { const iso = isoDate(y, m, d); const past = new Date(y, m, d) < new Date(today.getFullYear(), today.getMonth(), today.getDate()); // Mirror RentalBell: event-day = solid booked; check-in / checkout = half // (turnover) days that can still start or end a stay, so they stay selectable. // Dates beyond the data horizon are treated as available — Nick confirms by enquiry. const booked = !!avail && avail.booked.has(iso); const checkin = !!avail && avail.checkin.has(iso); const checkout = !!avail && avail.checkout.has(iso); const unavailable = past || booked; // not selectable (incl. same-day turnovers, which are 'booked') const mod = unavailable ? 'cal__cell--busy' : checkin ? 'cal__cell--checkin' : checkout ? 'cal__cell--checkout' : 'cal__cell--free'; const isEnd = (selStart && iso === selStart) || (selEnd && iso === selEnd); const inRange = selStart && selEnd && iso > selStart && iso < selEnd; const selStyle = isEnd ? { outline: '2px solid var(--color-primary)', outlineOffset: '-2px' } : inRange ? { boxShadow: 'inset 0 0 0 2px rgba(47,93,58,0.28)' } : {}; cells.push( !unavailable && onPick && onPick(iso)}>{d} ); } return (
{monthName(y, m)}
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) => {d})} {cells}
); }; const shift = (n) => setCursor(c => { let m = c.m + n, y = c.y; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } return { y, m }; }); const list = []; for (let i = 0; i < months; i++) { let m = cursor.m + i, y = cursor.y; while (m > 11) { m -= 12; y++; } list.push([y, m]); } return (
Availability
{list.map(([y, m]) => renderMonth(y, m))}
Available Booked Changeover day
); } // ---------- Booking modal ---------- function BookingModal({ onClose, prefill }) { const [sent, setSent] = useState(false); const [submitting, setSubmitting] = useState(false); const [sendError, setSendError] = useState(''); const [form, setForm] = useState({ arrive: (prefill && prefill.arrive) || '', depart: (prefill && prefill.depart) || '', guests: (prefill && prefill.guests) || '2', name: '', email: '', phone: '', msg: '', company: '', }); const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value })); const [focusField, setFocusField] = useState('arrive'); // Changing arrival drags a now-earlier (or empty) departure with it, so the // departure picker doesn't open months in the past. Changing departure leaves arrival. const onArrive = (e) => { const v = e.target.value; setFocusField('arrive'); setForm(f => { const next = { ...f, arrive: v }; if (v && (!f.depart || f.depart < v)) next.depart = v; return next; }); }; const onDepart = (e) => { setFocusField('depart'); setForm(f => ({ ...f, depart: e.target.value })); }; const calFocus = form[focusField] || form.arrive || form.depart; const dateError = (form.arrive && form.depart && form.depart < form.arrive) ? 'Your departure date is before your arrival date — please pick a later departure.' : ''; const rates = useRates(); const quote = quoteStay(form.arrive, form.depart, rates); const avail = useAvailability(); const stayAvail = dateError ? null : availabilityForStay(form.arrive, form.depart, avail); // High-season full-week guidance: if the arrival falls in a high-season period // and the stay isn't a clean Saturday-to-Saturday week, gently flag Nick's // usual Sat–Sat full-week pattern — without blocking the enquiry. const arrPeriod = (rates && form.arrive && !dateError) ? findPeriod(form.arrive, rates.periods) : null; const stayNights = (form.arrive && form.depart && !dateError) ? nightsBetween(form.arrive, form.depart) : 0; const arrDow = form.arrive ? new Date(form.arrive + 'T00:00:00').getDay() : null; // 6 = Saturday const isSatToSatWeek = arrDow === 6 && stayNights > 0 && stayNights % 7 === 0; const showSatNote = !!arrPeriod && arrPeriod.season === 'high' && stayNights > 0 && !isSatToSatWeek; const onSubmitEnquiry = async (e) => { e.preventDefault(); if (dateError || submitting) return; setSendError(''); setSubmitting(true); try { await submitEnquiry({ type: 'booking', name: form.name, email: form.email, phone: form.phone, guests: form.guests, arrive: form.arrive, depart: form.depart, guide: (quote && quote.total != null) ? money(quote.currency || (rates && rates.currency) || '£', quote.total) : '', message: form.msg, company: form.company, }); setSent(true); } catch (err) { setSendError('Sorry — we couldn’t send that just now. Please call Nick on ' + PHONE + ', or email ' + EMAIL + '.'); } finally { setSubmitting(false); } }; const dialogRef = useRef(null); useEffect(() => { if (window.lucide) window.lucide.createIcons(); }, [stayAvail, dateError, showSatNote, quote.mode]); useEffect(() => { const prev = document.activeElement; if (dialogRef.current) dialogRef.current.focus(); const onKey = (e) => e.key === 'Escape' && onClose(); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('keydown', onKey); if (prev && prev.focus) prev.focus(); }; }, [onClose]); return (
e.stopPropagation()}> {sent ? (

Enquiry sent

Thank you, {form.name || 'and'} — Nick will confirm availability and the total for your dates, usually within a day. No deposit is taken yet.

) : (
Check rates & reserve

Tell us your dates

An enquiry, not a payment — we'll reply with the exact price and hold the dates for you.

{ if (!form.arrive || (form.arrive && form.depart)) { setForm(f => ({ ...f, arrive: iso, depart: '' })); setFocusField('arrive'); } else if (iso > form.arrive) { setForm(f => ({ ...f, depart: iso })); setFocusField('depart'); } else { setForm(f => ({ ...f, arrive: iso })); setFocusField('arrive'); } }} />
Sleeps 5 {rates && rates.shortBreakNightly ? '3 nights from ' + money(rates.currency || '£', rates.shortBreakNightly) + '/night' : '3 nights from £130/night'}
{dateError &&
{dateError}
} {showSatNote && (
In our busy season we normally book full weeks, Saturday to Saturday. Do send your enquiry — Nick will confirm what's possible for your dates.
)} {sendError &&
{sendError}
}

Prefer to talk? Call Nick on {PHONE}

)}
); } // ---------- Lightbox ---------- function Lightbox({ items, index, onClose, onIndex }) { const boxRef = useRef(null); useEffect(() => { const prev = document.activeElement; if (boxRef.current) boxRef.current.focus(); const onKey = (e) => { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowRight') onIndex((index + 1) % items.length); if (e.key === 'ArrowLeft') onIndex((index - 1 + items.length) % items.length); }; window.addEventListener('keydown', onKey); return () => { window.removeEventListener('keydown', onKey); if (prev && prev.focus) prev.focus(); }; }, [index, items, onClose, onIndex]); const cur = items[index]; return (
e.stopPropagation()}> {cur.label
e.stopPropagation()}> {cur.label} · {index + 1} / {items.length}
); } // ---------- Shell ---------- function SiteShell({ page, children }) { const [booking, setBooking] = useState(false); const [prefill, setPrefill] = useState(null); // { arrive, depart, guests } carried in from a booking bar const [lb, setLb] = useState(null); // { items, index } const openBooking = useCallback((data) => { setPrefill(data && typeof data === 'object' && data.nativeEvent === undefined ? data : null); setBooking(true); }, []); const openLightbox = useCallback((items, index = 0) => setLb({ items, index }), []); useEffect(() => { if (window.lucide) window.lucide.createIcons(); // a11y: the DS Rating stars carry aria-label on a roleless span — give it a role document.querySelectorAll('.gk-rating__stars[aria-label]:not([role])').forEach(e => e.setAttribute('role', 'img')); }); useEffect(() => { document.body.style.overflow = (booking || lb) ? 'hidden' : ''; }, [booking, lb]); // scroll reveal useEffect(() => { const els = document.querySelectorAll('.reveal'); const io = new IntersectionObserver((ents) => ents.forEach(en => { if (en.isIntersecting) { en.target.classList.add('in'); io.unobserve(en.target); } }), { threshold: 0.12 }); els.forEach(el => io.observe(el)); return () => io.disconnect(); }); return ( Skip to content
{children}