Add an admin interface to edit product details for all boxes

Create a new admin page and backend endpoints to allow authorized users to modify product price, image, and description for the eight boxes.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 375ec6d3-d5af-4f82-ab81-5c60fd4a86a3
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 534b4c21-8691-4e0a-ba0c-0091bb20606a
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/147e665c-8c0d-48ec-b0ad-fdc89cd4460f/375ec6d3-d5af-4f82-ab81-5c60fd4a86a3/e238nM8
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
tornikegerantia 2026-04-15 00:27:21 +00:00
parent 171fd243de
commit 8f32ec7d16
13 changed files with 571 additions and 100 deletions

39
admin.html Normal file
View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Box Editor</title>
<link href="css/style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div class="admin-page">
<div class="admin-panel">
<div class="admin-header">
<div>
<h1>Administrator box editor</h1>
<p>Change the price, picture, and description for all 8 boxes.</p>
</div>
<a class="auth-tab admin-home-link" href="index.html">Back to home</a>
</div>
<div class="admin-code-row">
<label for="admin-code">Admin code</label>
<input class="auth-input" id="admin-code" type="password" placeholder="Enter admin code" autocomplete="off" />
<button class="auth-submit admin-small-button" id="load-boxes" type="button">Load boxes</button>
</div>
<p id="admin-message" class="auth-note">Enter the admin code to edit box details.</p>
<form id="admin-form" class="admin-grid" style="display:none;"></form>
<div class="admin-actions" id="admin-actions" style="display:none;">
<button class="auth-submit" id="save-boxes" type="button">Save all box changes</button>
<a class="auth-tab admin-home-link" href="order.html">View order page</a>
</div>
</div>
</div>
<script src="js/admin.js"></script>
</body>
</html>

View File

@ -31,6 +31,8 @@ exports.getAllProducts = async (req, res) => {
products.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
} else if (sortBy === 'rating') {
products.sort((a, b) => b.rating - a.rating);
} else if (products.length && products.every(product => String(product.sku || '').startsWith('BOX-'))) {
products.sort((a, b) => String(a.sku).localeCompare(String(b.sku)));
} else {
products.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}

View File

