| 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 ownerCode = "grange";
const ownerSessionTimeoutMs = 30 * 60 * 1000;
let ownerSessionTimer = null;
let managerState = {
settings: getSiteConfig(),
confirmedBookings: [],
availabilityBlocks: [],
requests: [],
archivedRequests: []
};
let requestView = "active";
const login = document.querySelector("[data-owner-login]");
const desk = document.querySelector("[data-owner-desk]");
const codeInput = document.querySelector("[data-owner-code]");
const unlockButton = document.querySelector("[data-owner-unlock]");
const logoutButton = document.querySelector("[data-owner-logout]");
const toast = document.querySelector("[data-toast]");
const requestList = document.querySelector("[data-request-list]");
const confirmedList = document.querySelector("[data-confirmed-list]");
const blockList = document.querySelector("[data-block-list]");
const confirmedForm = document.querySelector("[data-confirmed-form]");
const blockForm = document.querySelector("[data-block-form]");
const brandingForm = document.querySelector("[data-branding-form]");
const settingsForm = document.querySelector("[data-settings-form]");
const ownerTabs = document.querySelectorAll("[data-owner-tab]");
const ownerSections = document.querySelectorAll("[data-owner-section]");
function showToast(message) {
toast.textContent = message;
toast.classList.add("visible");
window.setTimeout(() => toast.classList.remove("visible"), 2600);
}
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 packageIncludedText(pkg) {
if (Array.isArray(pkg.included) && pkg.included.length) return pkg.included.join("\n");
return pkg.includes || "";
}
function hydratedAddons() {
const defaults = defaultSiteConfig.addons || [];
const configured = managerState.settings.addons || [];
if (!configured.length) return defaults;
return configured.map((addon) => {
const fallback = defaults.find((item) => (item.value || item.label) === (addon.value || addon.label)) || {};
return { ...fallback, ...addon };
});
}
function packageOptions() {
const configured = (managerState.settings.packages || []).filter((pkg) => pkg.enabled !== false);
if (configured.length) return configured;
return (defaultSiteConfig.packages || []).filter((pkg) => pkg.enabled !== false);
}
function packageDisplayPrice(pkg) {
if (/simple/i.test(pkg.name || "")) return "$1,500 Fri/Sun, $1,800 Sat";
return formatPriceRange(pkg.priceLow, pkg.priceHigh);
}
function numberValue(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}
function selectedPackage(packageName) {
return packageOptions().find((pkg) => pkg.name === packageName);
}
function addonByValue(value) {
return hydratedAddons().find((addon) => (addon.value || addon.label) === value);
}
function packageBaseAmount(packageName, dateValue, extraHours) {
const pkg = selectedPackage(packageName);
if (!pkg) return 0;
const low = numberValue(pkg.priceLow);
const high = numberValue(pkg.priceHigh);
const date = dateValue ? new Date(`${dateValue}T12:00:00`) : null;
const isSaturday = date ? date.getDay() === 6 : false;
const base = /simple/i.test(pkg.name) ? (isSaturday ? high : low) : low;
return base + (extraHours * 100);
}
function ownerTally({ packageName = "", date = "", addons = [], extraHours = 0, priceAdjustment = 0 }) {
const billableExtraHours = numberValue(extraHours);
let total = packageBaseAmount(packageName, date, billableExtraHours);
const lines = [];
if (packageName) lines.push(`Package: ${formatMoney(packageBaseAmount(packageName, date, billableExtraHours))}`);
addons.forEach((value) => {
const addon = addonByValue(value);
if (!addon) return;
if (billableExtraHours > 0 && /setup time|extra hours/i.test(addon.label || value)) return;
const addonAmount = numberValue(addon.priceLow);
total += addonAmount;
lines.push(`${addon.label || value}: ${addonAmount ? formatMoney(addonAmount) : "Free"}`);
});
const adjustment = numberValue(priceAdjustment);
total += adjustment;
if (adjustment) lines.push(`Owner adjustment: ${adjustment > 0 ? "+" : ""}${formatMoney(adjustment)}`);
return { total: Math.max(0, total), lines };
}
function tallyForRecord(record) {
const addons = record.addons || selectedBookingAddons(record.notes, hydratedAddons());
const packageName = record.package || selectedPackageFromNotes(record.notes);
return ownerTally({
packageName,
date: record.date,
addons,
extraHours: record.extraHours,
priceAdjustment: record.priceAdjustment
});
}
function tallyForForm(form) {
const formData = new FormData(form);
return ownerTally({
packageName: formData.get("package") || "",
date: formData.get("date") || "",
addons: formData.getAll("addons"),
extraHours: formData.get("extraHours") || 0,
priceAdjustment: formData.get("priceAdjustment") || 0
});
}
function refreshTally(form, overwriteAmount = true) {
const tally = tallyForForm(form);
const amountInput = form.elements.amountOwed;
if (overwriteAmount && amountInput) amountInput.value = tally.total.toFixed(2);
const output = form.querySelector("[data-owner-tally]");
if (output) {
output.textContent = tally.lines.length
? `Suggested tally: ${formatMoney(tally.total)} (${tally.lines.join(" + ")})`
: `Suggested tally: ${formatMoney(tally.total)}`;
}
}
function tallyFieldChanged(target) {
return ["package", "addons", "extraHours", "priceAdjustment", "date"].includes(target.name);
}
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) {
if (response.status === 401 && /session|login|required|timed out/i.test(data.error || "")) lockOwnerDesk(data.error || "Please enter the owner code again.", false);
throw new Error(data.error || "Request failed.");
}
return data;
}
function startOwnerSessionTimer() {
window.clearTimeout(ownerSessionTimer);
ownerSessionTimer = window.setTimeout(() => {
lockOwnerDesk("Owner desk timed out. Please enter the owner code again.");
}, ownerSessionTimeoutMs);
}
function noteOwnerActivity() {
if (!desk.hidden) startOwnerSessionTimer();
}
async function lockOwnerDesk(message = "Owner desk locked.", notifyServer = true) {
window.clearTimeout(ownerSessionTimer);
ownerSessionTimer = null;
sessionStorage.removeItem("grangeStationOwnerUnlocked");
login.hidden = false;
desk.hidden = true;
codeInput.value = "";
if (notifyServer) {
try {
await fetch("api/index.php?action=owner_logout");
} catch (error) {
// The visual lock still matters even if the network request fails.
}
}
showToast(message);
}
async function uploadImage(file) {
const formData = new FormData();
formData.append("image", file);
const response = await fetch("api/index.php?action=upload_image", {
method: "POST",
body: formData
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || "Upload failed.");
return data.path;
}
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 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 cleanBookingNotes(notes) {
return String(notes || "")
.split("\n")
.filter((line) => !/^(selected package|package|selected options|add-ons):/i.test(line.trim()))
.join("\n")
.trim();
}
function selectedPackageFromNotes(notes) {
const line = String(notes || "").split("\n").find((item) => /^(selected package|package):/i.test(item.trim()));
return line ? line.replace(/^(selected package|package):\s*/i, "").trim() : "";
}
function selectedBookingAddons(notes, addonOptions) {
const normalized = String(notes || "").toLowerCase();
return addonOptions
.map((addon) => addon.value || addon.label)
.filter((value) => value && normalized.includes(String(value).toLowerCase()));
}
function composeBookingNotes(notes, addons, packageName = "") {
const cleanNotes = cleanBookingNotes(notes);
const packageLine = packageName ? `Selected package: ${packageName}` : "";
const addonLine = addons.length ? `Selected options: ${addons.join(", ")}` : "";
return [packageLine, addonLine, cleanNotes].filter(Boolean).join("\n");
}
async function loadOwnerData() {
const data = await apiRequest("owner_get");
managerState = {
settings: mergeConfig(data.settings || {}),
confirmedBookings: data.confirmedBookings || [],
availabilityBlocks: data.availabilityBlocks || [],
requests: data.requests || [],
archivedRequests: data.archivedRequests || []
};
renderAll();
}
async function unlock() {
try {
await apiRequest("owner_login", { code: codeInput.value });
sessionStorage.setItem("grangeStationOwnerUnlocked", "true");
login.hidden = true;
desk.hidden = false;
startOwnerSessionTimer();
await loadOwnerData();
} catch (error) {
showToast("Owner code did not match or the server is not connected.");
}
}
function renderAll() {
renderConfirmedBookings();
renderBlocks();
renderRequests();
renderBrandingForm();
renderSettingsForm();
renderAddons();
renderPackages();
renderVendors();
renderGalleryManager();
renderFaqManager();
}
function showOwnerSection(sectionName) {
ownerTabs.forEach((tab) => tab.classList.toggle("active", tab.dataset.ownerTab === sectionName));
ownerSections.forEach((section) => section.classList.toggle("active", section.dataset.ownerSection === sectionName));
}
function renderConfirmedBookings() {
if (!managerState.confirmedBookings.length) {
confirmedList.innerHTML = '<div class="empty">No confirmed bookings yet.</div>';
return;
}
const addonOptions = hydratedAddons().filter((addon) => addon.enabled !== false);
const packageOptionsList = packageOptions();
confirmedList.innerHTML = managerState.confirmedBookings.map((booking) => `
<article class="request-card booking-card">
<details class="request-details booking-details">
<summary>
<strong>${escapeHtml(booking.title || "Confirmed booking")}</strong>
<span>${escapeHtml(formatDate(booking.date))} - ${escapeHtml(formatTime(booking.start))} to ${escapeHtml(formatTime(booking.end))}${booking.guests ? ` - ${escapeHtml(booking.guests)} guests` : ""}</span>
<span>Amount owed: ${escapeHtml(formatMoney(booking.amountOwed || tallyForRecord(booking).total))}</span>
${booking.notes ? `<span>${escapeHtml(booking.notes)}</span>` : ""}
</summary>
<form class="booking-edit-form" data-booking-edit>
<input type="hidden" name="id" value="${escapeHtml(booking.id)}">
<label>Event name<input required name="title" value="${escapeHtml(booking.title || "")}"></label>
<label>Date<input required name="date" type="date" value="${escapeHtml(booking.date || "")}"></label>
<label>Start<input required name="start" type="time" value="${escapeHtml(booking.start || "14:00")}"></label>
<label>End<input required name="end" type="time" value="${escapeHtml(booking.end || "22:00")}"></label>
<label>Guests<input name="guests" type="number" min="0" max="250" value="${escapeHtml(booking.guests || "")}"></label>
<label>Extra hours<input name="extraHours" type="number" min="0" step="0.25" value="${escapeHtml(booking.extraHours || 0)}"></label>
<label>Package
<select name="package">
<option value="">Not selected</option>
${packageOptionsList.map((pkg) => `<option value="${escapeHtml(pkg.name)}" ${selectedPackageFromNotes(booking.notes) === pkg.name ? "selected" : ""}>${escapeHtml(pkg.name)} - ${escapeHtml(packageDisplayPrice(pkg))}</option>`).join("")}
</select>
</label>
<label>Owner adjustment<input name="priceAdjustment" type="number" step="0.01" value="${escapeHtml(booking.priceAdjustment || 0)}"></label>
<label>Amount owed<input name="amountOwed" type="number" min="0" step="0.01" value="${escapeHtml(booking.amountOwed || tallyForRecord(booking).total)}"></label>
<fieldset class="request-addon-editor booking-addon-editor">
<legend>Features / add-ons</legend>
${addonOptions.map((addon) => {
const value = addon.value || addon.label;
const checked = selectedBookingAddons(booking.notes, addonOptions).includes(value) ? "checked" : "";
return `<label><input type="checkbox" name="addons" value="${escapeHtml(value)}" ${checked}> ${escapeHtml(addon.label || value)} <small>${escapeHtml(addonPriceRange(addon))}</small></label>`;
}).join("")}
</fieldset>
<div class="owner-tally" data-owner-tally>${escapeHtml(`Suggested tally: ${formatMoney(tallyForRecord(booking).total)}`)}</div>
<label class="booking-notes-field">Booking notes<textarea name="notes" rows="3">${escapeHtml(cleanBookingNotes(booking.notes))}</textarea></label>
<div class="booking-edit-actions">
<button type="button" data-refresh-tally>Update Tally</button>
<button type="submit">Save</button>
<button type="button" data-delete-confirmed="${escapeHtml(booking.id)}">Remove</button>
</div>
</form>
</details>
</article>
`).join("");
}
function renderBlocks() {
if (!managerState.availabilityBlocks.length) {
blockList.innerHTML = '<div class="empty">No unavailable dates or holds yet.</div>';
return;
}
blockList.innerHTML = managerState.availabilityBlocks.map((block) => `
<article class="request-card">
<div>
<strong>${escapeHtml(block.title || "Unavailable")}</strong>
<p>${escapeHtml(formatDate(block.date))} - ${escapeHtml(formatTime(block.start))} to ${escapeHtml(formatTime(block.end))} - ${escapeHtml(block.reason || "owner hold")}</p>
</div>
<button type="button" data-delete-block="${escapeHtml(block.id)}">Remove</button>
</article>
`).join("");
}
function renderRequests() {
const rows = requestView === "archived" ? managerState.archivedRequests : managerState.requests;
const addonOptions = hydratedAddons().filter((addon) => addon.enabled !== false);
const packageOptionsList = packageOptions();
document.querySelectorAll("[data-request-view]").forEach((button) => {
button.classList.toggle("active", button.dataset.requestView === requestView);
});
const archiveButton = document.querySelector("[data-request-action]");
const restoreButton = document.querySelector("[data-request-restore]");
archiveButton.textContent = requestView === "archived" ? "Delete Selected" : "Archive Selected";
restoreButton.hidden = requestView !== "archived";
if (!rows.length) {
requestList.innerHTML = `<div class="empty">No ${requestView === "archived" ? "archived" : "saved"} requests yet.</div>`;
return;
}
requestList.innerHTML = rows.map((request) => `
<article class="request-card request-card-selectable">
<label class="request-select"><input type="checkbox" data-request-select value="${escapeHtml(request.id)}"> Select</label>
<details class="request-details">
<summary>
<strong>${escapeHtml(request.requestType)}: ${escapeHtml(request.name)}</strong>
<span>${escapeHtml(formatDate(request.date))} at ${escapeHtml(formatTime(request.time))}${request.endTime ? ` to ${escapeHtml(formatTime(request.endTime))}` : ""} - ${escapeHtml(request.guests || "guest count TBD")} guests</span>
<span>${escapeHtml(request.email)} - ${escapeHtml(request.phone)}</span>
</summary>
<dl class="request-detail-list">
<div><dt>Event type</dt><dd>${escapeHtml(request.eventType || "Not selected")}</dd></div>
<div><dt>Space</dt><dd>${escapeHtml(request.space || "Not selected")}</dd></div>
<div><dt>Vendor help</dt><dd>${escapeHtml(request.vendorHelp || "No vendor help selected")}</dd></div>
<div><dt>Package</dt><dd>${escapeHtml(request.package || "Not selected")}</dd></div>
<div><dt>Add-ons</dt><dd>${escapeHtml((request.addons || []).join(", ") || "None selected")}</dd></div>
<div><dt>Amount owed</dt><dd>${escapeHtml(formatMoney(request.amountOwed || tallyForRecord(request).total))}</dd></div>
<div><dt>Message</dt><dd>${escapeHtml(request.message || "No message")}</dd></div>
</dl>
<form class="request-edit-form" data-request-edit>
<input type="hidden" name="id" value="${escapeHtml(request.id)}">
<label>Request type
<select name="requestType">
${["Event Reservation", "Private Viewing"].map((option) => `<option ${request.requestType === option ? "selected" : ""}>${option}</option>`).join("")}
</select>
</label>
<label>Name<input required name="name" value="${escapeHtml(request.name || "")}"></label>
<label>Email<input required name="email" type="email" value="${escapeHtml(request.email || "")}"></label>
<label>Phone<input required name="phone" value="${escapeHtml(request.phone || "")}"></label>
<label>Date<input required name="date" type="date" value="${escapeHtml(request.date || "")}"></label>
<label>Start<input required name="time" type="time" value="${escapeHtml(request.time || "16:00")}"></label>
<label>End<input required name="endTime" type="time" value="${escapeHtml(request.endTime || "22:00")}"></label>
<label>Guests<input name="guests" type="number" min="0" max="250" value="${escapeHtml(request.guests || "")}"></label>
<label>Extra hours<input name="extraHours" type="number" min="0" step="0.25" value="${escapeHtml(request.extraHours || 0)}"></label>
<label>Package
<select name="package">
<option value="">Not sure yet</option>
${packageOptionsList.map((pkg) => `<option value="${escapeHtml(pkg.name)}" ${request.package === pkg.name ? "selected" : ""}>${escapeHtml(pkg.name)} - ${escapeHtml(packageDisplayPrice(pkg))}</option>`).join("")}
</select>
</label>
<label>Owner adjustment<input name="priceAdjustment" type="number" step="0.01" value="${escapeHtml(request.priceAdjustment || 0)}"></label>
<label>Amount owed<input name="amountOwed" type="number" min="0" step="0.01" value="${escapeHtml(request.amountOwed || tallyForRecord(request).total)}"></label>
<label>Event type<input name="eventType" value="${escapeHtml(request.eventType || "")}"></label>
<label>Space<input name="space" value="${escapeHtml(request.space || "")}"></label>
<label>Vendor help<input name="vendorHelp" value="${escapeHtml(request.vendorHelp || "")}"></label>
<fieldset class="request-addon-editor">
<legend>Add-ons</legend>
${addonOptions.map((addon) => {
const value = addon.value || addon.label;
const checked = (request.addons || []).includes(value) ? "checked" : "";
return `<label><input type="checkbox" name="addons" value="${escapeHtml(value)}" ${checked}> ${escapeHtml(addon.label || value)} <small>${escapeHtml(addonPriceRange(addon))}</small></label>`;
}).join("")}
</fieldset>
<div class="owner-tally" data-owner-tally>${escapeHtml(`Suggested tally: ${formatMoney(tallyForRecord(request).total)}`)}</div>
<label class="request-message-field">Message / wants<textarea name="message" rows="3">${escapeHtml(request.message || "")}</textarea></label>
<button type="button" data-refresh-tally>Update Tally</button>
<button type="submit">Save Request Changes</button>
</form>
</details>
<div class="request-card-actions">
<span>${escapeHtml(request.status || "new")}</span>
${requestView === "active" && request.status !== "confirmed" ? `<button type="button" data-approve-request="${escapeHtml(request.id)}">Approve Request</button>` : ""}
</div>
</article>
`).join("");
}
function renderBrandingForm() {
const branding = managerState.settings.branding || {};
["venueName", "tagline", "logoImage", "heroImage", "socialImage"].forEach((key) => {
if (brandingForm.elements[key]) brandingForm.elements[key].value = branding[key] || "";
});
}
function renderSettingsForm() {
const pricingLabels = {
weekendLow: "Weekend low",
weekendHigh: "Weekend high",
weekdayLow: "Weekday low",
weekdayHigh: "Weekday high",
over100Low: "Over 100 guests low",
over100High: "Over 100 guests high",
standardAddonLow: "Standard add-on low",
standardAddonHigh: "Standard add-on high",
rentalAddonLow: "Rental add-on low",
rentalAddonHigh: "Rental add-on high",
vendorIntroLow: "Vendor intro low",
vendorIntroHigh: "Vendor intro high"
};
document.querySelector("[data-pricing-fields]").innerHTML = Object.entries(pricingLabels).map(([key, label]) => `
<label>${escapeHtml(label)}<input type="number" min="0" name="${key}" value="${escapeHtml(managerState.settings.pricing[key])}"></label>
`).join("");
const colorLabels = { ink: "Text", paper: "Page", cream: "Cards", barn: "Accent red", sage: "Available green", gold: "Gold" };
document.querySelector("[data-color-fields]").innerHTML = Object.entries(colorLabels).map(([key, label]) => `
<label>${escapeHtml(label)}<input type="color" name="${key}" value="${escapeHtml(managerState.settings.colors[key])}"></label>
`).join("");
}
function renderAddons() {
document.querySelector("[data-addon-manager]").innerHTML = `
<h3>Add-ons</h3>
${hydratedAddons().map((addon, index) => `
<div class="manager-row" data-addon-row="${index}">
<label><input type="checkbox" data-addon-enabled ${addon.enabled !== false ? "checked" : ""}> Show</label>
<input data-addon-label value="${escapeHtml(addon.label)}">
<input data-addon-value value="${escapeHtml(addon.value)}">
<label>Low<input type="number" min="0" data-addon-low value="${escapeHtml(addon.priceLow ?? 0)}"></label>
<label>High<input type="number" min="0" data-addon-high value="${escapeHtml(addon.priceHigh ?? 0)}"></label>
<input data-addon-unit value="${escapeHtml(addon.priceUnit || "")}" placeholder="per hour, per table, etc.">
<button class="button ghost danger" type="button" data-remove-addon="${index}">Remove</button>
</div>
`).join("")}
`;
}
function renderPackages() {
document.querySelector("[data-package-manager]").innerHTML = `
${managerState.settings.packages.map((pkg, index) => `
<div class="manager-row tall package-manager-row" data-package-row="${index}">
<label><input type="checkbox" data-package-enabled ${pkg.enabled !== false ? "checked" : ""}> Show</label>
<input data-package-name value="${escapeHtml(pkg.name || "")}" placeholder="Package name">
<input data-package-subtitle value="${escapeHtml(pkg.subtitle || "")}" placeholder="Subtitle">
<label>Low<input type="number" min="0" data-package-low value="${escapeHtml(pkg.priceLow || 0)}"></label>
<label>High<input type="number" min="0" data-package-high value="${escapeHtml(pkg.priceHigh || 0)}"></label>
<input data-package-investment-note value="${escapeHtml(pkg.investmentNote || "")}" placeholder="Investment note">
<textarea data-package-time rows="2" placeholder="Time block">${escapeHtml(pkg.timeBlock || "")}</textarea>
<textarea data-package-description rows="2" placeholder="Description">${escapeHtml(pkg.description || "")}</textarea>
<textarea data-package-includes rows="4" placeholder="What's included, one item per line">${escapeHtml(packageIncludedText(pkg))}</textarea>
<textarea data-package-blueprint rows="2" placeholder="Blueprint">${escapeHtml(pkg.blueprint || "")}</textarea>
<button class="button ghost danger" type="button" data-remove-package="${index}">Remove</button>
</div>
`).join("")}
`;
}
function renderVendors() {
document.querySelector("[data-vendor-manager]").innerHTML = `
<h3>Planning Help Cards</h3>
<p class="manager-hint">Drag the Move handle to reorder these cards, then click Save Offerings.</p>
${managerState.settings.vendors.map((vendor, index) => `
<div class="manager-row tall vendor-manager-row" data-vendor-row="${index}">
<button class="drag-handle" type="button" draggable="true" data-vendor-drag aria-label="Move planning card">Move</button>
<label><input type="checkbox" data-vendor-enabled ${vendor.enabled !== false ? "checked" : ""}> Show</label>
<input data-vendor-category value="${escapeHtml(vendor.category)}">
<input data-vendor-title value="${escapeHtml(vendor.title)}">
<textarea data-vendor-description rows="2">${escapeHtml(vendor.description)}</textarea>
<button class="button ghost danger" type="button" data-remove-vendor="${index}">Remove</button>
</div>
`).join("")}
`;
}
function renderGalleryManager() {
document.querySelector("[data-gallery-manager]").innerHTML = managerState.settings.gallery.map((photo, index) => `
<div class="manager-row gallery-manager-row" data-gallery-row="${index}">
<img src="${escapeHtml(photo.src)}" alt="">
<label><input type="checkbox" data-gallery-enabled ${photo.enabled !== false ? "checked" : ""}> Show</label>
<input data-gallery-src value="${escapeHtml(photo.src)}">
<input data-gallery-caption value="${escapeHtml(photo.caption)}">
<button class="button ghost danger" type="button" data-remove-gallery="${index}">Remove</button>
</div>
`).join("");
}
function renderFaqManager() {
document.querySelector("[data-faq-manager]").innerHTML = `
<p class="manager-hint">Drag the Move handle to reorder these questions, then click Save Helpful Answers.</p>
${managerState.settings.faqs.map((faq, index) => `
<div class="manager-row tall faq-manager-row" data-faq-row="${index}">
<button class="drag-handle" type="button" draggable="true" data-faq-drag aria-label="Move helpful answer">Move</button>
<label><input type="checkbox" data-faq-enabled ${faq.enabled !== false ? "checked" : ""}> Show</label>
<input data-faq-question value="${escapeHtml(faq.question)}">
<textarea data-faq-answer rows="3">${escapeHtml(faq.answer)}</textarea>
<button class="button ghost danger" type="button" data-remove-faq="${index}">Remove</button>
</div>
`).join("")}
`;
}
function collectSettingsFromManagers() {
document.querySelectorAll("[data-addon-row]").forEach((row) => {
const index = Number(row.dataset.addonRow);
managerState.settings.addons[index] = {
enabled: row.querySelector("[data-addon-enabled]").checked,
label: row.querySelector("[data-addon-label]").value,
value: row.querySelector("[data-addon-value]").value,
priceLow: Number(row.querySelector("[data-addon-low]").value || 0),
priceHigh: Number(row.querySelector("[data-addon-high]").value || 0),
priceUnit: row.querySelector("[data-addon-unit]").value
};
});
managerState.settings.packages = [...document.querySelectorAll("[data-package-row]")].map((row) => ({
enabled: row.querySelector("[data-package-enabled]").checked,
name: row.querySelector("[data-package-name]").value,
subtitle: row.querySelector("[data-package-subtitle]").value,
priceLow: Number(row.querySelector("[data-package-low]").value || 0),
priceHigh: Number(row.querySelector("[data-package-high]").value || 0),
investmentNote: row.querySelector("[data-package-investment-note]").value,
timeBlock: row.querySelector("[data-package-time]").value,
description: row.querySelector("[data-package-description]").value,
included: row.querySelector("[data-package-includes]").value.split("\n").map((item) => item.trim()).filter(Boolean),
includes: row.querySelector("[data-package-includes]").value,
blueprint: row.querySelector("[data-package-blueprint]").value
}));
managerState.settings.vendors = [...document.querySelectorAll("[data-vendor-row]")].map((row) => ({
enabled: row.querySelector("[data-vendor-enabled]").checked,
category: row.querySelector("[data-vendor-category]").value,
title: row.querySelector("[data-vendor-title]").value,
description: row.querySelector("[data-vendor-description]").value
}));
document.querySelectorAll("[data-gallery-row]").forEach((row) => {
const index = Number(row.dataset.galleryRow);
managerState.settings.gallery[index] = {
enabled: row.querySelector("[data-gallery-enabled]").checked,
src: row.querySelector("[data-gallery-src]").value,
caption: row.querySelector("[data-gallery-caption]").value
};
});
managerState.settings.faqs = [...document.querySelectorAll("[data-faq-row]")].map((row) => ({
enabled: row.querySelector("[data-faq-enabled]").checked,
question: row.querySelector("[data-faq-question]").value,
answer: row.querySelector("[data-faq-answer]").value
}));
}
async function saveSettings() {
collectSettingsFromManagers();
await apiRequest("save_settings", { settings: managerState.settings });
showToast("Site settings saved.");
}
unlockButton.addEventListener("click", unlock);
codeInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") unlock();
});
logoutButton.addEventListener("click", () => {
lockOwnerDesk("Owner desk locked.");
});
["click", "keydown", "input", "change", "scroll", "touchstart"].forEach((eventName) => {
window.addEventListener(eventName, noteOwnerActivity, { passive: true });
});
ownerTabs.forEach((tab) => {
tab.addEventListener("click", () => showOwnerSection(tab.dataset.ownerTab));
});
confirmedForm.addEventListener("submit", async (event) => {
event.preventDefault();
await apiRequest("add_confirmed", Object.fromEntries(new FormData(confirmedForm)));
confirmedForm.reset();
await loadOwnerData();
showToast("Confirmed booking added.");
});
blockForm.addEventListener("submit", async (event) => {
event.preventDefault();
await apiRequest("add_block", Object.fromEntries(new FormData(blockForm)));
blockForm.reset();
await loadOwnerData();
showToast("Unavailable time added.");
});
brandingForm.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(brandingForm));
managerState.settings.branding = {
venueName: data.venueName || "Grange Station",
tagline: data.tagline || "Wilson Creek, WA",
logoImage: data.logoImage || "",
heroImage: data.heroImage || "assets/grange-station.png",
socialImage: data.socialImage || data.heroImage || "assets/grange-station.png"
};
await saveSettings();
});
brandingForm.addEventListener("change", async (event) => {
const input = event.target.closest("[data-upload-image]");
if (!input || !input.files.length) return;
const targetField = input.dataset.uploadImage;
try {
showToast("Uploading image...");
const path = await uploadImage(input.files[0]);
brandingForm.elements[targetField].value = path;
input.value = "";
showToast("Image uploaded. Click Save Branding when ready.");
} catch (error) {
showToast(error.message || "Image upload failed.");
}
});
settingsForm.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(settingsForm));
Object.keys(managerState.settings.pricing).forEach((key) => managerState.settings.pricing[key] = Number(data[key] || 0));
Object.keys(managerState.settings.colors).forEach((key) => managerState.settings.colors[key] = data[key] || managerState.settings.colors[key]);
await saveSettings();
});
confirmedList.addEventListener("click", async (event) => {
const tallyButton = event.target.closest("[data-refresh-tally]");
if (tallyButton) {
const form = tallyButton.closest("form");
if (form) refreshTally(form, true);
return;
}
const button = event.target.closest("[data-delete-confirmed]");
if (!button) return;
await apiRequest("delete_confirmed", { id: button.dataset.deleteConfirmed });
await loadOwnerData();
showToast("Confirmed booking removed.");
});
confirmedList.addEventListener("input", (event) => {
const form = event.target.closest("[data-booking-edit]");
if (!form || !tallyFieldChanged(event.target)) return;
refreshTally(form, true);
});
confirmedList.addEventListener("change", (event) => {
const form = event.target.closest("[data-booking-edit]");
if (!form || !tallyFieldChanged(event.target)) return;
refreshTally(form, true);
});
confirmedList.addEventListener("submit", async (event) => {
const form = event.target.closest("[data-booking-edit]");
if (!form) return;
event.preventDefault();
const formData = new FormData(form);
const payload = Object.fromEntries(formData);
payload.addons = formData.getAll("addons");
payload.notes = composeBookingNotes(payload.notes, payload.addons, payload.package);
await apiRequest("update_confirmed", payload);
await loadOwnerData();
showToast("Confirmed booking updated.");
});
blockList.addEventListener("click", async (event) => {
const button = event.target.closest("[data-delete-block]");
if (!button) return;
await apiRequest("delete_block", { id: button.dataset.deleteBlock });
await loadOwnerData();
showToast("Unavailable time removed.");
});
requestList.addEventListener("click", async (event) => {
const tallyButton = event.target.closest("[data-refresh-tally]");
if (tallyButton) {
const form = tallyButton.closest("form");
if (form) refreshTally(form, true);
return;
}
const button = event.target.closest("[data-approve-request]");
if (!button) return;
await apiRequest("approve_request", { id: button.dataset.approveRequest });
await loadOwnerData();
showToast("Request approved and added to confirmed bookings.");
});
requestList.addEventListener("input", (event) => {
const form = event.target.closest("[data-request-edit]");
if (!form || !tallyFieldChanged(event.target)) return;
refreshTally(form, true);
});
requestList.addEventListener("change", (event) => {
const form = event.target.closest("[data-request-edit]");
if (!form || !tallyFieldChanged(event.target)) return;
refreshTally(form, true);
});
requestList.addEventListener("submit", async (event) => {
const form = event.target.closest("[data-request-edit]");
if (!form) return;
event.preventDefault();
const formData = new FormData(form);
const payload = Object.fromEntries(formData);
payload.addons = formData.getAll("addons");
await apiRequest("update_request", payload);
await loadOwnerData();
showToast("Request details updated.");
});
document.querySelector("[data-save-offerings]").addEventListener("click", saveSettings);
document.querySelector("[data-save-packages]").addEventListener("click", saveSettings);
document.querySelector("[data-save-gallery]").addEventListener("click", saveSettings);
document.querySelector("[data-save-faqs]").addEventListener("click", saveSettings);
document.querySelector("[data-add-addon]").addEventListener("click", () => {
collectSettingsFromManagers();
managerState.settings.addons.push({ label: "New add-on", value: "New add-on", priceLow: 0, priceHigh: 0, priceUnit: "", enabled: true });
renderAddons();
});
document.querySelector("[data-addon-manager]").addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-addon]");
if (!button) return;
collectSettingsFromManagers();
managerState.settings.addons.splice(Number(button.dataset.removeAddon), 1);
renderAddons();
});
document.querySelector("[data-add-package]").addEventListener("click", () => {
collectSettingsFromManagers();
managerState.settings.packages.push({
name: "New Package",
subtitle: "Package type",
priceLow: 0,
priceHigh: 0,
investmentNote: "",
timeBlock: "Describe the access window.",
description: "Describe who this package fits.",
included: ["List included items."],
includes: "List included items.",
blueprint: "Add planning notes.",
enabled: true
});
renderPackages();
});
document.querySelector("[data-package-manager]").addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-package]");
if (!button) return;
collectSettingsFromManagers();
managerState.settings.packages.splice(Number(button.dataset.removePackage), 1);
renderPackages();
});
document.querySelector("[data-add-vendor]").addEventListener("click", () => {
collectSettingsFromManagers();
managerState.settings.vendors.push({
category: "New Category",
title: "New planning card",
description: "Describe this offering or vendor help option.",
enabled: true
});
renderVendors();
});
const vendorManager = document.querySelector("[data-vendor-manager]");
let draggedVendorRow = null;
vendorManager.addEventListener("dragstart", (event) => {
const handle = event.target.closest("[data-vendor-drag]");
if (!handle) return;
draggedVendorRow = handle.closest("[data-vendor-row]");
draggedVendorRow.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedVendorRow.dataset.vendorRow || "");
});
vendorManager.addEventListener("dragover", (event) => {
if (!draggedVendorRow) return;
event.preventDefault();
const target = event.target.closest("[data-vendor-row]");
if (!target || target === draggedVendorRow) return;
const rect = target.getBoundingClientRect();
const placeAfter = event.clientY > rect.top + rect.height / 2;
target.parentNode.insertBefore(draggedVendorRow, placeAfter ? target.nextSibling : target);
});
vendorManager.addEventListener("dragend", () => {
if (!draggedVendorRow) return;
draggedVendorRow.classList.remove("dragging");
draggedVendorRow = null;
collectSettingsFromManagers();
renderVendors();
});
vendorManager.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-vendor]");
if (!button) return;
collectSettingsFromManagers();
managerState.settings.vendors.splice(Number(button.dataset.removeVendor), 1);
renderVendors();
});
document.querySelector("[data-add-gallery-image]").addEventListener("click", () => {
const src = document.querySelector("[data-new-gallery-src]").value.trim();
const caption = document.querySelector("[data-new-gallery-caption]").value.trim() || "Grange Station venue gallery image.";
if (!src) return showToast("Add the image path first.");
managerState.settings.gallery.push({ src, caption, enabled: true });
renderGalleryManager();
});
document.querySelector("[data-gallery-manager]").addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-gallery]");
if (!button) return;
managerState.settings.gallery.splice(Number(button.dataset.removeGallery), 1);
renderGalleryManager();
});
document.querySelector("[data-add-faq]").addEventListener("click", () => {
collectSettingsFromManagers();
managerState.settings.faqs.push({ question: "New question", answer: "New answer.", enabled: true });
renderFaqManager();
});
const faqManager = document.querySelector("[data-faq-manager]");
let draggedFaqRow = null;
faqManager.addEventListener("dragstart", (event) => {
const handle = event.target.closest("[data-faq-drag]");
if (!handle) return;
draggedFaqRow = handle.closest("[data-faq-row]");
draggedFaqRow.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedFaqRow.dataset.faqRow || "");
});
faqManager.addEventListener("dragover", (event) => {
if (!draggedFaqRow) return;
event.preventDefault();
const target = event.target.closest("[data-faq-row]");
if (!target || target === draggedFaqRow) return;
const rect = target.getBoundingClientRect();
const placeAfter = event.clientY > rect.top + rect.height / 2;
target.parentNode.insertBefore(draggedFaqRow, placeAfter ? target.nextSibling : target);
});
faqManager.addEventListener("dragend", () => {
if (!draggedFaqRow) return;
draggedFaqRow.classList.remove("dragging");
draggedFaqRow = null;
collectSettingsFromManagers();
renderFaqManager();
});
faqManager.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-faq]");
if (!button) return;
collectSettingsFromManagers();
managerState.settings.faqs.splice(Number(button.dataset.removeFaq), 1);
renderFaqManager();
});
document.querySelector("[data-export]").addEventListener("click", () => {
const rows = requestView === "archived" ? managerState.archivedRequests : managerState.requests;
if (!rows.length) return showToast("No requests to export yet.");
const headers = ["createdAt", "requestType", "name", "email", "phone", "date", "time", "endTime", "guests", "package", "eventType", "space", "vendorHelp", "message"];
const csv = [headers.join(","), ...rows.map((row) => headers.map((key) => `"${String(row[key] || "").replaceAll('"', '""')}"`).join(","))].join("\n");
const link = document.createElement("a");
link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
link.download = "grange-station-requests.csv";
link.click();
});
document.querySelectorAll("[data-request-view]").forEach((button) => {
button.addEventListener("click", () => {
requestView = button.dataset.requestView;
renderRequests();
});
});
document.querySelector("[data-request-action]").addEventListener("click", async () => {
const selectedIds = [...document.querySelectorAll("[data-request-select]:checked")].map((input) => input.value);
if (!selectedIds.length) {
showToast("Select one or more requests first.");
return;
}
await apiRequest(requestView === "archived" ? "delete_archived_requests" : "archive_requests", { ids: selectedIds });
await loadOwnerData();
showToast(requestView === "archived" ? "Selected archived requests deleted." : "Selected requests archived.");
});
document.querySelector("[data-request-restore]").addEventListener("click", async () => {
const selectedIds = [...document.querySelectorAll("[data-request-select]:checked")].map((input) => input.value);
if (!selectedIds.length) {
showToast("Select one or more archived requests first.");
return;
}
await apiRequest("unarchive_requests", { ids: selectedIds });
await loadOwnerData();
showToast("Selected requests moved back to active.");
});
if (sessionStorage.getItem("grangeStationOwnerUnlocked") === "true") {
login.hidden = true;
desk.hidden = false;
startOwnerSessionTimer();
loadOwnerData().catch((error) => {
sessionStorage.removeItem("grangeStationOwnerUnlocked");
login.hidden = false;
desk.hidden = true;
showToast(error.message === "Owner login required."
? "Please enter the owner code again."
: `Could not load owner data: ${error.message}`);
});
}