diff --git a/Launchpad/backend/package.json b/Launchpad/backend/package.json
index b739052..201184e 100644
--- a/Launchpad/backend/package.json
+++ b/Launchpad/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "shuttle-platform-backend",
- "version": "4.0.3",
+ "version": "4.1.0",
"private": true,
"scripts": {
"start": "node src/index.js",
diff --git a/Launchpad/backend/src/checkout.js b/Launchpad/backend/src/checkout.js
index 4d2ca73..2ef0452 100644
--- a/Launchpad/backend/src/checkout.js
+++ b/Launchpad/backend/src/checkout.js
@@ -1,8 +1,11 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
+const { checkShopPermission } = require('./users');
const router = express.Router();
+const VALID_SLUG = /^[a-zA-Z0-9_-]+$/;
+
const SHOPS_DIR = path.join(__dirname, '..', 'shops');
function getSchemaPath(slug) {
@@ -66,6 +69,7 @@ const DEFAULT_SCHEMA = {
// GET /api/shops/:slug/checkout/schema
router.get('/shops/:slug/checkout/schema', (req, res) => {
const { slug } = req.params;
+ if (!VALID_SLUG.test(slug)) return res.status(400).json({ error: 'Invalid slug' });
const schemaPath = getSchemaPath(slug);
try {
if (fs.existsSync(schemaPath)) {
@@ -82,6 +86,10 @@ router.get('/shops/:slug/checkout/schema', (req, res) => {
// PUT /api/shops/:slug/checkout/schema
router.put('/shops/:slug/checkout/schema', (req, res) => {
const { slug } = req.params;
+ if (!VALID_SLUG.test(slug)) return res.status(400).json({ error: 'Invalid slug' });
+ if (!checkShopPermission(req, 'can_edit_ui')) {
+ return res.status(403).json({ error: 'Insufficient permissions' });
+ }
const schema = req.body;
if (!schema || !Array.isArray(schema.sections)) {
return res.status(400).json({ error: 'Invalid schema: must have sections array' });
diff --git a/Launchpad/backend/src/files.js b/Launchpad/backend/src/files.js
index ef312c8..8a98663 100644
--- a/Launchpad/backend/src/files.js
+++ b/Launchpad/backend/src/files.js
@@ -195,6 +195,46 @@ router.delete('/:slug/files', (req, res) => {
res.json({ message: 'Deleted', path: relPath });
});
+// POST /api/shops/:slug/files/rename body: { path, newPath }
+// Renames or moves a file/directory within the shop. Covers:
+// renaming collections, moving items between collections, renaming photos.
+router.post('/:slug/files/rename', (req, res) => {
+ if (!checkShopPermission(req, 'can_edit_ui')) {
+ return res.status(403).json({ error: 'Insufficient permissions' });
+ }
+ const { slug } = req.params;
+ const { path: relPath, newPath } = req.body || {};
+ if (!relPath || !newPath) {
+ return res.status(400).json({ error: 'path and newPath are required' });
+ }
+ if (relPath === '.' || newPath === '.') {
+ return res.status(400).json({ error: 'Cannot rename root directory' });
+ }
+
+ const resolvedSrc = safeShopPath(slug, relPath);
+ const resolvedDest = safeShopPath(slug, newPath);
+ if (!resolvedSrc || !resolvedDest) return res.status(400).json({ error: 'Invalid path' });
+ if (resolvedSrc === resolvedDest) return res.status(400).json({ error: 'Source and destination are the same' });
+
+ if (!fs.existsSync(resolvedSrc)) return res.status(404).json({ error: 'Source not found' });
+ if (fs.existsSync(resolvedDest)) return res.status(409).json({ error: 'Destination already exists' });
+
+ // Prevent moving a directory into itself
+ if (fs.statSync(resolvedSrc).isDirectory() && (resolvedDest + path.sep).startsWith(resolvedSrc + path.sep)) {
+ return res.status(400).json({ error: 'Cannot move a directory into itself' });
+ }
+
+ try {
+ fs.mkdirSync(path.dirname(resolvedDest), { recursive: true });
+ fs.renameSync(resolvedSrc, resolvedDest);
+ } catch (err) {
+ return res.status(500).json({ error: `Rename failed: ${err.message}` });
+ }
+
+ req.app.locals.auditLog?.('file_renamed', { req, details: { slug, path: relPath, newPath } });
+ res.json({ message: 'Renamed', path: relPath, newPath });
+});
+
// POST /api/shops/:slug/files/upload-zip?path=DATABASE
const uploadZip = multer({
storage: multer.diskStorage({
diff --git a/Launchpad/backend/src/orders-webhook.js.bak b/Launchpad/backend/src/orders-webhook.js.bak
deleted file mode 100644
index 8beb7ba..0000000
--- a/Launchpad/backend/src/orders-webhook.js.bak
+++ /dev/null
@@ -1,334 +0,0 @@
-const express = require('express');
-const crypto = require('crypto');
-const fs = require('fs');
-const path = require('path');
-const Database = require('better-sqlite3');
-const { parse } = require('csv-parse/sync');
-
-const router = express.Router();
-const SHOPS_DIR = path.join(__dirname, '..', 'shops');
-const DATA_DIR = path.join(__dirname, '..', 'data');
-const DB_PATH = path.join(DATA_DIR, 'shops.db');
-
-const CANCEL_WINDOW_MS = 2 * 60 * 60 * 1000; // 2 hours
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function getSecret() {
- return process.env.SESSION_SECRET || process.env.MAILGUN_API_KEY || 'launchpad-cancel-fallback';
-}
-
-function generateCancelToken(orderId, slug) {
- return crypto.createHmac('sha256', getSecret()).update(`${orderId}:${slug}:cancel`).digest('hex').slice(0, 32);
-}
-
-function verifyCancelToken(orderId, slug, token) {
- return token && generateCancelToken(orderId, slug) === token;
-}
-
-function shopExists(slug) {
- try {
- const db = new Database(DB_PATH, { readonly: true });
- const shop = db.prepare('SELECT id FROM shops WHERE slug = ?').get(slug);
- db.close();
- if (!shop) return false;
- } catch { /* proceed */ }
- return fs.existsSync(path.join(SHOPS_DIR, slug));
-}
-
-function findCsvPath(slug) {
- const candidates = [
- path.join(SHOPS_DIR, slug, 'DATABASE', 'Orders', 'orders.csv'),
- path.join(SHOPS_DIR, slug, 'DATABASE', 'Orders', 'Orders.csv'),
- path.join(SHOPS_DIR, slug, 'DATABASE', 'orders', 'orders.csv'),
- path.join(SHOPS_DIR, slug, 'orders', 'orders.csv'),
- ];
- return candidates.find(p => fs.existsSync(p)) || null;
-}
-
-function escapeCSVField(value) {
- const str = String(value ?? '');
- if (str.includes(',') || str.includes('"') || str.includes('\n')) {
- return `"${str.replace(/"/g, '""')}"`;
- }
- return str;
-}
-
-function slugify(text) {
- return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
-}
-
-function getOrderById(csvPath, orderId) {
- if (!csvPath || !fs.existsSync(csvPath)) return null;
- const content = fs.readFileSync(csvPath, 'utf8');
- const records = parse(content, { columns: true, skip_empty_lines: true, trim: true });
- return records.find(r =>
- (r['Order ID'] || r['order_id'] || r['Order #'] || r['Order Number'] || r['ID'] || r['id']) === orderId
- ) || null;
-}
-
-function isWithinCancelWindow(row) {
- const dateVal = row['Date'] || row['date'] || row['Order Date'] || row['Timestamp'] || '';
- if (!dateVal) return false;
- const orderTime = new Date(dateVal).getTime();
- if (isNaN(orderTime)) return false;
- return (Date.now() - orderTime) <= CANCEL_WINDOW_MS;
-}
-
-function removeOrderFromCsv(csvPath, orderId) {
- if (!csvPath || !fs.existsSync(csvPath)) return false;
- const content = fs.readFileSync(csvPath, 'utf8');
- const records = parse(content, { columns: true, skip_empty_lines: true, trim: true });
- const before = records.length;
- const filtered = records.filter(r => {
- const id = r['Order ID'] || r['order_id'] || r['Order #'] || r['Order Number'] || r['ID'] || r['id'];
- return id !== orderId;
- });
- if (filtered.length === before) return false;
-
- const columns = Object.keys(records[0]);
- const headerLine = columns.map(escapeCSVField).join(',');
- const dataLines = filtered.map(r => columns.map(c => escapeCSVField(r[c])).join(','));
- fs.writeFileSync(csvPath, [headerLine, ...dataLines].join('\n') + '\n');
- return true;
-}
-
-// ---------------------------------------------------------------------------
-// POST /api/shops/:slug/orders/notify
-// Unauthenticated — called by Shuttle containers after writing an order to CSV.
-// ---------------------------------------------------------------------------
-router.post('/:slug/orders/notify', (req, res) => {
- const { slug } = req.params;
- const { orderData } = req.body;
-
- if (!orderData || typeof orderData !== 'object') {
- return res.status(400).json({ error: 'orderData is required' });
- }
-
- if (!shopExists(slug)) {
- return res.status(404).json({ error: 'Shop not found' });
- }
-
- // Fire-and-forget email
- const { sendOrderConfirmation } = require('./email');
- sendOrderConfirmation(orderData, slug).catch(err => {
- console.error(`[notify] Email failed for ${slug}: ${err.message}`);
- });
-
- res.json({ message: 'Notification queued' });
-});
-
-// ---------------------------------------------------------------------------
-// GET /api/shops/:slug/orders/:orderId/cancel?token=xxx
-// Public page — renders a confirmation page for order cancellation.
-// ---------------------------------------------------------------------------
-router.get('/:slug/orders/:orderId/cancel', (req, res) => {
- const { slug, orderId } = req.params;
- const { token } = req.query;
-
- if (!verifyCancelToken(orderId, slug, token)) {
- return res.status(403).send(cancelPage({ error: 'Invalid or expired cancellation link.' }));
- }
-
- if (!shopExists(slug)) {
- return res.status(404).send(cancelPage({ error: 'Shop not found.' }));
- }
-
- const csvPath = findCsvPath(slug);
- const row = getOrderById(csvPath, orderId);
-
- if (!row) {
- return res.send(cancelPage({ error: 'Order not found. It may have already been cancelled.' }));
- }
-
- const status = (row['Status'] || row['status'] || '').toLowerCase();
- if (status === 'cancelled' || status === 'canceled') {
- return res.send(cancelPage({ error: 'This order has already been cancelled.' }));
- }
-
- if (!isWithinCancelWindow(row)) {
- return res.send(cancelPage({ error: 'The 2-hour cancellation window has passed. Please contact us directly for assistance.' }));
- }
-
- // Read branding
- const { getShopBranding } = require('./email');
- const { companyName, primaryColor } = getShopBranding(slug);
- const customerName = row['Customer Name'] || row['Name'] || row['name'] || 'Customer';
-
- res.send(cancelPage({
- companyName,
- primaryColor,
- orderId,
- customerName,
- slug,
- token,
- showForm: true,
- }));
-});
-
-// ---------------------------------------------------------------------------
-// POST /api/shops/:slug/orders/:orderId/cancel
-// Public action — actually cancels the order (removes from CSV).
-// ---------------------------------------------------------------------------
-router.post('/:slug/orders/:orderId/cancel', (req, res, next) => {
- const { slug, orderId } = req.params;
- const token = req.body?.token || req.query?.token;
-
- // No token = admin cancel request, let it fall through to authenticated admin route
- if (!token) return next();
-
- if (!verifyCancelToken(orderId, slug, token)) {
- return res.status(403).send(cancelPage({ error: 'Invalid or expired cancellation link.' }));
- }
-
- if (!shopExists(slug)) {
- return res.status(404).send(cancelPage({ error: 'Shop not found.' }));
- }
-
- const csvPath = findCsvPath(slug);
- const row = getOrderById(csvPath, orderId);
-
- if (!row) {
- return res.send(cancelPage({ error: 'Order not found. It may have already been cancelled.' }));
- }
-
- const status = (row['Status'] || row['status'] || '').toLowerCase();
- if (status === 'cancelled' || status === 'canceled') {
- return res.send(cancelPage({ error: 'This order has already been cancelled.' }));
- }
-
- if (!isWithinCancelWindow(row)) {
- return res.send(cancelPage({ error: 'The 2-hour cancellation window has passed. Please contact us directly for assistance.' }));
- }
-
- // Remove the order from CSV
- const removed = removeOrderFromCsv(csvPath, orderId);
- if (!removed) {
- return res.send(cancelPage({ error: 'Failed to cancel order. Please try again.' }));
- }
-
- console.log(`[cancel] Order ${orderId} cancelled for shop ${slug}`);
-
- // Send cancellation email
- const { sendCancellationEmail } = require('./email');
- sendCancellationEmail(row, slug).catch(err => {
- console.error(`[cancel] Email failed for ${slug}/${orderId}: ${err.message}`);
- });
-
- const { getShopBranding } = require('./email');
- const { companyName, primaryColor } = getShopBranding(slug);
-
- res.send(cancelPage({
- companyName,
- primaryColor,
- orderId,
- success: true,
- }));
-});
-
-// ---------------------------------------------------------------------------
-// GET /api/shops/:slug/orders/email-image/:productId
-// Public — serves product images for use in emails (no auth required).
-// ---------------------------------------------------------------------------
-router.get('/:slug/orders/email-image/:productId', (req, res) => {
- const { slug, productId } = req.params;
- const collectionsDir = path.join(SHOPS_DIR, slug, 'DATABASE', 'ShopCollections');
-
- if (!fs.existsSync(collectionsDir)) {
- return res.status(404).end();
- }
-
- try {
- const collections = fs.readdirSync(collectionsDir, { withFileTypes: true });
- for (const col of collections) {
- if (!col.isDirectory()) continue;
- const colPath = path.join(collectionsDir, col.name);
- const items = fs.readdirSync(colPath, { withFileTypes: true });
- for (const item of items) {
- if (!item.isDirectory()) continue;
- if (`${slugify(col.name)}-${slugify(item.name)}` !== productId) continue;
-
- const photosDir = path.join(colPath, item.name, 'Photos');
- if (!fs.existsSync(photosDir)) continue;
-
- const files = fs.readdirSync(photosDir);
- const mainPhoto = files.find(f => /^main\.(jpg|jpeg|png|webp)$/i.test(f));
- const anyPhoto = files.find(f => /\.(jpg|jpeg|png|webp|gif)$/i.test(f));
- const photo = mainPhoto || anyPhoto;
-
- if (photo) {
- const photoPath = path.join(photosDir, photo);
- const ext = path.extname(photo).toLowerCase();
- const ct = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
- res.setHeader('Content-Type', ct);
- res.setHeader('Cache-Control', 'public, max-age=86400');
- return fs.createReadStream(photoPath).pipe(res);
- }
- }
- }
- } catch { /* directory unreadable */ }
-
- return res.status(404).end();
-});
-
-// ---------------------------------------------------------------------------
-// Cancel page HTML renderer
-// ---------------------------------------------------------------------------
-function cancelPage({ error, companyName, primaryColor, orderId, customerName, slug, token, showForm, success }) {
- const brand = primaryColor || '#00b4d8';
- const name = companyName || 'Store';
-
- let body = '';
- if (error) {
- body = `
-
-
❌
-
Cannot Cancel Order
-
${error}
-
`;
- } else if (success) {
- body = `
-
-
✅
-
Order Cancelled
-
Order ${orderId} has been successfully cancelled.
-
You will receive a confirmation email shortly.
-
`;
- } else if (showForm) {
- body = `
-
-
⚠️
-
Cancel Order ${orderId}?
-
- Hi ${customerName}, are you sure you want to cancel this order? This action cannot be undone.
-
-
-
This cancellation window closes 2 hours after your order was placed.
-
`;
- }
-
- return `
-
-Order Cancellation — ${name}
-
-
-
-
${name}
-
-
- ${body}
-
-
${name}
-
-`;
-}
-
-module.exports = router;
-module.exports.generateCancelToken = generateCancelToken;
diff --git a/Launchpad/backend/src/orders.js b/Launchpad/backend/src/orders.js
index 29e495d..3c18d1b 100644
--- a/Launchpad/backend/src/orders.js
+++ b/Launchpad/backend/src/orders.js
@@ -203,7 +203,12 @@ router.post('/:slug/orders/wipe', (req, res) => {
try {
const content = fs.readFileSync(csvPath, 'utf8');
- const firstLine = content.split('\n')[0];
+ // Find the actual header row (skip any leading blank lines so we never
+ // wipe the CSV down to an empty file with no columns)
+ const firstLine = content.split('\n').find(line => line.trim());
+ if (!firstLine) {
+ return res.status(400).json({ error: 'Orders CSV has no header row' });
+ }
// Write back just the header row
fs.writeFileSync(csvPath, firstLine + '\n');
req.app.locals.auditLog?.('orders_wiped', { req, details: { slug } });
diff --git a/Launchpad/backend/src/shops.js b/Launchpad/backend/src/shops.js
index cb32ea4..8028a9c 100644
--- a/Launchpad/backend/src/shops.js
+++ b/Launchpad/backend/src/shops.js
@@ -132,7 +132,13 @@ function getContainerStatus(slug) {
try { return JSON.parse(line); } catch { return null; }
}).filter(Boolean);
if (containers.length > 0 && containers[0].State === 'running') {
- return 'running';
+ // Container is up, but the shop may still be installing/building.
+ // The container startup is: npm install -> npm run build -> npm start,
+ // so until node_modules and .next/BUILD_ID exist it is still building.
+ const shopDir = path.join(SHOPS_DIR, slug);
+ const isBuilt = fs.existsSync(path.join(shopDir, 'node_modules')) &&
+ fs.existsSync(path.join(shopDir, '.next', 'BUILD_ID'));
+ return isBuilt ? 'running' : 'building';
}
}
return 'stopped';
diff --git a/Launchpad/frontend/package-lock.json b/Launchpad/frontend/package-lock.json
index 1ee5864..50ae663 100644
--- a/Launchpad/frontend/package-lock.json
+++ b/Launchpad/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "shuttle-platform-frontend",
- "version": "4.0.6",
+ "version": "4.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shuttle-platform-frontend",
- "version": "4.0.6",
+ "version": "4.1.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
diff --git a/Launchpad/frontend/package.json b/Launchpad/frontend/package.json
index e1b12b7..f34967d 100644
--- a/Launchpad/frontend/package.json
+++ b/Launchpad/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "shuttle-platform-frontend",
"private": true,
- "version": "4.0.6",
+ "version": "4.1.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/Launchpad/frontend/src/components/CollectionsEditor.jsx b/Launchpad/frontend/src/components/CollectionsEditor.jsx
index e41a5ff..52b1531 100644
--- a/Launchpad/frontend/src/components/CollectionsEditor.jsx
+++ b/Launchpad/frontend/src/components/CollectionsEditor.jsx
@@ -1,11 +1,11 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, Component } from 'react';
import {
Folder, Plus, Trash2, Save, Upload, ImageIcon, X, ChevronRight,
- Package, FolderPlus, Check,
+ Package, FolderPlus, Check, Pencil, FolderInput,
} from 'lucide-react';
import {
listShopFiles, readShopFile, writeShopFile, deleteShopFile,
- uploadShopFiles, replaceShopFile, getShopImageUrl,
+ uploadShopFiles, replaceShopFile, getShopImageUrl, renameShopFile,
} from '../lib/api';
const DETAIL_FIELDS = {
@@ -28,7 +28,47 @@ function friendlyLabel(filename) {
.trim();
}
+// Catches any unexpected render/runtime error inside the catalog editor so a
+// bug here degrades to an inline message instead of white-screening the SPA.
+class CollectionsEditorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { error: null };
+ }
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ componentDidCatch(error, info) {
+ console.error('CollectionsEditor crashed:', error, info);
+ }
+ render() {
+ if (this.state.error) {
+ return (
+
+
The catalog editor hit an error.
+
{String(this.state.error?.message || this.state.error)}
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
export default function CollectionsEditor({ slug }) {
+ return (
+
+
+
+ );
+}
+
+function CollectionsEditorInner({ slug }) {
// Collections list
const [collections, setCollections] = useState([]);
const [collectionsLoading, setCollectionsLoading] = useState(true);
@@ -54,6 +94,12 @@ export default function CollectionsEditor({ slug }) {
const [addingCollection, setAddingCollection] = useState(false);
const [addingItem, setAddingItem] = useState(false);
+ // Rename / move state
+ const [renamingCollection, setRenamingCollection] = useState(null);
+ const [renameValue, setRenameValue] = useState('');
+ const [renamingPhoto, setRenamingPhoto] = useState(null);
+ const [photoRenameValue, setPhotoRenameValue] = useState('');
+
// Messages
const [success, setSuccess] = useState('');
const [error, setError] = useState('');
@@ -61,6 +107,11 @@ export default function CollectionsEditor({ slug }) {
const photoUploadRef = useRef(null);
const photoReplaceRefs = useRef({});
+ // Monotonic id for item-list loads. Responses from a previous collection
+ // (or a previous slug) are discarded so fast collection switching can't
+ // leave the editor pointing at items that no longer exist.
+ const itemsRequestId = useRef(0);
+
// Load collections
useEffect(() => {
setCollectionsLoading(true);
@@ -78,9 +129,22 @@ export default function CollectionsEditor({ slug }) {
// Load items when collection selected
useEffect(() => {
- if (!selectedCollection) { setItems([]); return; }
- setItemsLoading(true);
+ const reqId = ++itemsRequestId.current;
+
+ // Fully reset all item-editor state on every collection change so no
+ // stale editingItem / details / photos survive the switch.
setEditingItem(null);
+ setItemDetails({});
+ setItemOriginal({});
+ setItemPhotos([]);
+ setItemSaving({});
+ setRenamingPhoto(null);
+ setPhotoRenameValue('');
+ setShowAddItem(false);
+ setNewItemName('');
+
+ if (!selectedCollection) { setItems([]); setItemsLoading(false); return; }
+ setItemsLoading(true);
const colPath = `DATABASE/ShopCollections/${selectedCollection}`;
listShopFiles(slug, colPath)
.then(async (data) => {
@@ -92,11 +156,11 @@ export default function CollectionsEditor({ slug }) {
let thumbnailFile = null;
try {
const d = await readShopFile(slug, `${basePath}/Details/Name.txt`);
- name = d.content.trim() || dir.name;
+ name = (d.content || '').trim() || dir.name;
} catch {}
try {
const d = await readShopFile(slug, `${basePath}/Details/ItemCost.txt`);
- price = d.content.trim();
+ price = (d.content || '').trim();
} catch {}
try {
const photos = await listShopFiles(slug, `${basePath}/Photos`);
@@ -105,10 +169,12 @@ export default function CollectionsEditor({ slug }) {
} catch {}
return { dirName: dir.name, name, price, thumbnailFile, basePath };
}));
+ if (reqId !== itemsRequestId.current) return; // stale response — collection changed since
setItems(itemsData);
setItemsLoading(false);
})
.catch(() => {
+ if (reqId !== itemsRequestId.current) return;
setItems([]);
setItemsLoading(false);
});
@@ -116,6 +182,7 @@ export default function CollectionsEditor({ slug }) {
// Load full item details
const loadItemDetails = async (item) => {
+ const reqId = itemsRequestId.current; // bound to the current collection load
setEditingItem(item.dirName);
setItemSaving({});
const detailsPath = `${item.basePath}/Details`;
@@ -136,21 +203,25 @@ export default function CollectionsEditor({ slug }) {
original[entry.name] = '';
}
}));
+ if (reqId !== itemsRequestId.current) return; // collection changed mid-load
setItemDetails(details);
setItemOriginal(original);
} catch {
+ if (reqId !== itemsRequestId.current) return;
setItemDetails({});
setItemOriginal({});
}
try {
const photoListing = await listShopFiles(slug, photosPath);
+ if (reqId !== itemsRequestId.current) return;
setItemPhotos(
(photoListing.entries || [])
.filter(e => e.isImage)
.map(e => ({ name: e.name, path: `${photosPath}/${e.name}`, size: e.size }))
);
} catch {
+ if (reqId !== itemsRequestId.current) return;
setItemPhotos([]);
}
};
@@ -264,7 +335,78 @@ export default function CollectionsEditor({ slug }) {
}
};
+ const sanitizeName = (name) => name.replace(/[\/\\:*?"<>|]/g, '').trim();
+
+ // #37 — rename a collection folder
+ const handleRenameCollection = async () => {
+ const oldName = renamingCollection;
+ const newName = sanitizeName(renameValue);
+ if (!oldName) return;
+ if (!newName || newName === oldName) { setRenamingCollection(null); return; }
+ setError('');
+ try {
+ await renameShopFile(
+ slug,
+ `DATABASE/ShopCollections/${oldName}`,
+ `DATABASE/ShopCollections/${newName}`
+ );
+ setCollections(prev => prev.map(c => c.name === oldName ? { ...c, name: newName } : c));
+ if (selectedCollection === oldName) setSelectedCollection(newName);
+ setSuccess(`Collection renamed to "${newName}".`);
+ setTimeout(() => setSuccess(''), 3000);
+ } catch (err) {
+ setError(err.response?.data?.error || 'Failed to rename collection');
+ } finally {
+ setRenamingCollection(null);
+ setRenameValue('');
+ }
+ };
+
+ // #36 — move an item folder to another collection
+ const handleMoveItem = async (item, targetCollection) => {
+ if (!item || !targetCollection || targetCollection === selectedCollection) return;
+ setError('');
+ try {
+ await renameShopFile(
+ slug,
+ item.basePath,
+ `DATABASE/ShopCollections/${targetCollection}/${item.dirName}`
+ );
+ setItems(prev => prev.filter(i => i.dirName !== item.dirName));
+ if (editingItem === item.dirName) setEditingItem(null);
+ setSuccess(`Moved "${item.name}" to "${targetCollection}".`);
+ setTimeout(() => setSuccess(''), 3000);
+ } catch (err) {
+ setError(err.response?.data?.error || 'Failed to move item');
+ }
+ };
+
+ // #35 — rename a photo file (extension is preserved)
+ const handlePhotoRename = async (photo) => {
+ let newName = sanitizeName(photoRenameValue);
+ if (!newName) { setRenamingPhoto(null); return; }
+ const ext = (photo.name.match(/\.[^.]+$/) || [''])[0];
+ if (ext && !newName.toLowerCase().endsWith(ext.toLowerCase())) newName += ext;
+ if (newName === photo.name) { setRenamingPhoto(null); return; }
+ const item = items.find(i => i.dirName === editingItem);
+ if (!item) return;
+ const newPath = `${item.basePath}/Photos/${newName}`;
+ setError('');
+ try {
+ await renameShopFile(slug, photo.path, newPath);
+ setItemPhotos(prev => prev.map(p => p.path === photo.path ? { ...p, name: newName, path: newPath } : p));
+ setSuccess(`Photo renamed to "${newName}".`);
+ setTimeout(() => setSuccess(''), 3000);
+ } catch (err) {
+ setError(err.response?.data?.error || 'Failed to rename photo');
+ } finally {
+ setRenamingPhoto(null);
+ setPhotoRenameValue('');
+ }
+ };
+
const handleDeleteItem = async (item) => {
+ if (!item) return;
if (!window.confirm(`Delete item "${item.name}"? This removes all its files and photos.`)) return;
setError('');
try {
@@ -348,7 +490,12 @@ export default function CollectionsEditor({ slug }) {
}
};
- const hasDirtyFields = editingItem && Object.keys(itemDetails).some(f => itemDetails[f] !== itemOriginal[f]);
+ // Resolve the item being edited against the CURRENT items array. If a
+ // collection switch (or move/delete) removed it, this is null and the
+ // editor simply doesn't render — instead of crashing on a missing item.
+ const editingItemObj = editingItem ? items.find(i => i.dirName === editingItem) || null : null;
+
+ const hasDirtyFields = editingItemObj && Object.keys(itemDetails).some(f => itemDetails[f] !== itemOriginal[f]);
// Ordered detail fields: known fields first, then unknown
const orderedDetailFiles = () => {
@@ -424,24 +571,55 @@ export default function CollectionsEditor({ slug }) {
@@ -540,7 +718,7 @@ export default function CollectionsEditor({ slug }) {
)}
{/* Expanded item editor */}
- {editingItem && (
+ {editingItemObj && (
@@ -555,8 +733,27 @@ export default function CollectionsEditor({ slug }) {
Save All
)}
+ {collections.length > 1 && (
+
+
+
+
+ )}
);
diff --git a/Launchpad/frontend/src/components/ShopCard.jsx b/Launchpad/frontend/src/components/ShopCard.jsx
index 27176ec..ff87409 100644
--- a/Launchpad/frontend/src/components/ShopCard.jsx
+++ b/Launchpad/frontend/src/components/ShopCard.jsx
@@ -6,9 +6,10 @@ import { usePermissions } from '../lib/permissions';
import { Play, Square, RotateCcw, Trash2, ShoppingCart, Settings, ExternalLink, Package, BarChart3, Lock } from 'lucide-react';
const STATUS_COLORS = {
- running: 'text-[hsl(142,70%,50%)]',
- error: 'text-destructive',
- stopped: 'text-muted-foreground',
+ running: 'text-[hsl(142,70%,50%)]',
+ building: 'text-amber-400',
+ error: 'text-destructive',
+ stopped: 'text-muted-foreground',
};
const LIFECYCLE_BADGE = {
@@ -138,11 +139,13 @@ export default function ShopCard({ shop }) {
{shop.status === 'running' ? (
+ ) : shop.status === 'building' ? (
+
) : (
)}
- {shop.status}
+ {shop.status === 'building' ? 'Building...' : shop.status}
@@ -202,7 +205,7 @@ export default function ShopCard({ shop }) {
{/* Actions */}
- {shop.status !== 'running' && (
+ {shop.status !== 'running' && shop.status !== 'building' && (
- {status}
+ {status === 'building' ? 'Building...' : status}
);
}
diff --git a/Launchpad/frontend/src/lib/api.js b/Launchpad/frontend/src/lib/api.js
index 93e8ec0..e489e9b 100644
--- a/Launchpad/frontend/src/lib/api.js
+++ b/Launchpad/frontend/src/lib/api.js
@@ -139,6 +139,9 @@ export const writeShopFile = (slug, filePath, content) =>
export const deleteShopFile = (slug, filePath) =>
api.delete(`/shops/${slug}/files`, { params: { path: filePath } }).then(r => r.data);
+export const renameShopFile = (slug, filePath, newPath) =>
+ api.post(`/shops/${slug}/files/rename`, { path: filePath, newPath }).then(r => r.data);
+
export const getShopImageUrl = (slug, filePath) =>
`/api/shops/${slug}/files/image?path=${encodeURIComponent(filePath)}`;
diff --git a/Launchpad/frontend/src/pages/Catalog.jsx b/Launchpad/frontend/src/pages/Catalog.jsx
index 1121384..c5fdc47 100644
--- a/Launchpad/frontend/src/pages/Catalog.jsx
+++ b/Launchpad/frontend/src/pages/Catalog.jsx
@@ -29,17 +29,6 @@ export default function Catalog() {
const { canShop } = usePermissions();
const canEdit = canShop(slug, 'can_edit_items');
- // No permission = block access entirely
- if (!canEdit) {
- return (
-
-
-
Access Restricted
-
You need Catalog permission to access this shop's catalog.
-
- );
- }
-
// Inventory state
const [editedRows, setEditedRows] = useState({});
const [searchQuery, setSearchQuery] = useState('');
@@ -165,6 +154,20 @@ export default function Catalog() {
saveMutation.mutate(updates);
};
+ // No permission = block access entirely.
+ // NOTE: this early return must stay BELOW every hook call above — returning
+ // between hooks changes the hook count across renders and crashes React
+ // ("Rendered more/fewer hooks than during the previous render").
+ if (!canEdit) {
+ return (
+
+
+
Access Restricted
+
You need Catalog permission to access this shop's catalog.
+
+ );
+ }
+
return (
{/* Page header */}
diff --git a/Launchpad/frontend/src/pages/MissionControl.jsx b/Launchpad/frontend/src/pages/MissionControl.jsx
index 04511dc..8ac59e1 100644
--- a/Launchpad/frontend/src/pages/MissionControl.jsx
+++ b/Launchpad/frontend/src/pages/MissionControl.jsx
@@ -541,8 +541,8 @@ function OrderToast({ notifications, onDismiss }) {
// ---------------------------------------------------------------------------
function StatusDot({ status, size = 'sm' }) {
const s = size === 'lg' ? 'w-2.5 h-2.5' : 'w-1.5 h-1.5';
- const color = status === 'running' ? 'bg-emerald-400' : 'bg-red-400';
- const pulse = status === 'running' ? 'animate-pulse' : '';
+ const color = status === 'running' ? 'bg-emerald-400' : status === 'building' ? 'bg-amber-400' : 'bg-red-400';
+ const pulse = (status === 'running' || status === 'building') ? 'animate-pulse' : '';
return
;
}
diff --git a/Launchpad/frontend/src/pages/NewShop.jsx b/Launchpad/frontend/src/pages/NewShop.jsx
index 0006e02..dedb1b2 100644
--- a/Launchpad/frontend/src/pages/NewShop.jsx
+++ b/Launchpad/frontend/src/pages/NewShop.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { createShop, getShopLogs, uploadDatabaseZip } from '../lib/api';
+import { createShop, getShop, getShopLogs, uploadDatabaseZip } from '../lib/api';
import { ArrowLeft, Terminal, Rocket, Database, FileArchive, Zap } from 'lucide-react';
const SHOP_PRESETS = [
@@ -54,6 +54,16 @@ export default function NewShop() {
});
const [hotelList, setHotelList] = useState('');
+ // #12 — poll the new shop until it finishes installing/building
+ const { data: newShopData } = useQuery({
+ queryKey: ['new-shop-status', createdSlug],
+ queryFn: () => getShop(createdSlug),
+ enabled: !!createdSlug,
+ refetchInterval: (query) =>
+ query.state.data?.shop?.status === 'running' ? false : 4000,
+ });
+ const newShopStatus = newShopData?.shop?.status;
+
const mutation = useMutation({
mutationFn: createShop,
onSuccess: async (data) => {
@@ -368,11 +378,40 @@ export default function NewShop() {
className="mx-auto rounded-lg mb-4 max-h-48"
/>
- Launching Site Now!
+ {newShopStatus === 'running' ? 'Shop is Ready!' : 'Launching Site Now!'}
Your shop /{createdSlug} has been created.
+
+ {newShopStatus === 'running' ? (
+ <>
+
+
Running
+
+ open shop →
+
+ >
+ ) : newShopStatus === 'error' ? (
+ <>
+
+
Error — check logs below
+ >
+ ) : (
+ <>
+
+
Building...
+
+ installing & compiling — usually 2–3 minutes, the shop is not ready yet
+
+ >
+ )}
+
)}
diff --git a/Launchpad/frontend/src/pages/Settings.jsx b/Launchpad/frontend/src/pages/Settings.jsx
index 660a69e..ff9f073 100644
--- a/Launchpad/frontend/src/pages/Settings.jsx
+++ b/Launchpad/frontend/src/pages/Settings.jsx
@@ -1008,21 +1008,24 @@ export default function Settings() {
{currentStatus === 'running' ? (
+ ) : currentStatus === 'building' ? (
+
) : (
)}
{currentStatus}
+ }`}>{currentStatus === 'building' ? 'Building...' : currentStatus}
{!canEditUI && (
Read-only
)}
- {currentStatus !== 'running' && (
+ {currentStatus !== 'running' && currentStatus !== 'building' && (
actionMutation.mutate('start')}
disabled={actionMutation.isPending || !canEditUI}
@@ -1456,14 +1459,17 @@ export default function Settings() {
{shop.status === 'running' ? (
+ ) : shop.status === 'building' ? (
+
) : (
)}
{shop.status}
+ }`}>{shop.status === 'building' ? 'Building...' : shop.status}
|