);
}
// ---------- 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 ?
);
};
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 (