Booking Details
1Your stay
2Guests & dates
3Review
Choose your accommodation to begin
Select a unit above to see photos, capacity and pricing — then pick your guests and dates.
Next, choose your guests and dates below.
Who’s coming?
Adults
Age 13+
Children
Age 0–12
Please add at least one guest
From
Select a date
→
To
—
Total ZAR 0
Please select check-in and check-out dates
PLEASE SELECT ACCOMMODATION TYPE
Accommodation Summary
Calculating...
Booking Details
Guest 1 Info
Adult
Child
Add Packages Drag & Drop Packages of your choosing
Treatments
Accommodation Booking
R0.00
Example Massage
R2,121.00
Example Specialised Treatment
R1,234.00
Example Wrap
R999.00
PURIFYING HAND WASH
R467.00
Calculating rate...
';
document.getElementById('innCostSummary').classList.add('show');
fetch(ajaxUrl, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
state.rateData = data.data;
} else {
state.rateData = { error: data.data || 'Unknown error fetching rate' };
}
updateCostSummary();
})
.catch(err => {
console.error('Error fetching rate:', err);
state.rateData = null;
updateCostSummary();
});
}
function formatDateForApi(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return year + '-' + month + '-' + day;
}
function updateCostSummary() {
const summaryEl = document.getElementById('innCostSummary');
const detailsEl = document.getElementById('innCostDetails');
updateStepRail();
if (!state.checkIn || !state.checkOut || state.accommodation === 'none') {
summaryEl.classList.remove('show');
// Also clear calendar total if dates are not complete
if (!state.checkIn || !state.checkOut) {
document.getElementById('innCalendarTotal').textContent = 'Total ZAR 0';
}
return;
}
summaryEl.classList.add('show');
if (!state.rateData) {
detailsEl.innerHTML = 'Calculating rate...
';
// Update calendar total to show calculating
document.getElementById('innCalendarTotal').textContent = 'Calculating...';
return;
}
// Handle potential error in rate data
if (state.rateData.error) {
detailsEl.innerHTML = 'Rate unavailable: ' + state.rateData.error + '
';
document.getElementById('innCalendarTotal').textContent = 'Error calculating rate';
return;
}
const nights = Math.ceil((state.checkOut - state.checkIn) / (1000 * 60 * 60 * 24));
const totalGuests = state.adults + state.children;
let html = '';
html += 'Guests' + state.adults + ' Adults, ' + state.children + ' Children
';
html += 'Check-in' + state.checkIn.toLocaleDateString('en-ZA') + '
';
html += 'Check-out' + state.checkOut.toLocaleDateString('en-ZA') + '
';
html += 'Nights' + nights + '
';
html += 'Total AccommodationR ' + state.rateData.formattedTotal + '
';
detailsEl.innerHTML = html;
// Also update the calendar total
if (state.rateData && state.rateData.formattedTotal) {
document.getElementById('innCalendarTotal').textContent = 'Total ZAR ' + state.rateData.formattedTotal;
}
}
// ==================== ACCOMMODATION ====================
document.getElementById('innAccommodation').addEventListener('change', function() {
const selected = this.options[this.selectedIndex];
state.accommodation = this.value;
state.unitTypeId = this.value !== 'none' ? parseInt(this.value) : 0;
if (this.value !== 'none') {
state.maxAdults = parseInt(selected.dataset.maxAdults) || 10;
state.maxChildren = parseInt(selected.dataset.maxChildren) || 5;
state.maxGuests = parseInt(selected.dataset.maxGuests) || 10;
// Fetch availability for the selected accommodation
fetchAvailability();
} else {
state.maxAdults = 10;
state.maxChildren = 5;
state.maxGuests = 10;
state.availableDates = [];
state.availabilityLoaded = false;
}
// Adjust current counts if they exceed new max
if (state.adults > state.maxAdults) {
state.adults = state.maxAdults;
document.getElementById('innAdultsCount').value = state.adults;
}
if (state.children > state.maxChildren) {
state.children = state.maxChildren;
document.getElementById('innChildrenCount').value = state.children;
}
// Ensure total guests don't exceed max
while ((state.adults + state.children) > state.maxGuests && state.children > 0) {
state.children--;
document.getElementById('innChildrenCount').value = state.children;
}
while ((state.adults + state.children) > state.maxGuests && state.adults > 1) {
state.adults--;
document.getElementById('innAdultsCount').value = state.adults;
}
// Reset dates when accommodation changes
state.checkIn = null;
state.checkOut = null;
state.selectingCheckout = false;
state.rateData = null;
innUpdateDateDisplays();
// Update tabs state
updateTabsState();
updateStayHero();
updateCostSummary();
updateStepRail();
});
// ==================== GUEST COUNTER ====================
window.innUpdateCounter = function(type, delta) {
if (type === 'adults') {
const newVal = state.adults + delta;
if (newVal >= 1 && newVal <= state.maxAdults && (newVal + state.children) <= state.maxGuests) {
state.adults = newVal;
document.getElementById('innAdultsCount').value = newVal;
fetchRate();
refreshHeroFromPrice();
}
} else if (type === 'children') {
const newVal = state.children + delta;
if (newVal >= 0 && newVal <= state.maxChildren && (state.adults + newVal) <= state.maxGuests) {
state.children = newVal;
document.getElementById('innChildrenCount').value = newVal;
fetchRate();
}
}
innUpdateGuestsDisplay();
};
function innUpdateGuestsDisplay() {
// Reflect min/max limits on the inline counter buttons so the
// controls communicate their own bounds.
const aMinus = document.getElementById('innAdultsMinus');
const aPlus = document.getElementById('innAdultsPlus');
const cMinus = document.getElementById('innChildrenMinus');
const cPlus = document.getElementById('innChildrenPlus');
const total = state.adults + state.children;
if (aMinus) aMinus.disabled = state.adults <= 1;
if (aPlus) aPlus.disabled = state.adults >= state.maxAdults || total >= state.maxGuests;
if (cMinus) cMinus.disabled = state.children <= 0;
if (cPlus) cPlus.disabled = state.children >= state.maxChildren || total >= state.maxGuests;
// Update info message
const infoMsg = document.getElementById('innInfoMsg');
if (state.accommodation === 'none') {
infoMsg.textContent = 'PLEASE SELECT ACCOMMODATION TYPE';
} else if (total > 0) {
if (!state.checkIn || !state.checkOut) {
infoMsg.textContent = 'PLEASE SELECT CHECK-IN AND CHECK-OUT DATES';
} else {
infoMsg.textContent = '';
}
} else {
infoMsg.textContent = 'PLEASE SELECT THE NUMBER OF GUESTS';
}
}
// Re-quote the hero's "from" price for the current adult count. The
// grid/initial badge is computed at 1 adult server-side; this keeps the
// hero honest once the guest picks their party size.
let innFromPriceReq = 0;
function refreshHeroFromPrice() {
const priceEl = document.getElementById('innStayHeroPrice');
if (!priceEl || state.unitTypeId === 0) return;
const reqId = ++innFromPriceReq;
const fd = new FormData();
fd.append('action', 'inn_get_from_price');
fd.append('nonce', innNonce);
fd.append('unit_type_id', state.unitTypeId);
fd.append('adults', state.adults);
fetch(ajaxUrl, { method: 'POST', body: fd })
.then(function(r){ return r.json(); })
.then(function(data){
if (reqId !== innFromPriceReq) return; // a newer request superseded this one
if (data && data.success && data.data && data.data.formatted) {
priceEl.innerHTML = 'from ' + data.data.formatted + ' /night';
priceEl.hidden = false;
}
})
.catch(function(){ /* leave the existing badge in place */ });
}
// ==================== CALENDAR ====================
window.innRenderCalendars = function() {
const month1 = new Date(state.currentMonth);
const month2 = new Date(state.currentMonth);
month2.setMonth(month2.getMonth() + 1);
document.getElementById('innMonth1').innerHTML = innRenderMonth(month1, true);
document.getElementById('innMonth2').innerHTML = innRenderMonth(month2, false);
innUpdateRangeHint();
};
// Check if a date is available
function isDateAvailable(dateStr) {
// If accommodation is not selected, allow all dates
// Unit Type IDs can be negative, so only check for exactly 0 (not selected)
if (state.accommodation === 'none' || state.unitTypeId === 0) return true;
// If availability not loaded yet, don't allow selection (safer)
if (!state.availabilityLoaded) return false;
// If no available dates at all, nothing is available
if (state.availableDates.length === 0) return false;
return state.availableDates.includes(dateStr);
}
// Find the first unavailable date after check-in
function getMaxCheckoutDate() {
if (!state.checkIn) return null;
const checkInStr = formatDateForApi(state.checkIn);
let currentDate = new Date(state.checkIn);
currentDate.setDate(currentDate.getDate() + 1); // Start from day after check-in
// Look up to 365 days ahead
for (let i = 0; i < 365; i++) {
const dateStr = formatDateForApi(currentDate);
if (!isDateAvailable(dateStr)) {
// Return the day before the first unavailable date
return currentDate;
}
currentDate.setDate(currentDate.getDate() + 1);
}
return null; // No restriction found
}
function innRenderMonth(date, showPrevNav) {
const year = date.getFullYear();
const month = date.getMonth();
const monthName = date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDayOfWeek = firstDay.getDay();
const daysInMonth = lastDay.getDate();
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get max checkout date if we're selecting checkout
const maxCheckout = state.selectingCheckout ? getMaxCheckoutDate() : null;
let html = '';
if (showPrevNav) {
html += '';
} else {
html += '';
}
html += '' + monthName.toUpperCase() + '';
if (!showPrevNav) {
html += '';
} else {
html += '';
}
html += '
';
html += '';
['S', 'M', 'T', 'W', 'T', 'F', 'S'].forEach(d => {
html += '
';
html += '' + d + '
';
});
html += '';
// Empty cells
for (let i = 0; i < startDayOfWeek; i++) {
html += '';
}
// Days
for (let d = 1; d <= daysInMonth; d++) {
const dayDate = new Date(year, month, d);
dayDate.setHours(0, 0, 0, 0);
const isPast = dayDate < today;
const dateStr = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
// Check if date is available
const available = isDateAvailable(dateStr);
let classes = 'inn-day';
let isDisabled = false;
if (isPast) {
classes += ' disabled';
isDisabled = true;
} else if (!available) {
// Unavailable dates are always marked as unavailable and disabled
classes += ' unavailable';
isDisabled = true;
} else if (state.selectingCheckout) {
// For checkout selection, check additional constraints
if (state.checkIn && dayDate <= state.checkIn) {
// Can't checkout on or before check-in
classes += ' disabled';
isDisabled = true;
} else if (maxCheckout && dayDate >= maxCheckout) {
// Everything from the first unavailable night onward is
// off-limits — you can't book across a gap.
classes += ' unavailable';
isDisabled = true;
} else if (state.checkIn && dayDate > state.checkIn) {
// A reachable, contiguous check-out date: give it extra
// visual weight so valid end dates stand out.
classes += ' checkout-option';
}
}
// Check if selected
if (state.checkIn && dayDate.getTime() === state.checkIn.getTime()) {
classes += ' check-in selected';
} else if (state.checkOut && dayDate.getTime() === state.checkOut.getTime()) {
classes += ' check-out selected';
} else if (state.checkIn && state.checkOut && dayDate > state.checkIn && dayDate < state.checkOut) {
classes += ' in-range';
}
if (isDisabled) {
html += '
';
return html;
}
window.innNavigateMonth = function(delta) {
state.currentMonth.setMonth(state.currentMonth.getMonth() + delta);
innRenderCalendars();
};
window.innSelectDate = function(dateStr) {
const date = new Date(dateStr + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date < today) return;
// Check availability for check-in selection
if (!state.selectingCheckout && !isDateAvailable(dateStr)) {
return;
}
if (!state.checkIn || (state.checkIn && state.checkOut)) {
// Start fresh selection - this is check-in
// Must be available for check-in
if (!isDateAvailable(dateStr)) {
return;
}
state.checkIn = date;
state.checkOut = null;
state.selectingCheckout = true;
// Keep calendar open for checkout selection
} else if (state.selectingCheckout) {
if (date <= state.checkIn) {
// If selected date is before or equal to check-in, make it new check-in
// Must be available for check-in
if (!isDateAvailable(dateStr)) {
return;
}
state.checkIn = date;
state.checkOut = null;
// Keep selectingCheckout = true
} else {
// Valid checkout date selected - check availability
if (!isDateAvailable(dateStr)) {
return;
}
state.checkOut = date;
state.selectingCheckout = false;
// Fetch rate now that both dates are selected
fetchRate();
}
}
innRenderCalendars();
innUpdateDateDisplays();
innUpdateRangeHint();
};
// Top-of-calendar cue: "From ___" before any pick, "From ' + d + '
';
} else {
html += '' + d + '
';
}
}
html += 'No treatments added yet
'; return; } let html = ''; treatments.forEach((t, idx) => { html += '';
html += '
';
});
container.innerHTML = html;
}
window.innRemoveTreatment = function(idx) {
state.guests[state.currentGuestIndex].treatments.splice(idx, 1);
innRenderGuestTreatments();
};
// ==================== DRAG AND DROP ====================
let draggedTreatmentData = null;
window.innDragStart = function(event) {
const row = event.target.closest('.inn-treatment-row');
if (!row) return;
row.classList.add('dragging');
// Store treatment data
draggedTreatmentData = {
id: row.dataset.id,
name: row.dataset.name,
price: parseFloat(row.dataset.price)
};
// Set drag data (required for Firefox)
event.dataTransfer.setData('text/plain', JSON.stringify(draggedTreatmentData));
event.dataTransfer.effectAllowed = 'copy';
};
window.innDragEnd = function(event) {
const row = event.target.closest('.inn-treatment-row');
if (row) {
row.classList.remove('dragging');
}
draggedTreatmentData = null;
};
window.innDragOver = function(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
const dropZone = document.getElementById('innGuestTreatmentsList');
dropZone.classList.add('drag-over');
};
window.innDragLeave = function(event) {
const dropZone = document.getElementById('innGuestTreatmentsList');
// Only remove class if we're actually leaving the drop zone
if (!dropZone.contains(event.relatedTarget)) {
dropZone.classList.remove('drag-over');
}
};
window.innDrop = function(event) {
event.preventDefault();
const dropZone = document.getElementById('innGuestTreatmentsList');
dropZone.classList.remove('drag-over');
// Use stored data or parse from dataTransfer
let treatment = draggedTreatmentData;
if (!treatment) {
try {
treatment = JSON.parse(event.dataTransfer.getData('text/plain'));
} catch (e) {
return;
}
}
if (treatment && treatment.id && treatment.name) {
// Add treatment to current guest
state.guests[state.currentGuestIndex].treatments.push({
id: treatment.id,
name: treatment.name,
price: treatment.price
});
innRenderGuestTreatments();
innShowToast(treatment.name + ' added to Guest ' + (state.currentGuestIndex + 1), 'success', 2000);
}
};
// ==================== CHECKOUT ====================
window.innProceedToCheckout = function() {
innSaveGuestData();
// Validate all guests have name and surname
for (let i = 0; i < state.guests.length; i++) {
const guest = state.guests[i];
if (!guest.name || !guest.name.trim()) {
// Switch to this guest
state.currentGuestIndex = i;
innRenderGuestForm();
// Focus on name field and add error styling
const nameField = document.getElementById('innGuestName');
nameField.classList.add('inn-input-error');
nameField.focus();
// Remove error styling after user starts typing
nameField.addEventListener('input', function() {
this.classList.remove('inn-input-error');
}, { once: true });
innShowToast('All guests must have a name and surname filled in', 'error');
return;
}
if (!guest.surname || !guest.surname.trim()) {
// Switch to this guest
state.currentGuestIndex = i;
innRenderGuestForm();
// Focus on surname field and add error styling
const surnameField = document.getElementById('innGuestSurname');
surnameField.classList.add('inn-input-error');
surnameField.focus();
// Remove error styling after user starts typing
surnameField.addEventListener('input', function() {
this.classList.remove('inn-input-error');
}, { once: true });
innShowToast('All guests must have a name and surname filled in', 'error');
return;
}
}
// Aggregate treatments for cart
const treatmentCounts = {};
state.guests.forEach(guest => {
guest.treatments.forEach(t => {
if (!treatmentCounts[t.id]) {
treatmentCounts[t.id] = {
id: t.id,
name: t.name,
price: t.price,
quantity: 0
};
}
treatmentCounts[t.id].quantity++;
});
});
// Build cart data
const cartData = {
accommodation: state.accommodation,
unitTypeId: state.unitTypeId,
checkIn: state.checkIn ? state.checkIn.toISOString().split('T')[0] : null,
checkOut: state.checkOut ? state.checkOut.toISOString().split('T')[0] : null,
adults: state.adults,
children: state.children,
guests: state.guests,
treatments: Object.values(treatmentCounts),
rateData: state.rateData
};
// Also keep a copy client-side in case the user lands back on the
// booking page (eg. via the WC "← Return to shop" link) so the
// selection can be restored from localStorage later.
try { localStorage.setItem('innBookingCart', JSON.stringify(cartData)); } catch (e) {}
// Disable the button while the request is in flight so a double
// click can't add the booking twice.
const nextBtn = document.querySelector('.inn-btn-checkout');
if (nextBtn) {
nextBtn.disabled = true;
nextBtn.dataset.origLabel = nextBtn.textContent;
nextBtn.textContent = 'Adding to cart…';
}
const restoreBtn = function() {
if (nextBtn) {
nextBtn.disabled = false;
nextBtn.textContent = nextBtn.dataset.origLabel || 'NEXT';
}
};
const formData = new FormData();
formData.append('action', 'inn_add_booking_to_cart');
formData.append('nonce', innNonce);
formData.append('booking', JSON.stringify(cartData));
fetch(ajaxUrl, { method: 'POST', body: formData, credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
if (!data || !data.success) {
restoreBtn();
const msg = (data && data.data) ? data.data : 'Could not add the booking to the cart.';
innShowToast(msg, 'error');
return;
}
if (data.data.notice) {
innShowToast(data.data.notice, 'warning', 6000);
}
const target = data.data.checkout_url || data.data.cart_url;
if (!target) {
restoreBtn();
innShowToast('Cart was updated but no checkout URL was returned.', 'error');
return;
}
window.location.href = target;
})
.catch(err => {
restoreBtn();
innShowToast('Network error: ' + (err && err.message ? err.message : err), 'error');
});
};
// ==================== INITIALIZATION ====================
// Initialize with tabs disabled (no accommodation selected)
updateTabsState();
updateStayHero();
updateStepRail();
innRenderCalendars();
// Deep-link support: when the dropdown is pre-selected server-side
// from a unit-id query string, fire the change handler so
// availability and occupancy limits load.
var innAccEl = document.getElementById('innAccommodation');
if (innAccEl && innAccEl.value && innAccEl.value !== 'none') {
innAccEl.dispatchEvent(new Event('change', { bubbles: true }));
}
})();
';
html += '' + t.name + '';
html += 'R' + (t.price / 100).toFixed(2) + '';
html += '
';
html += '';
html += '
























