| Server IP : 173.209.174.21 / Your IP : 216.73.216.89 Web Server : Apache/2.4.58 (Ubuntu) System : Linux wcfs-server 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 User : nodor ( 1000) PHP Version : 8.3.6 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/grangestation/ |
Upload File : |
const photos = [
{ src: "assets/building-wide.jpg", caption: "Original Grange Supply Co exterior and porch." },
{ src: "assets/interior1.jpg", caption: "Restored interior with open rafters, string lighting, and hardwood floors." },
{ src: "assets/tree-wagon-wheel.jpg", caption: "Shaded tree area with rustic wagon wheel detail." },
{ src: "assets/railroad-florals.jpg", caption: "Outdoor details near the railroad backdrop." },
{ src: "assets/dessert-wall.jpg", caption: "Warm wood wall and dessert table setup." },
{ src: "assets/bar-detail.jpg", caption: "Vintage serving details for receptions and private events." }
];
let siteConfig = getSiteConfig();
let galleryPhotos = activeGalleryPhotos();
const blockedDates = new Set(["2026-06-20", "2026-07-04", "2026-08-15"]);
const storageKey = "grangeStationRequests";
const confirmedStorageKey = "grangeStationConfirmedBookings";
const rateStorageKey = "grangeStationSubmissionLog";
const cropStorageKey = "grangeStationGalleryCrops";
const minimumSubmitSeconds = 8;
let currentPhoto = 0;
let selectedCropPhoto = 0;
const defaultCrops = [
{ x: 28, y: 50, zoom: 1, cols: 1, rows: 2, fit: "cover" },
{ x: 50, y: 60, zoom: 1, cols: 1, rows: 1, fit: "cover" },
{ x: 58, y: 52, zoom: 1, cols: 1, rows: 1, fit: "cover" },
{ x: 50, y: 62, zoom: 1, cols: 2, rows: 1, fit: "cover" },
{ x: 50, y: 64, zoom: 1, cols: 1, rows: 1, fit: "cover" },
{ x: 62, y: 58, zoom: 1, cols: 1, rows: 1, fit: "cover" }
];
const header = document.querySelector("[data-header]");
const menuToggle = document.querySelector("[data-menu-toggle]");
const nav = document.querySelector("[data-nav]");
const form = document.querySelector("[data-booking-form]");
const contactForm = document.querySelector("[data-contact-form]");
const estimate = document.querySelector("[data-estimate]");
const estimateNote = document.querySelector("[data-estimate-note]");
const estimateBreakdown = document.querySelector("[data-estimate-breakdown]");
const availability = document.querySelector("[data-availability]");
const confirmedSlots = document.querySelector("[data-confirmed-slots]");
const dateInput = document.querySelector("[data-date-input]");
const bookingCalendar = document.querySelector("[data-booking-calendar]");
const calendarMonthLabel = document.querySelector("[data-calendar-month]");
const calendarGrid = document.querySelector("[data-calendar-grid]");
const calendarDetails = document.querySelector("[data-calendar-details]");
const requestList = document.querySelector("[data-request-list]");
const toast = document.querySelector("[data-toast]");
const mailto = document.querySelector("[data-mailto]");
const lightbox = document.querySelector("[data-lightbox]");
const lightboxImage = document.querySelector("[data-lightbox-image]");
const lightboxCaption = document.querySelector("[data-lightbox-caption]");
const galleryThumbs = document.querySelector("[data-gallery-thumbs]");
const formStartedAt = document.querySelector("[data-form-started]");
const contactStartedAt = document.querySelector("[data-contact-started]");
const fullDayInput = document.querySelector("[data-full-day]");
const cropEditor = document.querySelector("[data-crop-editor]");
const cropPhotoSelect = document.querySelector("[data-crop-photo]");
const cropPreview = document.querySelector("[data-crop-preview]");
const cropX = document.querySelector("[data-crop-x]");
const cropY = document.querySelector("[data-crop-y]");
const cropZoom = document.querySelector("[data-crop-zoom]");
const cropCols = document.querySelector("[data-crop-cols]");
const cropRows = document.querySelector("[data-crop-rows]");
const cropFit = document.querySelector("[data-crop-fit]");
let calendarMonth = new Date();
calendarMonth.setDate(1);
let liveConfirmedBookings = null;
let liveAvailabilityBlocks = null;
let livePendingRequests = null;
let apiAvailable = false;
async function apiRequest(action, payload) {
const options = payload
? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }
: {};
const response = await fetch(`api/index.php?action=${encodeURIComponent(action)}`, options);
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.detail || data.error || "Request failed.");
return data;
}
async function loadLiveData() {
try {
const data = await apiRequest("bootstrap");
apiAvailable = true;
siteConfig = mergeConfig(data.settings || {});
galleryPhotos = (siteConfig.gallery || []).filter((photo) => photo.enabled !== false);
liveConfirmedBookings = data.confirmedBookings || [];
liveAvailabilityBlocks = data.availabilityBlocks || [];
livePendingRequests = data.pendingRequests || [];
} catch (error) {
apiAvailable = false;
}
}
function showToast(message) {
toast.textContent = message;
toast.classList.add("visible");
window.setTimeout(() => toast.classList.remove("visible"), 2800);
}
function getRequests() {
return JSON.parse(localStorage.getItem(storageKey) || "[]");
}
function saveRequests(requests) {
localStorage.setItem(storageKey, JSON.stringify(requests));
}
function defaultConfirmedBookings() {
return [
{ id: "confirmed-2026-06-20", date: "2026-06-20", start: "14:00", end: "22:00", title: "Confirmed private event" },
{ id: "confirmed-2026-07-04", date: "2026-07-04", start: "10:00", end: "18:00", title: "Confirmed holiday booking" },
{ id: "confirmed-2026-08-15", date: "2026-08-15", start: "15:00", end: "23:00", title: "Confirmed wedding" }
];
}
function getConfirmedBookings() {
if (Array.isArray(liveConfirmedBookings)) return liveConfirmedBookings;
const saved = JSON.parse(localStorage.getItem(confirmedStorageKey) || "[]");
return [...defaultConfirmedBookings(), ...saved].sort((a, b) => `${a.date} ${a.start}`.localeCompare(`${b.date} ${b.start}`));
}
function confirmedForDate(date) {
return getConfirmedBookings().filter((booking) => booking.date === date);
}
function blocksForDate(date) {
if (Array.isArray(liveAvailabilityBlocks)) return liveAvailabilityBlocks.filter((block) => block.date === date);
return getAvailabilityBlocks().filter((block) => block.date === date);
}
function unavailableForDate(date) {
return [
...confirmedForDate(date).map((booking) => ({ ...booking, label: booking.title || "Confirmed booking", kind: "confirmed" })),
...blocksForDate(date).map((block) => ({ ...block, label: block.title || "Unavailable", kind: "blocked" }))
];
}
function pendingForDate(date) {
const source = Array.isArray(livePendingRequests) ? livePendingRequests : getRequests();
return source.filter((request) => request.date === date);
}
function timeToMinutes(time) {
const [hours, minutes] = time.split(":").map(Number);
return hours * 60 + minutes;
}
function isFullDayItem(item) {
if (!item?.start || !item?.end) return false;
return timeToMinutes(item.start) <= 8 * 60 && timeToMinutes(item.end) >= 23 * 60;
}
function isFullDayUnavailable(items) {
return items.some((item) => isFullDayItem(item));
}
function formatTime(time) {
if (!time) return "time TBD";
const [rawHours, rawMinutes = "00"] = String(time).split(":");
const hours = Number(rawHours);
if (Number.isNaN(hours)) return time;
const suffix = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${String(rawMinutes).padStart(2, "0")} ${suffix}`;
}
function formatTimeRange(start, end) {
return `${formatTime(start)}-${formatTime(end)}`;
}
function formatPendingTime(item) {
const start = item.start || item.time;
const end = item.end || item.endTime;
return end ? formatTimeRange(start, end) : formatTime(start);
}
function overlapsConfirmedBooking(date, startTime) {
if (!date || !startTime) return false;
const endTime = form?.elements?.endTime?.value || "";
const requestedStart = timeToMinutes(startTime);
const requestedEnd = endTime ? timeToMinutes(endTime) : requestedStart + 180;
return unavailableForDate(date).some((item) => {
const unavailableStart = timeToMinutes(item.start);
const unavailableEnd = timeToMinutes(item.end);
return requestedStart < unavailableEnd && requestedEnd > unavailableStart;
});
}
function renderConfirmedSlots(date) {
const unavailableItems = unavailableForDate(date);
if (!date || !unavailableItems.length) {
confirmedSlots.hidden = true;
confirmedSlots.innerHTML = "";
return;
}
confirmedSlots.hidden = false;
confirmedSlots.innerHTML = `
<strong>Unavailable times on ${escapeHtml(formatDate(date))}</strong>
${unavailableItems.map((item) => `<span>${escapeHtml(formatTimeRange(item.start, item.end))} ${escapeHtml(item.label)}</span>`).join("")}
`;
}
function getSubmissionLog() {
return JSON.parse(localStorage.getItem(rateStorageKey) || "[]");
}
function saveSubmissionLog(log) {
localStorage.setItem(rateStorageKey, JSON.stringify(log));
}
function getCropSettings() {
return JSON.parse(localStorage.getItem(cropStorageKey) || "{}");
}
function saveCropSettings(settings) {
localStorage.setItem(cropStorageKey, JSON.stringify(settings));
}
function cropFor(index) {
return { ...defaultCrops[index], ...(getCropSettings()[index] || {}) };
}
function applyCropToImage(index, crop) {
const tile = document.querySelector(`[data-photo="${index}"]`);
const image = document.querySelector(`[data-photo="${index}"] img`);
if (!tile || !image) return;
tile.style.gridColumn = `span ${crop.cols || 1}`;
tile.style.gridRow = `span ${crop.rows || 1}`;
image.style.setProperty("--crop-x", `${crop.x}%`);
image.style.setProperty("--crop-y", `${crop.y}%`);
image.style.setProperty("--crop-zoom", crop.zoom);
image.style.setProperty("--crop-fit", crop.fit || "cover");
}
function applySavedCrops() {
photos.forEach((_, index) => applyCropToImage(index, cropFor(index)));
}
function updateCropPreview(crop) {
cropPreview.src = photos[selectedCropPhoto].src;
cropPreview.alt = photos[selectedCropPhoto].caption;
cropPreview.parentElement.style.setProperty("--preview-cols", crop.cols || 1);
cropPreview.parentElement.style.setProperty("--preview-rows", crop.rows || 1);
cropPreview.style.setProperty("--preview-x", `${crop.x}%`);
cropPreview.style.setProperty("--preview-y", `${crop.y}%`);
cropPreview.style.setProperty("--preview-zoom", crop.zoom);
cropPreview.style.setProperty("--preview-fit", crop.fit || "cover");
}
function loadCropEditor(index) {
selectedCropPhoto = Number(index);
const crop = cropFor(selectedCropPhoto);
cropPhotoSelect.value = String(selectedCropPhoto);
cropX.value = crop.x;
cropY.value = crop.y;
cropZoom.value = crop.zoom;
cropCols.value = crop.cols || 1;
cropRows.value = crop.rows || 1;
cropFit.value = crop.fit || "cover";
updateCropPreview(crop);
}
function currentEditorCrop() {
return {
x: Number(cropX.value),
y: Number(cropY.value),
zoom: Number(cropZoom.value),
cols: Number(cropCols.value),
rows: Number(cropRows.value),
fit: cropFit.value
};
}
function previewEditorCrop() {
const crop = currentEditorCrop();
if (crop.fit === "cover" && crop.zoom < 1) {
crop.zoom = 1;
cropZoom.value = 1;
}
applyCropToImage(selectedCropPhoto, crop);
updateCropPreview(crop);
}
function setupCropEditorMode() {
const params = new URLSearchParams(window.location.search);
const enabled = params.get("edit") === "gallery";
cropEditor.hidden = !enabled;
}
function createId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return `request-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function formatDate(dateString) {
if (!dateString) return "Date not selected";
const date = new Date(`${dateString}T12:00:00`);
return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
}
function monthLabel(date) {
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
function toDateValue(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 selectedDateValue() {
return form.elements.date.value;
}
function calendarDetailsHtml(dateValue) {
const unavailableItems = unavailableForDate(dateValue);
const pendingItems = pendingForDate(dateValue);
if (!dateValue) return "Select a date to see confirmed bookings.";
if (!unavailableItems.length && !pendingItems.length) {
return `<strong>${escapeHtml(formatDate(dateValue))}</strong><span>No confirmed bookings yet. This date can be requested.</span>`;
}
if (!unavailableItems.length) {
return `
<strong>${escapeHtml(formatDate(dateValue))}</strong>
${pendingItems.map((item) => `<span class="pending-note">${escapeHtml(formatPendingTime(item))} pending request</span>`).join("")}
`;
}
return `
<strong>${escapeHtml(formatDate(dateValue))}</strong>
${unavailableItems.map((item) => `<span>${escapeHtml(formatTimeRange(item.start, item.end))} ${escapeHtml(item.label)}</span>`).join("")}
`;
}
function renderBookingCalendar() {
const selected = selectedDateValue();
const todayValue = toDateValue(new Date());
const year = calendarMonth.getFullYear();
const month = calendarMonth.getMonth();
const firstDay = new Date(year, month, 1);
const totalDays = new Date(year, month + 1, 0).getDate();
const cells = [];
calendarMonthLabel.textContent = monthLabel(calendarMonth);
for (let i = 0; i < firstDay.getDay(); i++) {
cells.push('<button class="calendar-day blank" type="button" tabindex="-1"></button>');
}
for (let day = 1; day <= totalDays; day++) {
const date = new Date(year, month, day);
const value = toDateValue(date);
const unavailableItems = unavailableForDate(value);
const pendingItems = pendingForDate(value);
const classes = ["calendar-day"];
if (value < todayValue) classes.push("past");
if (unavailableItems.length) classes.push(isFullDayUnavailable(unavailableItems) ? "has-confirmed" : "has-partial");
if (pendingItems.length && !unavailableItems.length) classes.push("pending");
if (value === selected) classes.push("selected");
cells.push(`
<button class="${classes.join(" ")}" type="button" data-calendar-date="${value}">
<strong>${day}</strong>
${pendingItems.length && !unavailableItems.length ? "<small>Pending</small>" : ""}
</button>
`);
}
calendarGrid.innerHTML = cells.join("");
calendarDetails.innerHTML = calendarDetailsHtml(selected);
}
function setCalendarMonthFromDate(dateValue) {
const base = dateValue ? new Date(`${dateValue}T12:00:00`) : new Date();
calendarMonth = new Date(base.getFullYear(), base.getMonth(), 1);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function formatMoney(value) {
return `$${Number(value || 0).toLocaleString()}`;
}
function formatPriceRange(low, high) {
const lowValue = Number(low || 0);
const highValue = Number(high || 0);
if (!lowValue && !highValue) return "Quote";
if (lowValue === highValue) return formatMoney(lowValue);
return `${formatMoney(lowValue)} - ${formatMoney(highValue)}`;
}
function addonPriceRange(addon) {
const low = Number(addon.priceLow ?? 0);
const high = Number(addon.priceHigh ?? 0);
const price = low || high ? formatPriceRange(low, high) : "Free";
return addon.priceUnit && price !== "Free" ? `${price} ${addon.priceUnit}` : price;
}
function packageIncludedItems(pkg) {
if (Array.isArray(pkg.included) && pkg.included.length) return pkg.included;
return String(pkg.includes || "")
.split(/\n|;/)
.map((item) => item.trim())
.filter(Boolean);
}
function activePackages() {
const configured = (siteConfig.packages || []).filter((pkg) => pkg.enabled !== false);
if (configured.length) return configured;
return (defaultSiteConfig.packages || []).filter((pkg) => pkg.enabled !== false);
}
function activeAddons() {
const defaults = defaultSiteConfig.addons || [];
const configured = (siteConfig.addons || []).filter((addon) => addon.enabled !== false);
if (!configured.length) return defaults.filter((addon) => addon.enabled !== false);
return configured.map((addon) => {
const fallback = defaults.find((item) => (item.value || item.label) === (addon.value || addon.label)) || {};
return { ...fallback, ...addon };
});
}
function packageEstimateRange(pkg, dateValue) {
if (!pkg) return { low: 0, high: 0 };
const low = Number(pkg.priceLow || 0);
const high = Number(pkg.priceHigh || 0);
if (!/simple/i.test(pkg.name || "")) return { low, high };
if (!dateValue) return { low, high };
const date = new Date(`${dateValue}T12:00:00`);
const amount = date.getDay() === 6 ? high : low;
return { low: amount, high: amount };
}
function packageDisplayPrice(pkg) {
if (/simple/i.test(pkg.name || "")) return "$1,500 Fri/Sun, $1,800 Sat";
return formatPriceRange(pkg.priceLow, pkg.priceHigh);
}
function applyThemeSettings() {
const colors = siteConfig.colors || {};
Object.entries({
ink: "--ink",
paper: "--paper",
cream: "--cream",
barn: "--barn",
sage: "--sage",
gold: "--gold"
}).forEach(([key, property]) => {
if (colors[key]) document.documentElement.style.setProperty(property, colors[key]);
});
}
function applyBrandingSettings() {
const branding = siteConfig.branding || {};
const venueName = branding.venueName || "Grange Station";
const tagline = branding.tagline || "Wilson Creek, WA";
const logoImage = branding.logoImage || "";
const heroImage = branding.heroImage || "assets/grange-station.png";
const socialImage = branding.socialImage || heroImage;
document.querySelectorAll(".brand-mark").forEach((mark) => {
mark.innerHTML = logoImage ? `<img src="${escapeHtml(logoImage)}" alt="">` : "GS";
mark.classList.toggle("has-logo", Boolean(logoImage));
});
document.querySelectorAll(".site-header, .gallery-header").forEach((item) => {
item.classList.toggle("has-logo", Boolean(logoImage));
});
document.querySelectorAll(".brand strong").forEach((item) => item.textContent = venueName);
document.querySelectorAll(".brand small").forEach((item) => item.textContent = tagline);
const hero = document.querySelector(".hero > img");
if (hero) {
hero.src = heroImage;
hero.alt = `${venueName} venue exterior.`;
}
document.querySelectorAll("meta[property='og:image'], meta[name='twitter:image']").forEach((meta) => {
meta.setAttribute("content", socialImage.startsWith("http") ? socialImage : `https://www.grangestation.com/${socialImage}`);
});
}
function renderManagedContent() {
applyThemeSettings();
applyBrandingSettings();
const activePackageList = activePackages();
const packageGrid = document.querySelector("[data-package-grid]");
if (packageGrid) {
packageGrid.innerHTML = activePackageList.map((pkg) => {
const includedItems = packageIncludedItems(pkg);
const investment = `${formatPriceRange(pkg.priceLow, pkg.priceHigh)}${pkg.investmentNote ? ` <small>(${escapeHtml(pkg.investmentNote)})</small>` : ""}`;
return `
<article>
<span>${escapeHtml(pkg.subtitle || "Package")}</span>
<h3>${escapeHtml(pkg.name)}</h3>
<p>${escapeHtml(pkg.description || "")}</p>
<dl>
<div><dt>Investment</dt><dd><strong>${investment}</strong></dd></div>
<div><dt>Time Block</dt><dd>${escapeHtml(pkg.timeBlock || "")}</dd></div>
<div>
<dt>What's Included</dt>
<dd>${includedItems.length ? `<ul>${includedItems.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "Details coming soon."}</dd>
</div>
${pkg.blueprint ? `<div><dt>The Blueprint</dt><dd>${escapeHtml(pkg.blueprint)}</dd></div>` : ""}
</dl>
</article>
`;
}).join("");
}
const packageSelect = document.querySelector("[data-package-select]");
if (packageSelect) {
const selected = packageSelect.value;
packageSelect.innerHTML = `<option value="">Not sure yet</option>${activePackageList.map((pkg) => `<option value="${escapeHtml(pkg.name)}">${escapeHtml(pkg.name)} - ${escapeHtml(packageDisplayPrice(pkg))}</option>`).join("")}`;
packageSelect.value = selected;
}
const addonFieldset = document.querySelector("fieldset");
if (addonFieldset) {
addonFieldset.innerHTML = `
<legend>Optional add-ons</legend>
${activeAddons().map((addon) => `
<label><input type="checkbox" name="addons" value="${escapeHtml(addon.value)}"> ${escapeHtml(addon.label)} <small>${escapeHtml(addonPriceRange(addon))}</small></label>
`).join("")}
`;
}
const vendorGrid = document.querySelector(".vendor-grid");
if (vendorGrid) {
vendorGrid.innerHTML = (siteConfig.vendors || []).filter((vendor) => vendor.enabled !== false).map((vendor) => `
<article>
<span>${escapeHtml(vendor.category)}</span>
<h3>${escapeHtml(vendor.title)}</h3>
<p>${escapeHtml(vendor.description)}</p>
</article>
`).join("");
}
const faqGrid = document.querySelector(".faq-grid");
if (faqGrid) {
faqGrid.innerHTML = (siteConfig.faqs || []).filter((faq) => faq.enabled !== false).map((faq) => `
<details>
<summary>${escapeHtml(faq.question)}</summary>
<p>${escapeHtml(faq.answer)}</p>
</details>
`).join("");
}
}
function publicRequestData(request) {
const { confirmEmail, ...publicRequest } = request;
return publicRequest;
}
function collectFormData() {
const data = new FormData(form);
return {
id: createId(),
createdAt: new Date().toISOString(),
requestType: data.get("requestType"),
name: data.get("name").trim(),
email: data.get("email").trim(),
phone: data.get("phone").trim(),
confirmEmail: data.get("confirmEmail").trim(),
date: data.get("date"),
time: data.get("time"),
endTime: data.get("endTime"),
fullDay: data.get("fullDay") === "1",
guests: Number(data.get("guests") || 0),
package: data.get("package"),
eventType: data.get("eventType"),
space: data.get("space"),
vendorHelp: data.get("vendorHelp"),
addons: data.getAll("addons"),
message: data.get("message").trim(),
humanCheck: data.get("humanCheck").trim(),
website: data.get("website").trim(),
formStartedAt: Number(data.get("formStartedAt") || 0)
};
}
function collectContactData() {
const data = new FormData(contactForm);
return {
name: data.get("name").trim(),
email: data.get("email").trim(),
phone: data.get("phone").trim(),
reason: data.get("reason"),
message: data.get("message").trim(),
humanCheck: data.get("humanCheck").trim(),
website: data.get("website").trim(),
contactStartedAt: Number(data.get("contactStartedAt") || 0)
};
}
function validateRequest(request) {
const now = Date.now();
const secondsToSubmit = (now - request.formStartedAt) / 1000;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneDigits = request.phone.replace(/\D/g, "");
const messageHasLinks = /https?:\/\/|www\.|\.ru\b|\.cn\b/i.test(request.message);
const recentLog = getSubmissionLog().filter((time) => now - time < 60 * 60 * 1000);
const duplicate = getRequests().some((item) =>
item.email.toLowerCase() === request.email.toLowerCase() &&
item.date === request.date &&
item.requestType === request.requestType
);
if (request.website) return "Your request could not be accepted.";
if (secondsToSubmit < minimumSubmitSeconds) return "Please take a moment to review the form before submitting.";
if (!request.name || !request.email || !request.phone || !request.date || !request.time || !request.endTime) return "Please complete the required contact and event fields.";
if (!emailPattern.test(request.email)) return "Please enter a valid email address.";
if (request.email.toLowerCase() !== request.confirmEmail.toLowerCase()) return "The email confirmation does not match.";
if (phoneDigits.length < 10) return "Please enter a phone number with at least 10 digits.";
if (!request.fullDay && timeToMinutes(request.endTime) <= timeToMinutes(request.time)) return "Please choose an end time after the start time.";
if (Number(request.humanCheck) !== 7) return "Please answer the human check before submitting.";
if (messageHasLinks) return "Please remove links from the message field and submit again.";
if (duplicate) return "A matching request is already saved for that date and email.";
if (recentLog.length >= 3) return "Too many requests were submitted from this browser recently. Please try again later.";
saveSubmissionLog([...recentLog, now]);
return "";
}
function validateContactMessage(message) {
const now = Date.now();
const secondsToSubmit = (now - message.contactStartedAt) / 1000;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const messageHasLinks = /https?:\/\/|www\.|\.ru\b|\.cn\b/i.test(message.message);
if (message.website) return "Your message could not be accepted.";
if (secondsToSubmit < minimumSubmitSeconds) return "Please take a moment to review the message before submitting.";
if (!message.name || !message.email || !message.message) return "Please complete your name, email, and message.";
if (!emailPattern.test(message.email)) return "Please enter a valid email address.";
if (Number(message.humanCheck) !== 9) return "Please answer the human check before submitting.";
if (messageHasLinks) return "Please remove links from the message field and submit again.";
return "";
}
function resetAntiBotFields() {
formStartedAt.value = String(Date.now());
form.elements.humanCheck.value = "";
form.elements.website.value = "";
form.elements.confirmEmail.value = "";
if (contactForm && contactStartedAt) {
contactStartedAt.value = String(Date.now());
contactForm.elements.humanCheck.value = "";
contactForm.elements.website.value = "";
}
}
function estimateRange() {
const fields = form.elements;
const requestType = fields.requestType.value;
const guests = Number(fields.guests.value || 0);
const date = fields.date.value ? new Date(`${fields.date.value}T12:00:00`) : null;
const isWeekend = date ? [5, 6].includes(date.getDay()) : true;
const selectedAddons = [...form.querySelectorAll("input[name='addons']:checked")].map((input) => input.value);
const addonOptions = activeAddons();
if (requestType === "Private Viewing") {
estimate.textContent = "Complimentary";
estimateNote.textContent = "Private viewings can be scheduled before selecting an event date.";
if (estimateBreakdown) estimateBreakdown.textContent = "";
return;
}
const pricing = siteConfig.pricing;
const selectedPackage = activePackages().find((pkg) => pkg.name === fields.package?.value);
let low = 0;
let high = 0;
const breakdown = [];
if (selectedPackage) {
const packageRange = packageEstimateRange(selectedPackage, fields.date.value);
low = packageRange.low;
high = packageRange.high;
breakdown.push(`${selectedPackage.name}: ${formatPriceRange(low, high)}`);
} else {
low = isWeekend ? Number(pricing.weekendLow) : Number(pricing.weekdayLow);
high = isWeekend ? Number(pricing.weekendHigh) : Number(pricing.weekdayHigh);
breakdown.push(`${isWeekend ? "Weekend" : "Weekday"} base: ${formatPriceRange(low, high)}`);
}
if (!selectedPackage && guests > 100) {
low += Number(pricing.over100Low);
high += Number(pricing.over100High);
breakdown.push(`Over 100 guests: ${formatPriceRange(pricing.over100Low, pricing.over100High)}`);
}
selectedAddons.forEach((addonValue) => {
const option = addonOptions.find((addon) => (addon.value || addon.label) === addonValue);
if (!option) return;
const addonLow = Number(option.priceLow ?? 0);
const addonHigh = Number(option.priceHigh ?? 0);
low += addonLow;
high += addonHigh;
breakdown.push(`${option.label || addonValue}: ${addonPriceRange(option)}`);
});
estimate.textContent = formatPriceRange(low, high);
estimateNote.textContent = selectedPackage
? "Package estimate with selected add-ons. Final rental/vendor details are confirmed by quote."
: `${isWeekend ? "Weekend" : "Weekday"} estimate with selected add-ons. Final rental/vendor details are confirmed by quote.`;
if (estimateBreakdown) estimateBreakdown.textContent = breakdown.join(" + ");
}
function updateAvailability() {
const date = form.elements.date.value;
const time = form.elements.time.value;
renderConfirmedSlots(date);
renderBookingCalendar();
availability.className = "availability";
if (!date) {
availability.textContent = "Choose a date to check availability.";
return;
}
if (overlapsConfirmedBooking(date, time)) {
availability.textContent = `${formatDate(date)} has a confirmed booking at that time. Please choose another time or ask about waitlist options.`;
availability.classList.add("bad");
return;
}
const confirmedCount = confirmedForDate(date).length;
availability.textContent = confirmedCount
? `${formatDate(date)} has unavailable times listed below, but other times may be requested.`
: `${formatDate(date)} can be requested. Final confirmation happens after venue review.`;
availability.classList.add("good");
}
function updateMailto() {
if (!mailto) return;
const request = collectFormData();
const subject = encodeURIComponent(`${request.requestType}: ${request.date || "Date TBD"} at Grange Station`);
const body = encodeURIComponent([
`Request type: ${request.requestType}`,
`Name: ${request.name}`,
`Email: ${request.email}`,
`Phone: ${request.phone}`,
`Date: ${formatDate(request.date)}`,
`Time: ${request.fullDay ? "Full day" : `${formatTime(request.time)}-${formatTime(request.endTime)}`}`,
`Guest count: ${request.guests}`,
`Package: ${request.package || "Not sure yet"}`,
`Event type: ${request.eventType}`,
`Space: ${request.space}`,
`Preferred vendor help: ${request.vendorHelp}`,
`Additions: ${request.addons.join(", ") || "None"}`,
"",
request.message || "No additional notes yet."
].join("\n"));
mailto.href = `mailto:events@grangestation.com?subject=${subject}&body=${body}`;
}
function renderRequests() {
if (!requestList) return;
const requests = getRequests();
if (!requests.length) {
requestList.innerHTML = '<div class="empty">No saved requests yet. New reservation and viewing requests will appear here.</div>';
return;
}
requestList.innerHTML = requests.map((request) => `
<article class="request-card">
<div>
<strong>${escapeHtml(request.requestType)}: ${escapeHtml(request.name)}</strong>
<p>${escapeHtml(formatDate(request.date))} at ${escapeHtml(formatTime(request.time))}${request.endTime ? ` to ${escapeHtml(formatTime(request.endTime))}` : ""} - ${escapeHtml(request.guests || "guest count TBD")} guests - ${escapeHtml(request.eventType)}</p>
<p>${escapeHtml(request.package || "Package not selected")}</p>
<p>${escapeHtml(request.vendorHelp || "No vendor help selected")}</p>
<p>${escapeHtml(request.email)} - ${escapeHtml(request.phone)}</p>
</div>
<button type="button" data-delete-request="${escapeHtml(request.id)}">Remove</button>
</article>
`).join("");
}
function exportCsv() {
const requests = getRequests();
if (!requests.length) {
showToast("No requests to export yet.");
return;
}
const headers = ["createdAt", "requestType", "name", "email", "phone", "date", "time", "endTime", "guests", "package", "eventType", "space", "vendorHelp", "addons", "message"];
const rows = requests.map((request) => headers.map((key) => {
const value = Array.isArray(request[key]) ? request[key].join("; ") : request[key] || "";
return `"${String(value).replaceAll('"', '""')}"`;
}).join(","));
const blob = new Blob([[headers.join(","), ...rows].join("\n")], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "grange-station-requests.csv";
link.click();
URL.revokeObjectURL(url);
showToast("CSV exported.");
}
function openPhoto(index) {
if (!lightbox || !lightboxImage || !lightboxCaption) return;
currentPhoto = (index + galleryPhotos.length) % galleryPhotos.length;
lightboxImage.src = galleryPhotos[currentPhoto].src;
lightboxImage.alt = galleryPhotos[currentPhoto].caption;
lightboxCaption.textContent = galleryPhotos[currentPhoto].caption;
renderGalleryThumbs();
if (!lightbox.open) lightbox.showModal();
}
function renderGalleryThumbs() {
if (!galleryThumbs) return;
galleryThumbs.innerHTML = galleryPhotos.map((photo, index) => `
<button type="button" class="${index === currentPhoto ? "active" : ""}" data-gallery-thumb="${index}" aria-label="Open gallery photo ${index + 1}">
<img src="${photo.src}" alt="">
</button>
`).join("");
}
window.addEventListener("scroll", () => {
header.classList.toggle("scrolled", window.scrollY > 20);
});
menuToggle.addEventListener("click", () => {
const open = nav.classList.toggle("open");
menuToggle.setAttribute("aria-expanded", String(open));
});
nav.addEventListener("click", () => {
nav.classList.remove("open");
menuToggle.setAttribute("aria-expanded", "false");
});
document.querySelectorAll("[data-request-type]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-request-type]").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
form.elements.requestType.value = button.dataset.requestType;
if (button.dataset.requestType === "Private Viewing") {
form.elements.eventType.value = "Other";
form.elements.space.value = "Viewing only";
form.elements.vendorHelp.value = "No vendor help needed yet";
form.elements.guests.value = 2;
}
estimateRange();
updateAvailability();
updateMailto();
});
});
document.querySelector("[data-viewing-link]").addEventListener("click", () => {
document.querySelector("[data-request-type='Private Viewing']").click();
});
document.querySelector("[data-calendar-prev]").addEventListener("click", () => {
calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1);
renderBookingCalendar();
});
document.querySelector("[data-calendar-next]").addEventListener("click", () => {
calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1);
renderBookingCalendar();
});
calendarGrid.addEventListener("click", (event) => {
const button = event.target.closest("[data-calendar-date]");
if (!button) return;
dateInput.value = button.dataset.calendarDate;
renderBookingCalendar();
estimateRange();
updateAvailability();
updateMailto();
});
dateInput.addEventListener("change", () => {
setCalendarMonthFromDate(dateInput.value);
renderBookingCalendar();
});
dateInput.addEventListener("focus", () => {
bookingCalendar.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
form.addEventListener("input", () => {
estimateRange();
updateAvailability();
updateMailto();
});
form.addEventListener("change", () => {
estimateRange();
updateAvailability();
updateMailto();
});
if (fullDayInput) {
fullDayInput.addEventListener("change", () => {
form.elements.time.value = fullDayInput.checked ? "08:00" : (form.elements.time.value || "16:00");
form.elements.endTime.value = fullDayInput.checked ? "23:00" : (form.elements.endTime.value || "22:00");
form.elements.time.readOnly = fullDayInput.checked;
form.elements.endTime.readOnly = fullDayInput.checked;
estimateRange();
updateAvailability();
updateMailto();
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const request = collectFormData();
const validationError = validateRequest(request);
if (validationError) {
showToast(validationError);
return;
}
if (overlapsConfirmedBooking(request.date, request.time)) {
showToast("That time overlaps a confirmed booking. Please choose another time.");
return;
}
if (apiAvailable) {
try {
const result = await apiRequest("submit_request", publicRequestData(request));
await loadLiveData();
showToast(result.emailed ? "Request submitted and emailed to Grange Station." : "Request submitted. Email delivery is not configured on the server yet.");
} catch (error) {
showToast("The server could not save that request. Please try again or email events@grangestation.com.");
return;
}
} else {
const requests = [publicRequestData(request), ...getRequests()];
saveRequests(requests);
renderRequests();
}
form.reset();
form.elements.requestType.value = "Event Reservation";
form.elements.time.readOnly = false;
form.elements.endTime.readOnly = false;
resetAntiBotFields();
document.querySelectorAll("[data-request-type]").forEach((item) => item.classList.toggle("active", item.dataset.requestType === "Event Reservation"));
estimateRange();
updateAvailability();
updateMailto();
if (!apiAvailable) showToast("Request saved in the booking desk.");
});
if (contactForm) {
contactForm.addEventListener("submit", async (event) => {
event.preventDefault();
const message = collectContactData();
const validationError = validateContactMessage(message);
if (validationError) {
showToast(validationError);
return;
}
try {
const result = await apiRequest("submit_contact", message);
contactForm.reset();
if (contactStartedAt) contactStartedAt.value = String(Date.now());
showToast(result.emailed ? "Message emailed to Grange Station." : "Message saved. Email delivery is not configured on the server yet.");
} catch (error) {
showToast(error.message || "The server could not send that message. Please email events@grangestation.com.");
}
});
}
if (requestList) {
requestList.addEventListener("click", (event) => {
const button = event.target.closest("[data-delete-request]");
if (!button) return;
saveRequests(getRequests().filter((request) => request.id !== button.dataset.deleteRequest));
renderRequests();
showToast("Request removed.");
});
}
const exportButton = document.querySelector("[data-export]");
if (exportButton) exportButton.addEventListener("click", exportCsv);
const clearButton = document.querySelector("[data-clear]");
if (clearButton) {
clearButton.addEventListener("click", () => {
if (!getRequests().length) return;
saveRequests([]);
renderRequests();
showToast("Saved requests cleared.");
});
}
document.querySelector("[data-gallery-grid]").addEventListener("click", (event) => {
const button = event.target.closest("[data-photo]");
if (!button) return;
if (cropEditor && !cropEditor.hidden) {
loadCropEditor(Number(button.dataset.photo || 0));
return;
}
window.location.href = `gallery.html?photo=${button.dataset.galleryIndex || 0}`;
});
if (galleryThumbs) {
galleryThumbs.addEventListener("click", (event) => {
const button = event.target.closest("[data-gallery-thumb]");
if (button) openPhoto(Number(button.dataset.galleryThumb));
});
}
cropPhotoSelect.addEventListener("change", () => loadCropEditor(cropPhotoSelect.value));
[cropX, cropY, cropZoom, cropCols, cropRows, cropFit].forEach((control) => {
control.addEventListener("input", previewEditorCrop);
control.addEventListener("change", previewEditorCrop);
});
document.querySelector("[data-save-crop]").addEventListener("click", () => {
const settings = getCropSettings();
settings[selectedCropPhoto] = currentEditorCrop();
saveCropSettings(settings);
applySavedCrops();
showToast("Gallery crop saved.");
});
document.querySelector("[data-reset-crop]").addEventListener("click", () => {
const settings = getCropSettings();
delete settings[selectedCropPhoto];
saveCropSettings(settings);
loadCropEditor(selectedCropPhoto);
applySavedCrops();
showToast("Photo crop reset.");
});
document.querySelector("[data-reset-all-crops]").addEventListener("click", () => {
saveCropSettings({});
applySavedCrops();
loadCropEditor(selectedCropPhoto);
showToast("All gallery crops reset.");
});
if (lightbox) {
document.querySelector("[data-close-gallery]").addEventListener("click", () => lightbox.close());
document.querySelector("[data-prev-photo]").addEventListener("click", () => openPhoto(currentPhoto - 1));
document.querySelector("[data-next-photo]").addEventListener("click", () => openPhoto(currentPhoto + 1));
lightbox.addEventListener("click", (event) => {
if (event.target === lightbox) lightbox.close();
});
}
setCalendarMonthFromDate(dateInput.value);
bookingCalendar.hidden = false;
function initializePage() {
renderManagedContent();
estimateRange();
updateAvailability();
updateMailto();
renderRequests();
resetAntiBotFields();
applySavedCrops();
setupCropEditorMode();
loadCropEditor(0);
}
loadLiveData().finally(initializePage);