@ -1,117 +1,146 @@
[
{
"_id": "1775465493816",
"name": "Classic Burger",
"description": "Juicy beef patty with lettuce, tomato, onion, and our special sauce",
"price": 12.99,
"salePrice": 9.99,
"category": "Food",
"image": "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=300",
"images": [
"https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=300"
],
"stock": 50,
"sku": "BURGER-001",
"_id": "box-1",
"name": "box1",
"description": "Description for box1",
"price": 9.2,
"salePrice": null,
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "BOX-001",
"rating": 0,
"reviewCount": 0,
"tags": [
"popular",
"beef"
],
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.816Z",
"updatedAt": "2026-04-06T08:51:33.816Z"
"updatedAt": "2026-04-15T00:00:00.000Z"
},
{
"_id": "1775465493818",
"name": "Margherita Pizza",
"description": "Fresh mozzarella, tomato sauce, and basil on our signature crust",
"price": 15.99,
"_id": "box-2",
"name": "box2",
"description": "Description for box2",
"price": 10,
"salePrice": null,
"category": "Food",
"image": "https://images.unsplash.com/photo-1513104890138-7c749659a591?w=300",
"images": [
"https://images.unsplash.com/photo-1513104890138-7c749659a591?w=300"
],
"stock": 30,
"sku": "PIZZA-001",
"rating": 0,
"reviewCount": 0,
"tags": [
"vegetarian",
"classic"
],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.818Z",
"updatedAt": "2026-04-06T08:51:33.818Z"
},
{
"_id": "1775465493818",
"name": "Caesar Salad",
"description": "Crisp romaine lettuce with parmesan cheese, croutons, and Caesar dressing",
"price": 8.99,
"salePrice": null,
"category": "Salads",
"image": "https://images.unsplash.com/photo-1550304943-4f24f54ddde9?w=300",
"images": [
"https://images.unsplash.com/photo-1550304943-4f24f54ddde9?w=300"
],
"stock": 40,
"sku": "SALAD-001",
"rating": 0,
"reviewCount": 0,
"tags": [
"healthy",
"vegetarian"
],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.818Z",
"updatedAt": "2026-04-06T08:51:33.818Z"
},
{
"_id": "1775465493819",
"name": "French Fries",
"description": "Golden crispy fries served with ketchup",
"price": 4.99,
"salePrice": null,
"category": "Sides",
"image": "https://images.unsplash.com/photo-1573080496219-bb080dd4f877?w=300",
"images": [
"https://images.unsplash.com/photo-1573080496219-bb080dd4f877?w=300"
],
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "FRIES-001",
"sku": "BOX-002",
"rating": 0,
"reviewCount": 0,
"tags": [
"side",
"crispy"
],
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.817Z",
"updatedAt": "2026-04-15T00:00:00.000Z"
},
{
"_id": "box-3",
"name": "box3",
"description": "Description for box3",
"price": 8.5,
"salePrice": null,
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "BOX-003",
"rating": 0,
"reviewCount": 0,
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.818Z",
"updatedAt": "2026-04-15T00:00:00.000Z"
},
{
"_id": "box-4",
"name": "box4",
"description": "Description for box4",
"price": 11,
"salePrice": null,
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "BOX-004",
"rating": 0,
"reviewCount": 0,
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.819Z",
"updatedAt": "2026-04-06T08:51:33.819Z"
"updatedAt": "2026-04-15T00:00:00.000Z"
},
{
"_id": "1775465493820",
"name": "Chocolate Milkshake",
"description": "Rich and creamy chocolate milkshake topped with whipped cream",
"_id": "box-5",
"name": "box5",
"description": "Description for box5",
"price": 6.99,
"salePrice": null,
"category": "Drinks",
"image": "https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=300",
"images": [
"https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=300"
],
"stock": 25,
"sku": "SHAKE-001",
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "BOX-005",
"rating": 0,
"reviewCount": 0,
"tags": [
"sweet",
"cold"
],
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.820Z",
"updatedAt": "2026-04-06T08:51:33.820Z"
"updatedAt": "2026-04-15T00:00:00.000Z"
},
{
"_id": "box-6",
"name": "box6",
"description": "Description for box6",
"price": 4.99,
"salePrice": null,
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "BOX-006",
"rating": 0,
"reviewCount": 0,
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.821Z",
"updatedAt": "2026-04-15T00:00:00.000Z"
},
{
"_id": "box-7",
"name": "box7",
"description": "Description for box7",
"price": 5.99,
"salePrice": null,
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "BOX-007",
"rating": 0,
"reviewCount": 0,
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.822Z",
"updatedAt": "2026-04-15T00:00:00.000Z"
},
{
"_id": "box-8",
"name": "box8",
"description": "Description for box8",
"price": 7.99,
"salePrice": null,
"category": "Box",
"image": "",
"images": [],
"stock": 100,
"sku": "BOX-008",
"rating": 0,
"reviewCount": 0,
"tags": [],
"isActive": true,
"createdAt": "2026-04-06T08:51:33.823Z",
"updatedAt": "2026-04-15T00:00:00.000Z"
}
]

View File

@ -1,6 +1,7 @@
[
{
"_id": "1775634815531",
"id": "1775634815531",
"firstName": "Tornike",
"lastName": "Gerantia",
"email": "tornikegerantia@gmail.com",
@ -10,8 +11,8 @@
"role": "user",
"isActive": true,
"profileImage": null,
"lastLogin": null,
"lastLogin": "2026-04-15T00:22:28.632Z",
"createdAt": "2026-04-08T07:53:35.531Z",
"updatedAt": "2026-04-08T07:53:35.613Z"
"updatedAt": "2026-04-15T00:22:28.632Z"
}
]

View File

