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 && ( +
+ + +
+ )}

-

{photo.name}

+ {renamingPhoto === photo.path ? ( +
+ setPhotoRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handlePhotoRename(photo); + if (e.key === 'Escape') { setRenamingPhoto(null); setPhotoRenameValue(''); } + }} + className="min-w-0 flex-1 rounded border border-border/60 bg-input px-1 py-0.5 text-[10px] font-mono outline-none focus:ring-1 focus:ring-primary/60" + /> + + +
+ ) : ( +

{photo.name}

+ )}
); 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' && (