diff --git a/backend/package.json b/backend/package.json
index 048818b..90b95db 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "shuttle-platform-backend",
- "version": "2.0.1",
+ "version": "2.0.2",
"private": true,
"scripts": {
"start": "node src/index.js",
diff --git a/backend/src/orders.js b/backend/src/orders.js
index bc7a827..cb206ae 100644
--- a/backend/src/orders.js
+++ b/backend/src/orders.js
@@ -89,7 +89,22 @@ router.post('/:slug/orders/wipe', (req, res) => {
}
});
-// GET /api/shops/:slug/orders/po/:filename — Download a PO file (PDF)
+// Content-type map for common PO file extensions
+const PO_CONTENT_TYPES = {
+ '.pdf': 'application/pdf',
+ '.html': 'text/html',
+ '.htm': 'text/html',
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ '.xls': 'application/vnd.ms-excel',
+ '.csv': 'text/csv',
+ '.doc': 'application/msword',
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+};
+
+// GET /api/shops/:slug/orders/po/:filename — Download/open a PO file
router.get('/:slug/orders/po/:filename', (req, res) => {
const { slug, filename } = req.params;
@@ -114,8 +129,15 @@ router.get('/:slug/orders/po/:filename', (req, res) => {
return res.status(404).json({ error: 'PO file not found' });
}
- res.setHeader('Content-Type', 'application/pdf');
- res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
+ const ext = path.extname(safeName).toLowerCase();
+ const contentType = PO_CONTENT_TYPES[ext] || 'application/octet-stream';
+
+ // PDFs, HTML, and images can be shown inline; everything else triggers download
+ const isInline = ext === '.pdf' || ext === '.html' || ext === '.htm' || ext === '.png' || ext === '.jpg' || ext === '.jpeg';
+ const disposition = isInline ? 'inline' : 'attachment';
+
+ res.setHeader('Content-Type', contentType);
+ res.setHeader('Content-Disposition', `${disposition}; filename="${safeName}"`);
fs.createReadStream(filePath).pipe(res);
});
diff --git a/backend/src/shops.js b/backend/src/shops.js
index cd7dd08..e6950f3 100644
--- a/backend/src/shops.js
+++ b/backend/src/shops.js
@@ -477,14 +477,15 @@ router.post('/', (req, res) => {
`details: ${dr.details !== false}`,
`extra_notes: ${dr.extra_notes !== false}`,
`shipping_handler: ${dr.shipping_handler !== false}`,
- `hotel_list: ${dr.hotel_list === true}`,
+ `hotel_list: ${dr.hotel_list === true || dr.hotel_collection === true}`,
+ `hotel_collection: ${dr.hotel_collection === true}`,
].join('\n');
fs.writeFileSync(
path.join(shopDir, 'DATABASE', 'Presets', 'DataRequired.txt'),
drContent
);
- if (dr.hotel_list && hotelList) {
+ if ((dr.hotel_list || dr.hotel_collection) && hotelList) {
fs.mkdirSync(path.join(shopDir, 'DATABASE', 'Design', 'Details'), { recursive: true });
fs.writeFileSync(
path.join(shopDir, 'DATABASE', 'Design', 'Details', 'Hotels.txt'),
@@ -523,7 +524,7 @@ router.post('/', (req, res) => {
// Register in database
db.prepare(
'INSERT INTO shops (slug, name, description, status, port, subdomain, shuttle_version) VALUES (?, ?, ?, ?, ?, ?, ?)'
- ).run(slug, name, description || '', 'stopped', port, subdomain, 'STS-2.00');
+ ).run(slug, name, description || '', 'stopped', port, subdomain, 'STS-2.01');
// Start the container using container-readable path
const hostComposeFile = getComposeFilePath(slug);
@@ -981,7 +982,7 @@ router.get('/:slug/version', (req, res) => {
result.currentVersion = shop.shuttle_version;
}
- result.latestAvailable = 'STS-2.00';
+ result.latestAvailable = 'STS-2.01';
res.json(result);
} catch (err) {
@@ -1078,7 +1079,7 @@ router.post('/:slug/update-template', (req, res) => {
}).trim();
} catch { /* ignore */ }
- db.prepare('UPDATE shops SET shuttle_version = ? WHERE slug = ?').run('STS-2.00', slug);
+ db.prepare('UPDATE shops SET shuttle_version = ? WHERE slug = ?').run('STS-2.01', slug);
log.push(`Update complete. Now at commit ${newCommit}.`);
res.json({ message: `Shop "${slug}" updated successfully`, commit: newCommit, log: log.join('\n') });
@@ -1169,7 +1170,7 @@ router.post('/:slug/upgrade', (req, res) => {
}
// Update version in DB
- db.prepare('UPDATE shops SET shuttle_version = ?, status = ? WHERE slug = ?').run('STS-2.00', 'running', slug);
+ db.prepare('UPDATE shops SET shuttle_version = ?, status = ? WHERE slug = ?').run('STS-2.01', 'running', slug);
log.push('Updated shop version to STS-2.00.');
res.json({ message: `Shop "${slug}" upgraded to STS-2.00`, log: log.join('\n') });
diff --git a/frontend/package.json b/frontend/package.json
index 909c06b..576b644 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "shuttle-platform-frontend",
"private": true,
- "version": "2.0.1",
+ "version": "2.0.2",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/CollectionsEditor.jsx b/frontend/src/components/CollectionsEditor.jsx
index e41a5ff..7a4d73a 100644
--- a/frontend/src/components/CollectionsEditor.jsx
+++ b/frontend/src/components/CollectionsEditor.jsx
@@ -7,6 +7,7 @@ import {
listShopFiles, readShopFile, writeShopFile, deleteShopFile,
uploadShopFiles, replaceShopFile, getShopImageUrl,
} from '../lib/api';
+import FadeImage from './FadeImage';
const DETAIL_FIELDS = {
'Name.txt': { type: 'text', label: 'Name' },
@@ -517,10 +518,10 @@ export default function CollectionsEditor({ slug }) {
}`}
>
{item.thumbnailFile ? (
-
) : (
@@ -661,10 +662,10 @@ export default function CollectionsEditor({ slug }) {
const imgUrl = getShopImageUrl(slug, photo.path) + (ts ? `&_t=${ts}` : '');
return (
-
. All sizing / rounding /
+ * border classes go on the outer className as usual.
+ */
+export default function FadeImage({ src, alt, className = '', style, ...rest }) {
+ const [loaded, setLoaded] = useState(false);
+ const [error, setError] = useState(false);
+ const prevSrc = useRef(src);
+
+ // Reset state when src changes (e.g. image replaced)
+ if (src !== prevSrc.current) {
+ prevSrc.current = src;
+ setLoaded(false);
+ setError(false);
+ }
+
+ const handleLoad = useCallback(() => setLoaded(true), []);
+ const handleError = useCallback(() => { setLoaded(true); setError(true); }, []);
+
+ return (
+
+ {/* Shimmer placeholder — visible until image loads */}
+
+ {!error ? (
+
+ ) : (
+
+ Failed to load
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/KeyValueEditor.jsx b/frontend/src/components/KeyValueEditor.jsx
index 4552c1a..ccd8f3c 100644
--- a/frontend/src/components/KeyValueEditor.jsx
+++ b/frontend/src/components/KeyValueEditor.jsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { Save, Eye, EyeOff, Copy, Upload, Check } from 'lucide-react';
import { getShopImageUrl } from '../lib/api';
+import FadeImage from './FadeImage';
const HIDDEN_FILES = new Set(['readme.md', 'readme.txt', '.gitkeep', '.ds_store']);
@@ -229,7 +230,7 @@ export default function KeyValueEditor({
{entry.name}
-
+
imageInputRefs.current[filePath]?.click()}
diff --git a/frontend/src/components/OrderTable.jsx b/frontend/src/components/OrderTable.jsx
index 32f609b..04cd6c8 100644
--- a/frontend/src/components/OrderTable.jsx
+++ b/frontend/src/components/OrderTable.jsx
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
-import { ArrowUpDown, FileDown } from 'lucide-react';
+import { ArrowUpDown, FileDown, FileSpreadsheet } from 'lucide-react';
import { getPoFileUrl } from '../lib/api';
export default function OrderTable({ orders, slug }) {
@@ -11,6 +11,11 @@ export default function OrderTable({ orders, slug }) {
return Object.keys(orders[0]);
}, [orders]);
+ // Check if any order has a PO File value
+ const hasPo = useMemo(() => {
+ return orders.some(row => row['PO File'] && row['PO File'].trim());
+ }, [orders]);
+
const sorted = useMemo(() => {
if (!sortKey) return orders;
return [...orders].sort((a, b) => {
@@ -35,18 +40,10 @@ export default function OrderTable({ orders, slug }) {
}
const renderCell = (col, value) => {
- // Render PO File column as a download link
+ // Render PO File column as the filename text (the button is in the dedicated column)
if (col === 'PO File' && value && slug) {
return (
-
-
- {value}
-
+ {value}
);
}
return value;
@@ -69,6 +66,14 @@ export default function OrderTable({ orders, slug }) {
))}
+ {hasPo && (
+
+
+
+ Open PO
+
+
+ )}
@@ -79,6 +84,23 @@ export default function OrderTable({ orders, slug }) {
{renderCell(col, row[col])}
))}
+ {hasPo && (
+
+ {row['PO File'] && row['PO File'].trim() ? (
+
+
+ Open PO
+
+ ) : (
+ —
+ )}
+
+ )}
))}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b0d4d9a..fa06f5e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -204,3 +204,66 @@ html.light .lp-card:hover {
border-color: hsl(188 100% 32% / 0.35);
box-shadow: 0 0 0 1px hsl(188 100% 32% / 0.1), 0 4px 24px hsl(188 100% 32% / 0.08);
}
+
+/* ── FadeImage — placeholder block → image crossfade ───── */
+.fade-img-wrap {
+ position: relative;
+ overflow: hidden;
+}
+
+.fade-img-placeholder {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(135deg, hsl(var(--muted)), hsl(var(--secondary)));
+ opacity: 1;
+ transition: opacity 0.45s ease;
+ z-index: 1;
+}
+
+@keyframes fade-img-shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+.fade-img-placeholder::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ hsl(var(--border) / 0.3) 50%,
+ transparent 100%
+ );
+ background-size: 200% 100%;
+ animation: fade-img-shimmer 1.8s ease-in-out infinite;
+}
+
+.fade-img-placeholder--hidden {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.fade-img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: 0;
+ transition: opacity 0.45s ease;
+}
+
+.fade-img--visible {
+ opacity: 1;
+}
+
+.fade-img-error {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: hsl(var(--muted));
+ color: hsl(var(--muted-foreground));
+ font-size: 0.7rem;
+}
diff --git a/frontend/src/pages/NewShop.jsx b/frontend/src/pages/NewShop.jsx
index 7fa325d..09d6cb0 100644
--- a/frontend/src/pages/NewShop.jsx
+++ b/frontend/src/pages/NewShop.jsx
@@ -3,27 +3,28 @@ import { useNavigate, Link } from 'react-router-dom';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { createShop, getShopLogs, uploadDatabaseZip } from '../lib/api';
import { ArrowLeft, Terminal, Rocket, Database, FileArchive, Zap } from 'lucide-react';
+import FadeImage from '../components/FadeImage';
const SHOP_PRESETS = [
{
label: 'Basic Free Shop',
shopType: 'free',
- dataRequired: { address: true, details: true, extra_notes: true, shipping_handler: true, hotel_list: false },
+ dataRequired: { address: true, details: true, extra_notes: true, shipping_handler: true, hotel_list: false, hotel_collection: false },
},
{
label: 'PO Required Shop',
shopType: 'po',
- dataRequired: { address: true, details: true, extra_notes: true, shipping_handler: true, hotel_list: false },
+ dataRequired: { address: true, details: true, extra_notes: true, shipping_handler: true, hotel_list: false, hotel_collection: false },
},
{
label: 'Hotel Event Shop',
shopType: 'free',
- dataRequired: { address: true, details: true, extra_notes: true, shipping_handler: false, hotel_list: true },
+ dataRequired: { address: true, details: true, extra_notes: true, shipping_handler: false, hotel_list: false, hotel_collection: true },
},
{
label: 'Minimal Free Shop',
shopType: 'free',
- dataRequired: { address: false, details: false, extra_notes: false, shipping_handler: true, hotel_list: false },
+ dataRequired: { address: false, details: false, extra_notes: false, shipping_handler: true, hotel_list: false, hotel_collection: false },
},
];
@@ -43,7 +44,7 @@ export default function NewShop() {
const navigate = useNavigate();
const queryClient = useQueryClient();
- // STS-2.00: Shop type & preset state
+ // STS-2.01: Shop type & preset state
const [shopType, setShopType] = useState('free');
const [dataRequired, setDataRequired] = useState({
address: true,
@@ -51,6 +52,7 @@ export default function NewShop() {
extra_notes: true,
shipping_handler: true,
hotel_list: false,
+ hotel_collection: false,
});
const [hotelList, setHotelList] = useState('');
@@ -106,13 +108,16 @@ export default function NewShop() {
const applyPreset = (preset) => {
setShopType(preset.shopType);
setDataRequired({ ...preset.dataRequired });
- if (!preset.dataRequired.hotel_list) setHotelList('');
+ if (!preset.dataRequired.hotel_list && !preset.dataRequired.hotel_collection) setHotelList('');
};
const toggleDataRequired = (key) => {
setDataRequired(prev => {
const next = { ...prev, [key]: !prev[key] };
- if (key === 'hotel_list' && !next.hotel_list) setHotelList('');
+ if ((key === 'hotel_list' && !next.hotel_list && !next.hotel_collection) ||
+ (key === 'hotel_collection' && !next.hotel_collection && !next.hotel_list)) {
+ setHotelList('');
+ }
return next;
});
};
@@ -128,7 +133,7 @@ export default function NewShop() {
if (customSlug.trim()) payload.slug = customSlug.trim();
if (description.trim()) payload.description = description.trim();
if (folderPath.trim()) payload.folderPath = folderPath.trim();
- if (dataRequired.hotel_list && hotelList.trim()) payload.hotelList = hotelList.trim();
+ if ((dataRequired.hotel_list || dataRequired.hotel_collection) && hotelList.trim()) payload.hotelList = hotelList.trim();
mutation.mutate(payload);
};
@@ -141,7 +146,8 @@ export default function NewShop() {
details: 'Details',
extra_notes: 'Extra Notes',
shipping_handler: 'Shipping Handler',
- hotel_list: 'Hotel List',
+ hotel_list: 'Hotel List (Dropdown)',
+ hotel_collection: 'Hotel Collection (Textbox)',
};
return (
@@ -287,24 +293,24 @@ export default function NewShop() {
- {/* Hotel List (conditional) */}
- {dataRequired.hotel_list && (
+ {/* Hotel Collection (conditional) */}
+ {(dataRequired.hotel_list || dataRequired.hotel_collection) && (
- Hotel List
+ Hotel Collection
)}
@@ -404,7 +410,7 @@ export default function NewShop() {
{/* Launch success */}
{createdSlug && !mutation.isPending && (
-
{
setPresetLoading(true);
Promise.all([
@@ -289,7 +290,7 @@ export default function Settings() {
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
await writeShopFile(slug, 'DATABASE/Presets/DataRequired.txt', drContent);
- if (presetDataRequired.hotel_list && presetHotelList.trim()) {
+ if ((presetDataRequired.hotel_list || presetDataRequired.hotel_collection) && presetHotelList.trim()) {
await writeShopFile(slug, 'DATABASE/Design/Details/Hotels.txt', presetHotelList);
}
setPresetExists(true);
@@ -727,12 +728,12 @@ export default function Settings() {
{/* Shop Collections — DATABASE/ShopCollections */}
- {/* Shop Configuration — STS-2.00 Presets */}
+ {/* Shop Configuration — STS-2.01 Presets */}
Shop Configuration
- STS-2.00 Presets
+ STS-2.01 Presets
{presetSuccess && (
@@ -753,7 +754,7 @@ export default function Settings() {
{!presetExists && (
- No presets configured yet. This shop may be pre-STS-2.00. Configure and save to create preset files.
+ No presets configured yet. This shop may be pre-STS-2.01. Configure and save to create preset files.
)}
@@ -784,7 +785,8 @@ export default function Settings() {
['details', 'Details'],
['extra_notes', 'Extra Notes'],
['shipping_handler', 'Shipping Handler'],
- ['hotel_list', 'Hotel List'],
+ ['hotel_list', 'Hotel List (Dropdown)'],
+ ['hotel_collection', 'Hotel Collection (Textbox)'],
].map(([key, label]) => (
setPresetDataRequired(prev => {
const next = { ...prev, [key]: !prev[key] };
- if (key === 'hotel_list' && !next.hotel_list) setPresetHotelList('');
+ if ((key === 'hotel_list' && !next.hotel_list && !next.hotel_collection) ||
+ (key === 'hotel_collection' && !next.hotel_collection && !next.hotel_list)) {
+ setPresetHotelList('');
+ }
return next;
})}
className="rounded border-border accent-primary"
@@ -805,12 +810,12 @@ export default function Settings() {
- {/* Hotel List */}
- {presetDataRequired.hotel_list && (
+ {/* Hotel Collection */}
+ {(presetDataRequired.hotel_list || presetDataRequired.hotel_collection) && (
- Hotel List
+ Hotel Collection
{presetHotelList.trim() && (
setPresetHotelList(e.target.value)}
- placeholder={"Hilton Downtown\nMarriott Convention Center"}
- className="w-full rounded-md border border-border bg-input px-3 py-2 text-xs font-mono outline-none focus:ring-1 focus:ring-primary/60 transition-all resize-none"
- rows={4}
+ placeholder={"Hilton Downtown\nMarriott Convention Center\nHyatt Regency\nBest Western Plus"}
+ className="w-full rounded-md border border-border bg-input px-3 py-2 text-xs font-mono outline-none focus:ring-1 focus:ring-primary/60 transition-all resize-y min-h-[120px]"
+ rows={8}
/>
- One hotel per line.
+
+ One hotel per line. {presetDataRequired.hotel_collection ? 'Shown as a textbox during checkout.' : 'Shown as a dropdown during checkout.'}
+
>
) : (() => {
const hotels = presetHotelList.trim().split('\n').filter(h => h.trim());
@@ -998,7 +1005,7 @@ export default function Settings() {
Shuttle Version
- {currentShop?.shuttle_version || 'Unknown (pre-STS-2.00)'}
+ {currentShop?.shuttle_version || 'Unknown (pre-STS-2.01)'}
@@ -1231,10 +1238,11 @@ export default function Settings() {
-