@ -21,12 +21,12 @@ class Product {
this._id = data._id || Date.now().toString();
this.name = data.name;
this.description = data.description;
this.price = data.price;
this.price = Number(data.price || 0);
this.salePrice = data.salePrice || null;
this.category = data.category;
this.image = data.image;
this.images = data.images || [data.image];
this.stock = data.stock;
this.stock = Number(data.stock || 0);
this.sku = data.sku;
this.rating = data.rating || 0;
this.reviewCount = data.reviewCount || 0;
@ -158,6 +158,17 @@ Product.findByIdAndUpdate = async (id, updateData) => {
}
};
Product.replaceAll = async (productsData) => {
try {
const products = productsData.map(data => new Product(data));
fs.writeFileSync(PRODUCTS_FILE, JSON.stringify(products, null, 2));
return products;
} catch (error) {
console.error('Error replacing products:', error);
throw error;
}
};
Product.findByIdAndDelete = async (id) => {
try {
const products = await Product.find();

View File

@ -0,0 +1,71 @@
const express = require('express');
const Product = require('../models/productModel');
const router = express.Router();
const ADMIN_CODE = process.env.ADMIN_CODE || '1234';
function requireAdminCode(req, res, next) {
const code = req.headers['x-admin-code'] || req.body.adminCode || req.query.adminCode;
if (code !== ADMIN_CODE) {
return res.status(401).json({ success: false, message: 'Invalid admin code' });
}
next();
}
function normalizeBox(product, index) {
const boxNumber = index + 1;
return {
_id: product._id || `box-${boxNumber}`,
name: `box${boxNumber}`,
description: product.description || '',
price: Number(product.price || 0),
salePrice: null,
category: 'Box',
image: product.image || '',
images: product.image ? [product.image] : [],
stock: Number(product.stock || 100),
sku: `BOX-${String(boxNumber).padStart(3, '0')}`,
rating: Number(product.rating || 0),
reviewCount: Number(product.reviewCount || 0),
tags: Array.isArray(product.tags) ? product.tags : [],
isActive: true,
createdAt: product.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
router.get('/boxes', async (req, res) => {
try {
const products = await Product.find({ isActive: true });
const boxes = Array.from({ length: 8 }, (_, index) => {
const sku = `BOX-${String(index + 1).padStart(3, '0')}`;
const product = products.find(item => item.sku === sku) || products[index] || {};
return normalizeBox(product, index);
});
res.json({ success: true, boxes });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
router.put('/boxes', requireAdminCode, async (req, res) => {
try {
const boxes = Array.isArray(req.body.boxes) ? req.body.boxes : [];
if (boxes.length !== 8) {
return res.status(400).json({ success: false, message: 'Exactly 8 boxes are required' });
}
const existingProducts = await Product.find();
const nonBoxProducts = existingProducts.filter(product => !String(product.sku || '').startsWith('BOX-'));
const normalizedBoxes = boxes.map((box, index) => normalizeBox(box, index));
await Product.replaceAll([...normalizedBoxes, ...nonBoxProducts]);
res.json({ success: true, message: 'Boxes saved successfully', boxes: normalizedBoxes });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
module.exports = router;

View File

@ -75,6 +75,7 @@ app.get('/api/health', (req, res) => {
app.use('/api/auth', require('./routes/authRoutes'));
app.use('/api/users', require('./routes/userRoutes'));
app.use('/api/products', require('./routes/productRoutes'));
app.use('/api/admin', require('./routes/adminRoutes'));
app.use('/api/cart', require('./routes/cartRoutes'));
app.use('/api/orders', require('./routes/orderRoutes'));

View File

@ -65,6 +65,9 @@
<a class="nav-link w-nav-link" href="index.html#call-store">
Contact
</a>
<a class="nav-link w-nav-link" href="admin.html">
Admin
</a>
</nav>
<a class="auth-link" href="login.html">
Login / Register

View File

@ -391,6 +391,138 @@ html {
font-size: 0.95rem;
}
.admin-page {
min-height: 100vh;
padding: 3rem 1.5rem;
background: linear-gradient(180deg, #f9ffeb 0%, #f0ffdb 55%, #ffffff 100%);
}
.admin-panel {
width: min(100%, 1180px);
margin: 0 auto;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(34, 84, 0, 0.12);
border-radius: 28px;
box-shadow: 0 36px 80px rgba(38, 88, 6, 0.12);
padding: 2rem;
}
.admin-header,
.admin-code-row,
.admin-actions {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.admin-header h1 {
margin: 0 0 0.5rem;
color: #1d4700;
}
.admin-header p {
margin: 0;
color: #4f6f31;
}
.admin-code-row {
margin-top: 1.5rem;
justify-content: flex-start;
}
.admin-code-row label {
color: #4b640d;
font-weight: 700;
}
.admin-code-row .auth-input {
max-width: 260px;
}
.admin-small-button {
width: auto;
}
.admin-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.admin-box-card {
border: 1px solid rgba(34, 84, 0, 0.12);
border-radius: 22px;
padding: 1rem;
display: grid;
gap: 1rem;
}
.admin-box-card label {
display: grid;
gap: 0.5rem;
color: #4b640d;
font-weight: 700;
}
.admin-box-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.admin-box-title h2 {
margin: 0;
color: #1d4700;
}
.admin-box-title span {
color: #5b742e;
}
.admin-image-preview,
.admin-image-placeholder {
width: 100%;
height: 180px;
border-radius: 18px;
}
.admin-image-preview {
object-fit: cover;
}
.admin-image-placeholder {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed rgba(34, 84, 0, 0.24);
color: #5b742e;
}
.admin-actions {
margin-top: 1.5rem;
justify-content: flex-start;
}
.admin-home-link {
text-align: center;
text-decoration: none;
}
.box-card-image {
width: 92px;
height: 92px;
object-fit: cover;
border-radius: 14px;
margin-right: 24px;
}
.box-card-description {
margin-top: 8px;
}
body {
color: #333;
background-color: #fff;

View File

@ -70,6 +70,9 @@
<a class="nav-link w-nav-link" href="#call-store">
Contact
</a>
<a class="nav-link w-nav-link" href="admin.html">
Admin
</a>
</nav>
<a class="auth-link" href="login.html">
Login / Register

138
js/admin.js Normal file
View File

@ -0,0 +1,138 @@
const ADMIN_CODE_KEY = 'adminBoxCode';
const adminCodeInput = document.getElementById('admin-code');
const loadButton = document.getElementById('load-boxes');
const saveButton = document.getElementById('save-boxes');
const adminForm = document.getElementById('admin-form');
const adminActions = document.getElementById('admin-actions');
const adminMessage = document.getElementById('admin-message');
adminCodeInput.value = localStorage.getItem(ADMIN_CODE_KEY) || '';
function setAdminMessage(message) {
adminMessage.textContent = message;
}
function apiBase() {
return `${window.location.protocol}//${window.location.host}/api`;
}
function boxCard(box, index) {
const boxNumber = index + 1;
const imageValue = box.image || '';
const imagePreview = imageValue
? `<img class="admin-image-preview" src="${imageValue}" alt="box${boxNumber} preview" />`
: '<div class="admin-image-placeholder">No picture</div>';
return `
<section class="admin-box-card" data-index="${index}" data-id="${box._id || `box-${boxNumber}`}">
<div class="admin-box-title">
<h2>box${boxNumber}</h2>
<span>${box.sku || `BOX-${String(boxNumber).padStart(3, '0')}`}</span>
</div>
${imagePreview}
<label>
Price
<input class="auth-input admin-price" type="number" min="0" step="0.01" value="${Number(box.price || 0).toFixed(2)}" />
</label>
<label>
Picture URL
<input class="auth-input admin-image" type="url" placeholder="https://..." value="${imageValue}" />
</label>
<label>
Description
<textarea class="auth-input admin-description" rows="4" placeholder="Describe this box">${box.description || ''}</textarea>
</label>
</section>
`;
}
function renderBoxes(boxes) {
adminForm.innerHTML = boxes.map(boxCard).join('');
adminForm.style.display = 'grid';
adminActions.style.display = 'flex';
adminForm.querySelectorAll('.admin-image').forEach(input => {
input.addEventListener('input', () => {
const card = input.closest('.admin-box-card');
const oldPreview = card.querySelector('.admin-image-preview, .admin-image-placeholder');
const value = input.value.trim();
const replacement = document.createElement(value ? 'img' : 'div');
if (value) {
replacement.className = 'admin-image-preview';
replacement.src = value;
replacement.alt = `${card.querySelector('h2').textContent} preview`;
} else {
replacement.className = 'admin-image-placeholder';
replacement.textContent = 'No picture';
}
oldPreview.replaceWith(replacement);
});
});
}
async function loadBoxes() {
const code = adminCodeInput.value.trim();
localStorage.setItem(ADMIN_CODE_KEY, code);
setAdminMessage('Loading boxes...');
const response = await fetch(`${apiBase()}/admin/boxes`);
const data = await response.json();
if (!data.success) {
setAdminMessage(data.message || 'Could not load boxes.');
return;
}
renderBoxes(data.boxes);
setAdminMessage('Boxes loaded. Make changes, then save.');
}
function collectBoxes() {
return Array.from(adminForm.querySelectorAll('.admin-box-card')).map((card, index) => {
const boxNumber = index + 1;
return {
_id: card.dataset.id || `box-${boxNumber}`,
name: `box${boxNumber}`,
sku: `BOX-${String(boxNumber).padStart(3, '0')}`,
price: Number(card.querySelector('.admin-price').value || 0),
image: card.querySelector('.admin-image').value.trim(),
description: card.querySelector('.admin-description').value.trim(),
stock: 100,
isActive: true,
};
});
}
async function saveBoxes() {
const code = adminCodeInput.value.trim();
if (!code) {
setAdminMessage('Enter the admin code before saving.');
return;
}
localStorage.setItem(ADMIN_CODE_KEY, code);
setAdminMessage('Saving boxes...');
const response = await fetch(`${apiBase()}/admin/boxes`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-admin-code': code,
},
body: JSON.stringify({ boxes: collectBoxes() }),
});
const data = await response.json();
if (data.success) {
renderBoxes(data.boxes);
setAdminMessage('Saved. The order page and home menu now use these box details.');
} else {
setAdminMessage(data.message || 'Save failed.');
}
}
loadButton.addEventListener('click', loadBoxes);
saveButton.addEventListener('click', saveBoxes);
if (adminCodeInput.value) {
loadBoxes();
}

View File

@ -11,13 +11,24 @@
}
});
function simplifyMenuBoxes() {
async function getBoxes() {
try {
const response = await fetch('/api/admin/boxes');
const data = await response.json();
return data && data.success && Array.isArray(data.boxes) ? data.boxes : [];
} catch (error) {
return [];
}
}
async function simplifyMenuBoxes() {
const path = window.location.pathname;
const isMainPage = path === '/' || path.endsWith('/index.html');
const isOrderPage = path.endsWith('/order.html');
if (!isMainPage && !isOrderPage) return;
const limit = isOrderPage ? 8 : 4;
const boxes = await getBoxes();
const tabs = document.querySelector('.w-tabs');
if (!tabs) return;
@ -41,12 +52,29 @@
firstList.innerHTML = '';
selectedItems.forEach((item, index) => {
item.querySelectorAll('.food-image-square, .paragraph').forEach(element => element.remove());
const box = boxes[index] || {};
const imageWrap = item.querySelector('.food-image-square');
const image = item.querySelector('.food-image');
if (imageWrap && image && box.image) {
image.src = box.image;
image.alt = `box${index + 1}`;
imageWrap.removeAttribute('href');
} else if (imageWrap) {
imageWrap.remove();
}
const paragraph = item.querySelector('.paragraph');
if (paragraph && box.description) {
paragraph.textContent = box.description;
} else if (paragraph) {
paragraph.remove();
}
item.querySelectorAll('.quantity').forEach(element => {
element.type = 'hidden';
});
const title = item.querySelector('h6');
if (title) title.textContent = `box${index + 1}`;
const price = item.querySelector('.price');
if (price) price.textContent = `$${Number(box.price || 0).toFixed(2)} USD`;
item.querySelectorAll('a[href]').forEach(link => link.removeAttribute('href'));
firstList.appendChild(item);
});

View File

@ -68,6 +68,9 @@
<a class="nav-link w-nav-link" href="index.html#call-store">
Contact
</a>
<a class="nav-link w-nav-link" href="admin.html">
Admin
</a>
</nav>
<a class="auth-link" href="login.html">
Login / Register
@ -1370,19 +1373,29 @@
return {
_id: product._id || `box-${index + 1}`,
name: `box${index + 1}`,
price: Number(product.price || 0)
price: Number(product.price || 0),
image: product.image || '',
description: product.description || ''
};
});
boxes.forEach(product => {
const imageHTML = product.image
? `<img class="box-card-image" src="${product.image}" alt="${product.name}" />`
: '';
const descriptionHTML = product.description
? `<p class="paragraph box-card-description">${product.description}</p>`
: '';
const productHTML = `
<div class="menu-item w-dyn-item w-col w-col-6" role="listitem">
<div class="food-card">
${imageHTML}
<div class="food-card-content">
<div class="food-title-wrap w-inline-block">
<h6>${product.name}</h6>
<div class="price">$${product.price.toFixed(2)} USD</div>
</div>
${descriptionHTML}
<div class="add-to-cart">
<input class="quantity" type="hidden" min="1" value="1" id="quantity-${product._id}" />
<button class="order-button w-button add-to-cart-btn"