const TIME_FORMAT = "YYYY-MM-DD"; const NUM_FORMATTER = new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD', }); Number.prototype.asMoney = function () { return NUM_FORMATTER.format(this); // return `$${this.toFixed(2)}`; } var SelectedCampsites = []; var StatusRefreshTimestamp = 0, StatusRefreshInterval = 2000, StatusRefreshQueue = null; var InvoiceRefreshTimestamp = 0, InvoiceRefreshInterval = 2000, InvoiceRefreshQueue = null; const select = (n) => document.querySelector(n); const selectAll = (n) => document.querySelectorAll(n); const plural = (s, n) => `${n} ${s}${n == 1 ? '' : 's'}`; // the bane of my existence class Campsite { constructor(cg) { // database fields this.id = cg.id; this.section = cg.section; this.number = cg.number; this.weekday_price = cg.weekday_price; this.weekend_price = cg.weekend_price; this.one_time_fee = cg.one_time_fee; this.max_campers = cg.max_campers; this.special_msg = cg.special_msg; this.location_lat = cg.location_lat; this.location_lng = cg.location_lng; this.enabled = cg.enabled == 1; this.special = cg.special == 1; this.visible = cg.visible == 1; this.has_fire = cg.has_fire == 1; this.has_table = cg.has_table == 1; this.has_camp_spot = cg.has_camp_spot == 1; this.has_tipi = cg.has_tipi == 1; this.has_tent = cg.has_tent == 1; this.has_trailor = cg.has_trailor == 1; this.has_power = cg.has_power == 1; // custom fields this.status = ''; // 'available', 'pending', 'paid', 'canceled'; this.groupSize = 1; this.leafletMarker = cg.leafletMarker || null; } GetButtonElement() { return select(`#${this.section}-${this.number}`); } GetGroupSizeElement() { return select(`#${this.section}-${this.number}-group-size`); } GetLeafletMarker() { return this.leafletMarker._tooltip._container; } OnButtonClicked(event) { // do nothing when the group size is clicked if (event && event.target.id != `${this.section}-${this.number}`) return false; Stoney.GetResponseMessage({}); if (this.enabled == 0 || this.visible == 0) { Stoney.GetResponseMessage({ 'errors': `Campsite ${this.section}-${this.number} is not available.` }); return false; } let button = this.GetButtonElement(); let list = select('#cg-campsite-list'); let container = list.parentElement; list.scrollTo({ top: button.offsetTop - (container.clientHeight / 2), behavior: 'smooth' }); switch (this.status) { case 'pending': { Stoney.GetResponseMessage({ 'errors': `Campsite ${this.section}-${this.number} is being reserved. Try again later.` }); return false; } case 'paid': { Stoney.GetResponseMessage({ 'errors': `Campsite ${this.section}-${this.number} is already reserved.` }); return false; } } if (SelectedCampsites.find(c => c.id == this.id) != null) { // remove campsite from selected list SelectedCampsites = SelectedCampsites.filter(c => c.id != this.id); } else { // add campsite to selected list SelectedCampsites.push(this); if (this.leafletMarker != null) { Stoney.Map.Leaflet.flyTo(this.leafletMarker.getLatLng(), 3, { animate: true, duration: 0.6, }); } } this.UpdateRender(); OnRequestInvoice(); return true; } OnCampsiteMarkerClicked(event) { this.OnButtonClicked(); } OnGroupSizeChanged(event) { let value = parseInt(event.target.value); if (isNaN(value)) return; this.groupSize = value; OnRequestInvoice(); } UpdateRender() { let button = this.GetButtonElement(); let status = document.querySelector(`#${this.section}-${this.number}-status`); button.classList.remove('bg-stone-200', 'bg-blue-500'); status.classList.remove(...'bg-yellow-500 bg-red-500 bg-red-500 bg-green-500'.split(' ')); status.innerHTML = ""; status.classList.add('rounded-full'); if (this.leafletMarker != null) { L.DomUtil.removeClass(this.GetLeafletMarker(), 'disabled'); L.DomUtil.removeClass(this.GetLeafletMarker(), 'active'); } switch (this.status) { case 'pending': { status.classList.add('bg-yellow-500'); break; } case 'paid': { status.classList.add('bg-red-500'); if (this.leafletMarker != null) { L.DomUtil.addClass(this.GetLeafletMarker(), 'disabled'); } break; } default: { if (this.enabled == 0 || this.visible == 0) { status.classList.add('bg-red-500'); if (this.leafletMarker != null) { L.DomUtil.addClass(this.GetLeafletMarker(), 'disabled'); } } else { status.classList.add('bg-green-500'); } break; } } if (SelectedCampsites.find(c => c.id == this.id) != null) { button.classList.add('bg-blue-500'); if (this.leafletMarker != null) { L.DomUtil.addClass(this.GetLeafletMarker(), 'active'); } } else { button.classList.add('bg-stone-200'); } } } function OnReservationClear() { localStorage.removeItem('invoice'); window.location.reload(); } function OnRequestData(path) { select('#reserve-submit').setAttribute('disabled', 'true'); // generate POST data to send via ajax const body = {}; const appendBody = (el, n) => { return body[n] = el ? el.value : ''; }; appendBody(select('#user-name-first'), 'user-name-first'); appendBody(select('#user-name-last'), 'user-name-last'); appendBody(select('#user-email'), 'user-email'); appendBody(select('#user-phone'), 'user-phone'); appendBody(select('#user-age'), 'user-age'); let dateIn = moment(select('#date-in').value); let dateOut = moment(select('#date-out').value); body['dates'] = `${dateIn.format(TIME_FORMAT)} - ${dateOut.format(TIME_FORMAT)}`; body['campsites'] = []; for (let campsite of SelectedCampsites) { let site = `${campsite.section}-${campsite.number}`; body['campsites'].push({ campsite: site, groupSize: campsite.groupSize }); } return fetch(path, { method: 'POST', cache: 'no-cache', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } function OnReservationSubmit() { let button = select('#reserve-submit'); button.setAttribute('disabled', 'true'); OnRequestData('/reserve/checkout') .then(res => res.json()) .then((res) => { button.removeAttribute('disabled'); if (!Stoney.GetResponseMessage(res)) return; console.log(res); delete localStorage.invoice; if (res.redirect) { // administrators will usually be redirected // to the informational page of the reservation window.location = res.redirect; } else if (res.stripe) { // if successful, redirect to the stripe checkout Stoney.Stripe.redirectToCheckout({ sessionId: res.stripe }); } }) .catch((error) => { button.removeAttribute('disabled'); Stoney.GetResponseMessage({ 'errors': error.message }); }); } function OnRequestInvoice() { if (moment().valueOf() - InvoiceRefreshTimestamp < InvoiceRefreshInterval) { if (InvoiceRefreshQueue) clearTimeout(InvoiceRefreshQueue); InvoiceRefreshQueue = setTimeout(() => { OnRequestInvoice(); }, InvoiceRefreshInterval); return; } InvoiceRefreshTimestamp = moment().valueOf(); Stoney.GetResponseMessage({}); // reset to show a processing state RenderInvoice(null); if (SelectedCampsites.length == 0) return; // for future reference debugging, change the // res.json() to res.text() and print the output OnRequestData('/api/cart') .then(res => res.json()) .then((res) => { select('#reserve-submit').removeAttribute('disabled'); if (!Stoney.GetResponseMessage(res)) return; if (!res.reservations) return console.error('No invoice data returned'); localStorage.invoice = JSON.stringify(res); RenderInvoice(res.reservations); }) .catch((error) => { // reset again idk just to make sure i guess, at least we'll know // it's explicitly reset and not just a side effect of the parent function call select('#reserve-submit').removeAttribute('disabled'); console.log(error); Stoney.GetResponseMessage({ 'errors': error.message }); delete localStorage.invoice; RenderInvoice(null); }); } function OnRequestCampsiteStatus() { if (moment().valueOf() - StatusRefreshTimestamp < StatusRefreshInterval) { if (StatusRefreshQueue) clearTimeout(StatusRefreshQueue); StatusRefreshQueue = setTimeout(() => { OnRequestCampsiteStatus(); }, StatusRefreshInterval); return; } StatusRefreshTimestamp = moment().valueOf(); let dateIn = select('#date-in'); let dateOut = select('#date-out'); if (!dateIn || !dateOut) return; // not loaded? dateIn = moment(dateIn.value); dateOut = moment(dateOut.value); let timeFrame = `${dateIn.format(TIME_FORMAT)} - ${dateOut.format(TIME_FORMAT)}`; // check campsite status based on selected reservation dates // any campsites returned in ${data} is meant to be unavailble Stoney.GetCampgroundsStatus(timeFrame, (reservations) => { /* NOTE: there's a possiblity that a reservation is canceled and appears after declaring the campsite as 'pending' or 'paid'. To prevent resetting the status of a campsite that is already reserved, we first reset all statuses to work with a fresh data set. Campsites that are reserved are declared then any cancellations processed after are ignored. */ // reset status for (let campsite_id in Stoney.Campsites) { const campsite = Stoney.Campsites[campsite_id]; campsite.status = ''; } // if a campsite is reserved, set its status to 'pending' or 'paid' for (let campsite of Object.values(Stoney.Campsites)) { let reserved = reservations .filter(r => r.campground_id == `${campsite.section}-${campsite.number}`) .find(r => r.status == 'paid' || r.status == 'pending'); if (reserved) { campsite.status = reserved.status; } campsite.UpdateRender(); } if (ImportLocalStorage) { ImportLocalStorage(); ImportLocalStorage = null; // only run once } }, (error) => { // Stoney.GetResponseMessage({ 'errors': error.message }); // console.error('Error fetching campsite status', error); }); StatusRefreshQueue = setTimeout(() => { OnRequestCampsiteStatus(); }, 8000); } function OnDatesChanged(event) { Stoney.GetResponseMessage({}); /* the event parameter is passed in when the user selects a date this is used to differentiate between user input and programmatic changes */ let isDateInUpdate = event && event.target.id == 'date-in'; let dateIn = select('#date-in'); let dateOut = select('#date-out'); let arrive = moment(dateIn.value); let depart = moment(dateOut.value); let seasonStart = moment(Stoney.SeasonStart); let seasonEnd = moment(Stoney.SeasonEnd); let now = moment(); let earliest = now.isAfter(seasonStart) ? now : seasonStart; arrive.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); depart.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); earliest.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) // set the min date to the earliest date with a 1 day gap select('#date-in').setAttribute('min', earliest.format(TIME_FORMAT)); select('#date-out').setAttribute('min', moment(earliest).add(1, 'day').format(TIME_FORMAT)); // set the max date to the last day of the season with a 1 day gap select('#date-in').setAttribute('max', moment(seasonEnd).subtract(1, 'day').format(TIME_FORMAT)); select('#date-out').setAttribute('max', seasonEnd.format(TIME_FORMAT)); if (!arrive.isValid() || !depart.isValid()) { if (dateIn.value == '' || dateOut.value == '') return; arrive = now.isBefore(seasonStart) ? seasonStart : now; arrive.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); depart = moment(arrive).add(1, 'day'); dateIn.value = arrive.format(TIME_FORMAT); dateOut.value = depart.format(TIME_FORMAT); Stoney.GetResponseMessage({ 'errors': 'The reservation dates have been reset.' }); } if (arrive.isBefore(earliest)) { if (event) { // occurs only when the user selects a date Stoney.GetResponseMessage({ 'errors': `Reservations cannot start before ${earliest.format('MMMM D, YYYY')}.` }); } else { // if arrive is too early, set it to the earliest date arrive = earliest; dateIn.value = earliest.format(TIME_FORMAT); } } else if (arrive.isSame(seasonEnd) || arrive.isAfter(seasonEnd)) { if (event) { Stoney.GetResponseMessage({ 'errors': `Reservations cannot start after ${seasonEnd.format('MMMM D, YYYY')}.` }); // if selected arrive date is invalid, stop here to prevent changing the depart date return false; } else { // if arrive is too late, set it to the last date // and subtract a day to allow for a 1 day reservation arrive = moment(seasonEnd).subtract(1, 'day'); dateIn.value = seasonEnd.format(TIME_FORMAT); } } if (depart.isAfter(seasonEnd)) { if (event) { Stoney.GetResponseMessage({ 'errors': `Reservations cannot end after ${seasonEnd.format('MMMM D, YYYY')}.` }); // if selected depart date is invalid, stop here to prevent changing the arrive date return false; } else { // if depart is too late, set it to the last date depart = seasonEnd; dateOut.value = seasonEnd.format(TIME_FORMAT); } } // when adjusting programmatically or via user input when input is the date-in field // adjust depart date to the next day if necessary if ((!event || isDateInUpdate) && (arrive.isSame(depart) || arrive.isAfter(depart))) { depart = moment(arrive).add(1, 'day'); dateOut.value = depart.format(TIME_FORMAT); } // when adjusting depart date via user input when input is the date-out field // adjust arrive date to the previous day if necessary if (event && !isDateInUpdate && (depart.isSame(arrive) || depart.isBefore(arrive))) { arrive = moment(depart).subtract(1, 'day'); dateIn.value = arrive.format(TIME_FORMAT); } if (arrive.isSame(depart)) { Stoney.GetResponseMessage({ 'errors': `Reservations cannot be made for less than 1 day.` }); return false; } OnRequestCampsiteStatus(); OnRequestInvoice(); return true; } function RenderInvoice(data) { try { const summary = document.querySelector('#summary'); summary.innerHTML = ""; let depart = moment(select('#date-out').value); let arrive = moment(select('#date-in').value); let nights = depart.diff(arrive, 'days'); if (nights > 0 && SelectedCampsites.length > 0) { summary.innerHTML = `You are booking ${SelectedCampsites.length} campsites for ${plural('night', nights)} from ${arrive.format('MMMM Do')} to ${depart.format('MMMM Do')}.`; } } catch (e) { // ignore who cares } const invoice = select('#invoice'); invoice.innerHTML = ""; if (!(data instanceof Array) // there's a small timing where the AJAX request is sent but the user has de-selected a campsite // causing an invoice to be displayed when no campsite is selected || SelectedCampsites.length == 0) return; const createRow = (name, value) => { let container = document.createElement('div'); container.classList.add( 'flex', 'justify-between', 'space-x-4', 'px-4', 'py-2', 'rounded', 'hover:bg-neutral-100', 'dark:hover:bg-neutral-600' ); // left side of the row let text = document.createElement('p'); text.classList.add('w-full'); text.innerHTML = name; // right side of the row let total = document.createElement('td'); total.classList.add('text-right'); total.innerHTML = value; container.append(text, total) invoice.append(container); return container; }; let subtotal = 0; for (let i = 0; i < data.length; i++) { let reservation = data[i]; if (reservation.special == 1) { let price = parseFloat(reservation.weekday_price); price *= parseInt(reservation.weekdays); createRow(plural('Weekend', reservation.weekends), price.asMoney()); price = parseFloat(reservation.weekend_price); price *= parseInt(reservation.weekends); createRow(plural('Weekday', reservation.weekdays), price.asMoney()); } else { let price = parseFloat(reservation.weekday_price); price *= parseInt(reservation.nights); createRow(plural('Night', reservation.nights) + ` (${reservation.camping_type ? 'Large' : 'Regular'}) - ${reservation.campground_id}`, price.asMoney()); } if (reservation.camping_type > 0) createRow(`Large Group Fee1 - ${reservation.campground_id}`, parseFloat(reservation.one_time_fee).asMoney()); subtotal += reservation.cost; } createRow('Subtotal', parseFloat(subtotal).asMoney()); createRow('GST (5%)', parseFloat(subtotal * 0.05).asMoney()); let bottomRow = createRow('Total', (subtotal * 1.05).asMoney()); bottomRow.classList.add(...'font-bold border-t-2 dark:border-white/20 text-end'.split(' ')); } Stoney.OnCampgroundLoaded = function (container, cg) { const campsite = new Campsite(cg); if (campsite.visible == 0) return; // map legend icons from "What do the icons mean?" section // to properties for the campsite object let displayIcons = []; let campsiteTraits = { 'ct-icon-fire': campsite.has_fire, 'ct-icon-table': campsite.has_table, // 'ct-icon-tent': campsite.has_camp_spot, 'ct-icon-itent': campsite.has_tent, 'ct-icon-tipi': campsite.has_tipi, 'ct-icon-trailor': campsite.has_trailor, 'ct-icon-power': campsite.has_power, }; // show the icons (without the text) that are enabled for this campsite for (let key in campsiteTraits) { if (campsiteTraits[key] == 1) { let el = select(`.${key}`).cloneNode(true); el.classList.remove('hidden'); // remove all p tags, leaving just icons let p = el.querySelectorAll('p'); for (let i = 0; i < p.length; i++) p[i].remove(); displayIcons.push(el.outerHTML); } } // create an interactive campsite element let button = document.createElement('div'); button.id = `${campsite.section}-${campsite.number}`; button.classList.add( ...'button px-3 py-2 flex justify-between items-center gap-2 rounded border border-black/25'.split(' ')); button.setAttribute('role', 'button'); // add pointer cursor button.onclick = (event) => campsite.OnButtonClicked(event); button.innerHTML = ` ${campsite.section}-${campsite.number}
${displayIcons.join('')}
`; let groupSize = document.createElement("select"); groupSize.id = `${campsite.section}-${campsite.number}-group-size`; groupSize.classList.add('text-sm'); for (let i = 0; i < campsite.max_campers * 2; i++) { let el = document.createElement('option'); el.value = i + 1; el.innerHTML = plural('Camper', i + 1); if (i >= campsite.max_campers) { el.innerHTML += ` | (Large Group +${parseFloat(campsite.one_time_fee).asMoney()})`; } else { el.innerHTML += ` | (Regular)` } groupSize.append(el); } groupSize.onchange = (event) => campsite.OnGroupSizeChanged(event); button.append(groupSize); container.append(button); Stoney.Campsites[`${campsite.section}-${campsite.number}`] = campsite; campsite.UpdateRender(); } Stoney.OnCampgroundsLoaded = function (container) { select('#reserve-clear').onclick = OnReservationClear; select('#reserve-submit').onclick = OnReservationSubmit; Stoney.Map.OnInitializeMap(); for (let campsite of Object.values(Stoney.Campsites)) { if (campsite.leafletMarker == null) continue; // add click event to marker campsite.leafletMarker.on('click', (event) => campsite.OnCampsiteMarkerClicked(event)); } select('#date-in').onchange = OnDatesChanged; select('#date-out').onchange = OnDatesChanged; OnDatesChanged(); } /** * Imports the reservation data from localStorage if it exists. * This function is called once when campsites are populated and their status is set. * Once loaded the function is set to null to prevent it from running again. */ var ImportLocalStorage = function () { try { const invoiceString = localStorage.getItem('invoice'); if (!invoiceString) return; // no invoice data to import const invoice = JSON.parse(invoiceString); let userInfoProcessed = false; let reservations = invoice.reservations || []; for (let i = reservations.length - 1; i >= 0; i--) { let reservation = reservations[i]; if (!userInfoProcessed) { select('#user-name-first').value = reservation.first_name || ''; select('#user-name-last').value = reservation.last_name || ''; select('#user-email').value = reservation.email || ''; select('#user-phone').value = reservation.phone || ''; select('#user-age').value = reservation.age || ''; select('#date-in').value = moment(reservation.date_in).format('YYYY-MM-DD'); select('#date-out').value = moment(reservation.date_out).format('YYYY-MM-DD'); userInfoProcessed = true; } let campsite = Stoney.Campsites[`${reservation.campground_id}`]; if (!campsite) continue; if (campsite.status == 'paid' || campsite.status == 'pending') { console.warn(`[Import] Campsite ${campsite.section}-${campsite.number} is already reserved.`); Stoney.GetResponseMessage({ 'errors': `Campsite ${campsite.section}-${campsite.number} is already reserved and has been removed.` }); // if the campsite is already reserved, skip it reservations.splice(i, 1); continue; } campsite.GetButtonElement().click(); campsite.groupSize = reservation.camping_type == 0 ? 1 : campsite.max_campers + 1; campsite.GetGroupSizeElement().value = campsite.groupSize; } // save the updated invoice back to localStorage invoice.reservations = reservations; localStorage.invoice = JSON.stringify(invoice); } catch (e) { console.error('Error parsing cached data from localStorage', e); delete localStorage.invoice; } }