Ridgeview Student Store

Register 1

RegisterOpen
Sales today0
Revenue$0.00
Low stock0

Catalog

Select items

Quantity

Items are added to the active register cart. Changes are saved in this browser only.

All items

    Backup Data saves a copy of products, student balances, and sales to a JSON file. Keep backups private.

    Cashier

    Active register

    Type a student ID to load their account automatically.

    Cart0 items
      Subtotal$0.00
      TaxExempt
      Total$0.00
      Account balance—
      Balance after sale—

      Choose this for classroom delivery. The student's room on file will be used as the destination.

      Store admin

      Management

      Add a product

      Products
      ProductPriceActions
      "; var win = window.open("", "_blank", "width=460,height=680"); if (!win) { toast("Allow pop-ups to print a receipt.", "warn"); return; } win.document.open(); win.document.write(receiptHtml); win.document.close(); } /* ---------- Export ---------- */ function exportSalesCsv() { var rows = [["Sale", "Status", "Date", "Time", "Student ID", "Student", "Items", "Units", "Total"]]; state.sales.slice().reverse().forEach(function (sale) { var date = sale.time instanceof Date ? sale.time : new Date(sale.time); var items = (sale.lines || []).map(function (line) { return line.qty + "x " + line.name + (line.size ? " (" + line.size + ")" : ""); }).join("; "); rows.push([sale.no, sale.voided ? "VOIDED" : "COMPLETED", date.toLocaleDateString(), date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), sale.studentId, sale.studentName, items, sale.units, money(sale.total)]); }); var csv = rows.map(function (row) { return row.map(function (value) { var cell = String(value == null ? "" : value); // Neutralize spreadsheet formula injection in exported text fields. if (/^[=+@\-\t\r]/.test(cell) && !/^\-?\d+(?:\.\d+)?$/.test(cell)) cell = "'" + cell; return '"' + cell.replace(/"/g, '""') + '"'; }).join(","); }).join("\r\n"); var blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }); var url = URL.createObjectURL(blob); var link = document.createElement("a"); link.href = url; link.download = "ridgeview-sales-" + new Date().toISOString().slice(0, 10) + ".csv"; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); toast("Sales CSV exported."); } function csvCell(value) { var cell = String(value == null ? "" : value); if (/^[=+@\-\t\r]/.test(cell) && !/^\-?\d+(?:\.\d+)?$/.test(cell)) cell = "'" + cell; return '"' + cell.replace(/"/g, '""') + '"'; } function exportTableCsv(rows, filename, message) { var csv = rows.map(function (row) { return row.map(csvCell).join(","); }).join("\r\n"); downloadBlob(filename, "\uFEFF" + csv, "text/csv;charset=utf-8;"); toast(message); } function exportInventoryCsv() { var rows = [["Product ID", "Product", "Category", "Description", "Unit Price", "Stock", "Stock Value", "Has Sizes", "Stock Status"]]; state.products.slice().sort(function (a, b) { return a.name.localeCompare(b.name); }).forEach(function (p) { rows.push([p.id, p.name, p.category, p.desc || "", money(p.price), p.stock, money(p.price * p.stock), p.sized ? "Yes" : "No", p.stock < LOW_STOCK ? "LOW STOCK" : "OK"]); }); exportTableCsv(rows, "ridgeview-inventory-" + new Date().toISOString().slice(0, 10) + ".csv", "Inventory CSV exported."); } function exportStudentsCsv() { var rows = [["Student ID", "Student Name", "Room", "Account Balance"]]; Object.keys(state.students).map(function (id) { return state.students[id]; }).sort(function (a, b) { return a.name.localeCompare(b.name); }).forEach(function (student) { rows.push([student.id, student.name, student.room, money(student.balance)]); }); exportTableCsv(rows, "ridgeview-student-balances-" + new Date().toISOString().slice(0, 10) + ".csv", "Student balance CSV exported. Keep this file private."); } /* ---------- Portable backup and restore ---------- */ function downloadBlob(filename, contents, type) { var blob = new Blob([contents], { type: type }); var url = URL.createObjectURL(blob); var link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); } function exportBackup() { var backup = { format: "ridgeview-student-store-backup", version: 1, exportedAt: new Date().toISOString(), products: state.products, students: state.students, sales: state.sales, nextSale: state.nextSale, nextProduct: state.nextProduct, selectedProductId: state.selectedProductId }; downloadBlob("ridgeview-store-backup-" + new Date().toISOString().slice(0, 10) + ".json", JSON.stringify(backup, null, 2), "application/json;charset=utf-8"); toast("Backup downloaded. Store it somewhere private."); } function validateBackup(data) { if (!data || typeof data !== "object" || data.format !== "ridgeview-student-store-backup" || data.version !== 1) return "This file is not a supported Ridgeview store backup."; if (!Array.isArray(data.products) || !data.students || typeof data.students !== "object" || Array.isArray(data.students) || !Array.isArray(data.sales)) return "Backup is missing required store data."; var productIds = {}; for (var i = 0; i < data.products.length; i++) { var p = data.products[i]; if (!p || typeof p.id !== "string" || !p.id || productIds[p.id] || typeof p.name !== "string" || !Number.isInteger(p.price) || p.price < 1 || !Number.isInteger(p.stock) || p.stock < 0) return "Backup contains an invalid product record."; productIds[p.id] = true; } var studentIds = Object.keys(data.students); for (var j = 0; j < studentIds.length; j++) { var st = data.students[studentIds[j]]; if (!st || String(st.id) !== studentIds[j] || typeof st.name !== "string" || !Number.isInteger(st.balance) || st.balance < 0) return "Backup contains an invalid student account."; } for (var k = 0; k < data.sales.length; k++) { var sale = data.sales[k]; if (!sale || !Array.isArray(sale.lines) || !Number.isFinite(Number(sale.total)) || !Number.isFinite(Number(new Date(sale.time).getTime()))) return "Backup contains an invalid sale record."; } return ""; } function importBackup(file) { if (!file) return; var reader = new FileReader(); reader.onload = function () { var imported; try { imported = JSON.parse(String(reader.result)); } catch (error) { toast("That file is not valid JSON.", "warn"); return; } var problem = validateBackup(imported); if (problem) { toast(problem, "warn"); return; } if (!window.confirm("Restore this backup? It will replace the products, student balances, and sales currently saved in this browser.")) return; state.products = imported.products; state.students = imported.students; state.sales = imported.sales.map(function (sale) { sale.time = new Date(sale.time); return sale; }); state.nextSale = Number.isInteger(imported.nextSale) && imported.nextSale > 0 ? imported.nextSale : state.sales.length + 1; state.nextProduct = Number.isInteger(imported.nextProduct) && imported.nextProduct > 0 ? imported.nextProduct : state.products.length + 1; state.selectedProductId = findProduct(imported.selectedProductId) ? imported.selectedProductId : (state.products[0] ? state.products[0].id : null); state.cart = []; state.activeStudentId = null; $("student-id").value = ""; registerNotice(null); renderAll(); toast("Backup restored successfully."); }; reader.onerror = function () { toast("Could not read that backup file.", "warn"); }; reader.readAsText(file); } /* ---------- Render all ---------- */ /* ---------- Local store assistant ---------- */ function storeAssistantAnswer(rawQuestion) { var q = String(rawQuestion || "").trim().toLowerCase(); var activeSales = state.sales.filter(function (sale) { return !sale.voided; }); var now = new Date(); var todaysSales = activeSales.filter(function (sale) { var d = sale.time instanceof Date ? sale.time : new Date(sale.time); return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); }); var todayRevenue = todaysSales.reduce(function (sum, sale) { return sum + (Number(sale.total) || 0); }, 0); var allRevenue = activeSales.reduce(function (sum, sale) { return sum + (Number(sale.total) || 0); }, 0); var lowProducts = state.products.filter(function (product) { return Number(product.stock) < LOW_STOCK; }).sort(function (a, b) { return a.stock - b.stock; }); var itemSales = {}; activeSales.forEach(function (sale) { (sale.lines || []).forEach(function (line) { var key = line.productId || line.name; if (!itemSales[key]) itemSales[key] = { name: line.name || (findProduct(key) || {}).name || "Unknown item", qty: 0, revenue: 0 }; itemSales[key].qty += Number(line.qty) || 0; itemSales[key].revenue += (Number(line.price) || 0) * (Number(line.qty) || 0); }); }); var bestSellers = Object.keys(itemSales).map(function (key) { return itemSales[key]; }).sort(function (a, b) { return b.qty - a.qty; }); if (/^(hi|hello|hey|good morning|good afternoon|good evening)[!. ]*$/.test(q)) { return "Hi! I'm Ridgeview AI. I can help explain topics, brainstorm, write, work through math or coding questions, and answer questions about store inventory, sales, and classroom deliveries. What would you like to know?"; } if (/\b(what products do we have|show (me )?(the )?(catalog|products)|list products|product list)\b/.test(q)) { if (!state.products.length) return "There are no products in the catalog yet. Add products in the Products tab first."; return "Current product catalog (" + state.products.length + " items):\n" + state.products.slice(0, 30).map(function (p) { return "• " + p.name + " — " + money(Number(p.price) || 0) + ", " + (Number(p.stock) || 0) + " in stock"; }).join("\n") + (state.products.length > 30 ? "\n…and " + (state.products.length - 30) + " more." : ""); } if (!q || /\b(help|what can you do|suggestions|summari[sz]e|store summary)\b/.test(q)) { return "I can help with store data using these questions:\n• What should I restock?\n• What are the best sellers?\n• How are sales doing today?\n• Find [product name]\n\nQuick snapshot: " + state.products.length + " products, " + lowProducts.length + " low-stock items, " + activeSales.length + " completed sales, and " + money(allRevenue) + " in recorded sales revenue."; } if (/\b(restock|low stock|low-stock|stock out|out of stock|inventory)\b/.test(q)) { if (!lowProducts.length) return "No products are currently below the low-stock threshold of " + LOW_STOCK + " units. Inventory looks healthy based on that threshold."; return "Restock watch (threshold: fewer than " + LOW_STOCK + " units):\n" + lowProducts.map(function (p) { return "• " + p.name + ": " + p.stock + " left" + (p.stock < LOW_STOCK ? " — consider ordering at least " + (LOW_STOCK - p.stock) + " to return to the threshold" : ""); }).join("\n") + "\n\nThis is a simple threshold-based suggestion, not a forecast of future demand."; } if (/\b(best[- ]sell(?:er|ers)?|popular|top product|top seller|most sold|sell the most)\b/.test(q)) { if (!bestSellers.length) return "There are no completed sales yet, so I can't identify best sellers. Once sales are recorded, ask again."; return "Best sellers by units in completed, non-voided sales:\n" + bestSellers.slice(0, 5).map(function (item, index) { return (index + 1) + ". " + item.name + " — " + item.qty + " unit" + (item.qty === 1 ? "" : "s") + " (" + money(item.revenue) + " recorded line revenue)"; }).join("\n"); } if (/\b(deliver|delivery|deliveries|classroom order)\b/.test(q)) { var deliverySales = state.sales.filter(function (sale) { return sale.delivery && sale.delivery.method === "classroom"; }); var openDeliveries = deliverySales.filter(function (sale) { return !sale.voided && (sale.delivery.status === "pending" || sale.delivery.status === "out" || !sale.delivery.status); }); var pendingDeliveries = openDeliveries.filter(function (sale) { return !sale.delivery.status || sale.delivery.status === "pending"; }); var outDeliveries = openDeliveries.filter(function (sale) { return sale.delivery.status === "out"; }); return "Classroom delivery snapshot:\n• Total delivery orders: " + deliverySales.length + "\n• To prepare: " + pendingDeliveries.length + "\n• Out for delivery: " + outDeliveries.length + "\n• Delivered: " + deliverySales.filter(function (sale) { return !sale.voided && sale.delivery.status === "delivered"; }).length + "\n\nOpen the Deliveries tab to manage the queue. This browser-based app tracks staff workflow but does not dispatch a courier or notify classrooms automatically."; } if (/\b(today|daily|sales|revenue|how are sales|performance)\b/.test(q)) { return "Sales snapshot:\n• Today: " + todaysSales.length + " completed sale" + (todaysSales.length === 1 ? "" : "s") + ", " + money(todayRevenue) + " revenue\n• All time: " + activeSales.length + " completed sale" + (activeSales.length === 1 ? "" : "s") + ", " + money(allRevenue) + " revenue\n• Units sold: " + activeSales.reduce(function (sum, sale) { return sum + (Number(sale.units) || 0); }, 0) + "\n\nVoided sales are excluded. Totals are based only on sales saved in this browser."; } if (/\b(student|students|account balance|student balance|roster)\b/.test(q)) { var studentCount = Object.keys(state.students).length; var totalBalances = Object.keys(state.students).reduce(function (sum, id) { return sum + (Number(state.students[id].balance) || 0); }, 0); return "There are " + studentCount + " student accounts with a combined current balance of " + money(totalBalances) + ". For an individual account, use the Students tab and follow your school's access rules; I won't list individual student balances in this assistant."; } var productMatches = state.products.filter(function (p) { return (p.name + " " + p.category + " " + (p.desc || "")).toLowerCase().indexOf(q.replace(/^(find|look up|search for|how much is|price of)\s+/, "")) !== -1; }); if (productMatches.length) { return "Catalog matches:\n" + productMatches.slice(0, 8).map(function (p) { return "• " + p.name + " — " + money(p.price) + ", " + p.stock + " in stock, " + p.category + (p.stock < LOW_STOCK ? " (low stock)" : ""); }).join("\n"); } return "I couldn't match that question to a store report. Try asking about restocking, best sellers, today's sales, or a product name. This local assistant uses built-in rules and store data; it is not connected to a cloud language model."; } var assistantHistory = []; function getAssistantContext() { var activeSales = state.sales.filter(function (sale) { return !sale.voided; }); var products = state.products.map(function (p) { return { name: String(p.name || "").slice(0, 100), category: String(p.category || "").slice(0, 60), price: Number(p.price) || 0, stock: Number(p.stock) || 0, description: String(p.desc || "").slice(0, 160) }; }); var itemSales = {}; activeSales.forEach(function (sale) { (sale.lines || []).forEach(function (line) { var key = String(line.productId || line.name || "item"); if (!itemSales[key]) itemSales[key] = { name: String(line.name || "Item").slice(0, 100), units: 0, revenue: 0 }; itemSales[key].units += Number(line.qty) || 0; itemSales[key].revenue += (Number(line.price) || 0) * (Number(line.qty) || 0); }); }); var now = new Date(); var todaysSales = activeSales.filter(function (sale) { var d = sale.time instanceof Date ? sale.time : new Date(sale.time); return !isNaN(d.getTime()) && d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); }); var deliverySales = state.sales.filter(function (sale) { return !sale.voided && sale.delivery && sale.delivery.method === "classroom"; }); return { currency: "USD", note: "Amounts are integer cents. Student names, student IDs, account balances, delivery notes, and other student-identifying details are excluded.", products: products, salesSummary: { completedSaleCount: activeSales.length, allTimeRevenueCents: activeSales.reduce(function (sum, sale) { return sum + (Number(sale.total) || 0); }, 0), todaysSaleCount: todaysSales.length, todaysRevenueCents: todaysSales.reduce(function (sum, sale) { return sum + (Number(sale.total) || 0); }, 0), totalUnitsSold: activeSales.reduce(function (sum, sale) { return sum + (Number(sale.units) || 0); }, 0), bestSellersByUnits: Object.keys(itemSales).map(function (key) { return itemSales[key]; }) .sort(function (a, b) { return b.units - a.units; }).slice(0, 15) }, classroomDeliverySummary: { total: deliverySales.length, toPrepare: deliverySales.filter(function (sale) { return !sale.delivery.status || sale.delivery.status === "pending"; }).length, outForDelivery: deliverySales.filter(function (sale) { return sale.delivery.status === "out"; }).length, delivered: deliverySales.filter(function (sale) { return sale.delivery.status === "delivered"; }).length } }; } function renderAssistantHistory() { $("assistant-answer").textContent = assistantHistory.map(function (entry) { return "You: " + entry.question + "\n\nChatGPT: " + entry.answer; }).join("\n\n————————————\n\n"); } async function askStoreAssistant(question) { var input = $("assistant-question"); var answerBox = $("assistant-answer"); var q = String(question || input.value || "").trim(); if (!q) { answerBox.textContent = "Type a question first, or choose one of the suggested questions."; input.focus(); return; } var askButton = $("assistant-ask"); var promptButtons = Array.prototype.slice.call(document.querySelectorAll("[data-ai-prompt]")); if (askButton) { askButton.disabled = true; askButton.textContent = "Connecting…"; } promptButtons.forEach(function (button) { button.disabled = true; }); answerBox.setAttribute("aria-busy", "true"); answerBox.textContent = "Connecting to ChatGPT…"; try { var response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: q, history: assistantHistory.slice(-8), storeContext: getAssistantContext() }) }); var payload = {}; try { payload = await response.json(); } catch (_) {} if (!response.ok) throw new Error(payload.error || ("Server request failed (" + response.status + ").")); if (!payload.answer || typeof payload.answer !== "string") throw new Error("The server returned an empty answer."); assistantHistory.push({ question: q, answer: payload.answer }); if (assistantHistory.length > 12) assistantHistory.shift(); renderAssistantHistory(); input.value = ""; } catch (error) { console.error("ChatGPT connection failed:", error); var detail = error && error.message ? error.message : "Unknown connection error."; var setupHint = window.location.protocol === "file:" ? "\n\nYou opened the HTML file directly. Close this tab, start the Ridgeview server, then open http://127.0.0.1:3000." : (detail === "Failed to fetch" ? "\n\nThe browser could not reach the local server. Make sure the terminal running `npm start` is still open and shows http://127.0.0.1:3000. Then open http://127.0.0.1:3000 in this browser tab." : "\n\nCheck the server message in the terminal. If it says the API key is not configured, set OPENAI_API_KEY in the local .env file and restart the server."); // If the server is unavailable (or this page was opened as a file), // use the built-in local store assistant instead of leaving the user stuck. if (window.location.protocol === "file:" || detail === "Failed to fetch" || detail === "NetworkError when attempting to fetch resource.") { try { var localAnswer = storeAssistantAnswer(q); assistantHistory.push({ question: q, answer: localAnswer }); if (assistantHistory.length > 12) assistantHistory.shift(); renderAssistantHistory(); input.value = ""; answerBox.textContent += "\n\n(Local assistant mode: the server is unavailable, so this answer uses built-in store rules. General ChatGPT questions need the server.)"; } catch (fallbackError) { console.error("Local assistant fallback failed:", fallbackError); answerBox.textContent = "I couldn't connect to the Ridgeview AI server. " + detail + setupHint; } } else { answerBox.textContent = "I couldn't connect to the Ridgeview AI server. " + detail + setupHint; } } finally { answerBox.removeAttribute("aria-busy"); if (askButton) { askButton.disabled = false; askButton.textContent = "Ask ChatGPT"; } promptButtons.forEach(function (button) { button.disabled = false; }); } } function renderAll() { renderCatalogSelect(); renderPreview(); renderCatalogList(); renderStudent(); renderCart(); renderProductTable(); renderInventory(); renderRoster(); renderSales(); renderDeliveries(); persistState(); if (typeof syncSelfCheckout === "function") syncSelfCheckout(); } /* ---------- Tabs ---------- */ var tabs = Array.prototype.slice.call(document.querySelectorAll('[role="tab"]')); function activateTab(tab) { tabs.forEach(function (t) { var selected = t === tab; t.setAttribute("aria-selected", selected); t.tabIndex = selected ? 0 : -1; $(t.getAttribute("aria-controls")).hidden = !selected; }); tab.focus(); } tabs.forEach(function (tab, i) { tab.addEventListener("click", function () { activateTab(tab); }); tab.addEventListener("keydown", function (e) { if (e.key === "ArrowRight") { e.preventDefault(); activateTab(tabs[(i + 1) % tabs.length]); } if (e.key === "ArrowLeft") { e.preventDefault(); activateTab(tabs[(i - 1 + tabs.length) % tabs.length]); } }); }); /* ---------- Events ---------- */ $("product-select").addEventListener("change", function (e) { selectProduct(e.target.value); }); $("catalog-search-input").addEventListener("input", renderCatalogList); $("export-backup").addEventListener("click", exportBackup); $("import-backup-trigger").addEventListener("click", function () { $("import-backup").click(); }); $("import-backup").addEventListener("change", function (e) { importBackup(e.target.files && e.target.files[0]); e.target.value = ""; }); $("catalog-list").addEventListener("click", function (e) { var btn = e.target.closest("[data-product]"); if (btn) selectProduct(btn.getAttribute("data-product")); }); $("qty-minus").addEventListener("click", function () { var input = $("qty-input"); input.value = Math.max(1, (parseInt(input.value, 10) || 1) - 1); }); $("qty-plus").addEventListener("click", function () { var input = $("qty-input"); input.value = Math.min(99, (parseInt(input.value, 10) || 0) + 1); }); $("add-to-cart").addEventListener("click", addToCart); $("student-id").addEventListener("input", function (e) { lookupStudent(e.target.value); }); $("student-id").addEventListener("keydown", function (e) { if (e.key !== "Enter" || e.isComposing || e.keyCode === 229) return; e.preventDefault(); lookupStudent(e.target.value); }); $("cart-list").addEventListener("click", function (e) { var btn = e.target.closest("[data-cart]"); if (!btn) return; var i = parseInt(btn.getAttribute("data-index"), 10); var line = state.cart[i]; if (!line) return; var action = btn.getAttribute("data-cart"); var p = findProduct(line.productId); if (action === "inc") { if (cartQtyFor(line.productId) >= p.stock) { toast("No more " + p.name + " in stock.", "warn"); return; } line.qty++; } else if (action === "dec") { line.qty--; if (line.qty <= 0) state.cart.splice(i, 1); } else if (action === "remove") { state.cart.splice(i, 1); } registerNotice(null); renderCart(); renderPreview(); syncSelfCheckout(); }); // Separate self-checkout popup. BroadcastChannel works between same-origin tabs/windows // on this Chromebook; it is not a Bluetooth connection between separate devices. var selfCheckoutChannel = ("BroadcastChannel" in window) ? new BroadcastChannel("ridgeview-self-checkout-v1") : null; function checkoutCatalogPayload() { return state.products.map(function (p) { return { id: p.id, name: p.name, category: p.category, price: p.price, stock: p.stock, sized: !!p.sized }; }); } // HTMLRunner and other embedded previews can isolate BroadcastChannel storage // between the main page and an embedded checkout. Keep a direct iframe bridge too. var selfCheckoutFrame = null; function sendCheckoutState(message) { if (selfCheckoutChannel) selfCheckoutChannel.postMessage(message); if (selfCheckoutFrame && selfCheckoutFrame.contentWindow) { selfCheckoutFrame.contentWindow.postMessage({ ridgeviewCheckout: true, payload: message }, "*"); } } function syncSelfCheckout() { sendCheckoutState({ type: "catalog", products: checkoutCatalogPayload() }); sendCheckoutState({ type: "cart-update", cart: state.cart.map(function (l) { return { productId: l.productId, qty: l.qty, size: l.size || null }; }) }); } function openSelfCheckout() { if (!("BroadcastChannel" in window) || !selfCheckoutChannel) { toast("This browser does not support the self-checkout connection. Use a current Edge or Chrome browser.", "warn"); return; } // Try a separate window first. Managed school Chromebooks may block pop-ups, // so the same checkout page can also open inside this page without a pop-up. var popup = window.open("", "ridgeviewSelfCheckout", "width=680,height=800,resizable=yes,scrollbars=yes"); var checkoutHtml = `Ridgeview Self-Checkout

      Ridgeview Self-Checkout

      Enter your student ID, choose items, review your cart, and send it to the register for staff approval.

      Student ID

      Your ID will be checked by the register before checkout.

      Waiting for products from the register…

      Your cart

      No items yet.

      Total: $0.00

      Deliverly

      Enter your room when requesting delivery. The register will check it against the room saved on your student account.

      Pickup is selected. Choose the box above to request classroom delivery.

      This window submits a cart for cashier review. It does not charge an account, reduce inventory, or finalize a sale.