Feature: Replace global order limits with per-item stock limits and add qty picker popup
This commit is contained in:
1 parent
8e55716a94
commit
7b9c654fb1
7 files changed
+243
-110
No files matched your search
@@ -22,6 +22,7 @@ model StockItem {
|
||||
bulk1Price Float @default(0)
|
||||
bulk2Threshold Float @default(0)
|
||||
bulk2Price Float @default(0)
|
||||
maxStock Float? // null = unlimited
|
||||
}
|
||||
|
||||
model Order {
|
||||
@@ -42,6 +43,4 @@ model Config {
|
||||
finalDepositDate String?
|
||||
cookDay String?
|
||||
payIdInfo String? @default("Your PayID Here")
|
||||
maxTotalWeight Float? @default(100.0)
|
||||
maxItemWeight Float? @default(50.0)
|
||||
}
|
||||
+37
-5
@@ -16,7 +16,7 @@ app.use(express.json());
|
||||
// Fetch all data (Stock, Orders, Config)
|
||||
app.get('/api/data', async (req, res) => {
|
||||
try {
|
||||
const stock = await prisma.stockItem.findMany();
|
||||
let stock = await prisma.stockItem.findMany();
|
||||
const orders = await prisma.order.findMany();
|
||||
const config = await prisma.config.findFirst();
|
||||
|
||||
@@ -26,7 +26,41 @@ app.get('/api/data', async (req, res) => {
|
||||
items: JSON.parse(order.items)
|
||||
}));
|
||||
|
||||
res.json({ stock, orders: parsedOrders, config });
|
||||
// Tally total ordered kg per stock item across all orders
|
||||
const orderedQtyById: Record<number, number> = {};
|
||||
for (const order of parsedOrders) {
|
||||
for (const item of order.items) {
|
||||
orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-deactivate items that have hit their maxStock limit
|
||||
for (const item of stock) {
|
||||
if (item.maxStock !== null && item.maxStock !== undefined) {
|
||||
const ordered = orderedQtyById[item.id] || 0;
|
||||
const shouldBeAvailable = ordered < item.maxStock;
|
||||
if (item.available !== shouldBeAvailable) {
|
||||
await prisma.stockItem.update({
|
||||
where: { id: item.id },
|
||||
data: { available: shouldBeAvailable }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch stock after potential auto-updates
|
||||
stock = await prisma.stockItem.findMany();
|
||||
|
||||
// Attach remaining stock info to each item
|
||||
const stockWithRemaining = stock.map(item => ({
|
||||
...item,
|
||||
orderedQty: orderedQtyById[item.id] || 0,
|
||||
remainingStock: item.maxStock !== null && item.maxStock !== undefined
|
||||
? Math.max(0, item.maxStock - (orderedQtyById[item.id] || 0))
|
||||
: null
|
||||
}));
|
||||
|
||||
res.json({ stock: stockWithRemaining, orders: parsedOrders, config });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Failed to fetch data' });
|
||||
@@ -138,15 +172,13 @@ app.delete('/api/stock/:id', async (req, res) => {
|
||||
// Update Config
|
||||
app.post('/api/config', async (req, res) => {
|
||||
try {
|
||||
const { adminCode, finalDepositDate, cookDay, payIdInfo, maxTotalWeight, maxItemWeight } = req.body;
|
||||
const { adminCode, finalDepositDate, cookDay, payIdInfo } = req.body;
|
||||
const config = await prisma.config.findFirst();
|
||||
const data: any = {};
|
||||
if (adminCode !== undefined) data.adminCode = adminCode;
|
||||
if (finalDepositDate !== undefined) data.finalDepositDate = finalDepositDate;
|
||||
if (cookDay !== undefined) data.cookDay = cookDay;
|
||||
if (payIdInfo !== undefined) data.payIdInfo = payIdInfo;
|
||||
if (maxTotalWeight !== undefined) data.maxTotalWeight = maxTotalWeight !== null ? parseFloat(maxTotalWeight) : null;
|
||||
if (maxItemWeight !== undefined) data.maxItemWeight = maxItemWeight !== null ? parseFloat(maxItemWeight) : null;
|
||||
|
||||
if (config) {
|
||||
await prisma.config.update({
|
||||
|
||||
+15
-67
@@ -4,6 +4,7 @@ import { Icons } from './components/Icons';
|
||||
import CustomerView from './components/CustomerView';
|
||||
import AdminDashboard from './components/AdminDashboard';
|
||||
import CartModal from './components/CartModal';
|
||||
import QuantityPicker from './components/QuantityPicker';
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<'customer' | 'admin'>('customer');
|
||||
@@ -18,6 +19,7 @@ function App() {
|
||||
const [adminCode, setAdminCode] = useState('1234');
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [confirmModal, setConfirmModal] = useState<{ show: boolean, message: string, onConfirm: (() => void) | null }>({ show: false, message: '', onConfirm: null });
|
||||
const [pickerItem, setPickerItem] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refreshData();
|
||||
@@ -56,81 +58,25 @@ function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const addToCart = (item: any) => {
|
||||
const maxItem = config?.maxItemWeight ?? 50;
|
||||
const maxTotal = config?.maxTotalWeight ?? 100;
|
||||
|
||||
let isAllowed = true;
|
||||
let errorMsg = '';
|
||||
|
||||
const addToCart = (item: any, qty: number = 1) => {
|
||||
setCart(prev => {
|
||||
const existing = prev.find(i => i.id === item.id);
|
||||
const currentQty = existing ? existing.qty : 0;
|
||||
const targetItemQty = currentQty + 1;
|
||||
const currentTotalWeight = prev.reduce((acc, i) => acc + i.qty, 0);
|
||||
const targetTotalWeight = currentTotalWeight + 1;
|
||||
|
||||
if (targetItemQty > maxItem) {
|
||||
isAllowed = false;
|
||||
errorMsg = `Limit reached: Max ${maxItem}${item.unit} per item.`;
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (targetTotalWeight > maxTotal) {
|
||||
isAllowed = false;
|
||||
errorMsg = `Limit reached: Max ${maxTotal}kg total order weight.`;
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
return prev.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i);
|
||||
return prev.map(i => i.id === item.id ? { ...i, qty: i.qty + qty } : i);
|
||||
}
|
||||
return [...prev, { ...item, qty: 1 }];
|
||||
return [...prev, { ...item, qty }];
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
showNotification(errorMsg);
|
||||
} else {
|
||||
showNotification(`Added 1${item.unit} ${item.name}`);
|
||||
}
|
||||
showNotification(`Added ${qty}${item.unit} ${item.name}`);
|
||||
};
|
||||
|
||||
const updateCartQty = (itemId: number, delta: number) => {
|
||||
const maxItem = config?.maxItemWeight ?? 50;
|
||||
const maxTotal = config?.maxTotalWeight ?? 100;
|
||||
|
||||
let isAllowed = true;
|
||||
let errorMsg = '';
|
||||
|
||||
setCart(prev => {
|
||||
const existing = prev.find(i => i.id === itemId);
|
||||
if (!existing) return prev;
|
||||
|
||||
const newQty = existing.qty + delta;
|
||||
if (newQty <= 0) return prev;
|
||||
|
||||
if (delta > 0) {
|
||||
if (newQty > maxItem) {
|
||||
isAllowed = false;
|
||||
errorMsg = `Limit reached: Max ${maxItem}${existing.unit} per item.`;
|
||||
return prev;
|
||||
}
|
||||
|
||||
const currentTotalWeight = prev.reduce((acc, i) => acc + i.qty, 0);
|
||||
const targetTotalWeight = currentTotalWeight + delta;
|
||||
if (targetTotalWeight > maxTotal) {
|
||||
isAllowed = false;
|
||||
errorMsg = `Limit reached: Max ${maxTotal}kg total order weight.`;
|
||||
return prev;
|
||||
}
|
||||
setCart(prev => prev.map(i => {
|
||||
if (i.id === itemId) {
|
||||
const newQty = i.qty + delta;
|
||||
return newQty > 0 ? { ...i, qty: newQty } : i;
|
||||
}
|
||||
|
||||
return prev.map(i => i.id === itemId ? { ...i, qty: newQty } : i);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
showNotification(errorMsg);
|
||||
}
|
||||
return i;
|
||||
}));
|
||||
};
|
||||
|
||||
const removeFromCart = (itemId: number) => {
|
||||
@@ -214,11 +160,13 @@ function App() {
|
||||
|
||||
<main className="max-w-6xl mx-auto px-4 py-8">
|
||||
{notification && <div className="fixed bottom-4 right-4 bg-red-600 text-white px-6 py-3 rounded-lg shadow-2xl animate-bounce z-50 font-bold tracking-wide border border-red-400">{notification}</div>}
|
||||
{view === 'customer' ? <CustomerView stock={stock} addToCart={addToCart} config={config} /> : <AdminDashboard stock={stock} orders={orders} config={config} refreshData={refreshData} notify={showNotification} promptConfirm={promptConfirm} />}
|
||||
{view === 'customer' ? <CustomerView stock={stock} onAddToCart={(item: any) => setPickerItem(item)} config={config} /> : <AdminDashboard stock={stock} orders={orders} config={config} refreshData={refreshData} notify={showNotification} promptConfirm={promptConfirm} />}
|
||||
</main>
|
||||
|
||||
{showCartModal && <CartModal cart={cart} config={config} onClose={() => setShowCartModal(false)} onRemove={removeFromCart} onUpdateQty={updateCartQty} onSubmit={submitOrder} />}
|
||||
|
||||
{pickerItem && <QuantityPicker item={pickerItem} onConfirm={addToCart} onClose={() => setPickerItem(null)} />}
|
||||
|
||||
{showAdminLogin && (
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-stone-950 border border-red-900 w-full max-w-xs p-6 rounded-xl shadow-2xl flex flex-col gap-4">
|
||||
|
||||
@@ -12,9 +12,7 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
setSettingsForm({
|
||||
finalDepositDate: config.finalDepositDate || '',
|
||||
cookDay: config.cookDay || '',
|
||||
payIdInfo: config.payIdInfo || '',
|
||||
maxTotalWeight: config.maxTotalWeight !== undefined && config.maxTotalWeight !== null ? config.maxTotalWeight : 100,
|
||||
maxItemWeight: config.maxItemWeight !== undefined && config.maxItemWeight !== null ? config.maxItemWeight : 50
|
||||
payIdInfo: config.payIdInfo || ''
|
||||
});
|
||||
}
|
||||
}, [config, settingsForm]);
|
||||
@@ -230,28 +228,6 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-bold text-red-500 uppercase tracking-widest border-b border-stone-800 pt-4 pb-2">Order Limits</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-stone-500 uppercase mb-2">Max Total Order (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settingsForm.maxTotalWeight}
|
||||
onChange={(e) => setSettingsForm({ ...settingsForm, maxTotalWeight: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white focus:border-red-600 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-stone-500 uppercase mb-2">Max Per Item (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settingsForm.maxItemWeight}
|
||||
onChange={(e) => setSettingsForm({ ...settingsForm, maxItemWeight: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white focus:border-red-600 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
className="w-full bg-red-700 hover:bg-red-600 text-white font-bold py-3.5 rounded-lg transition-colors uppercase tracking-widest text-xs font-black shadow-lg shadow-red-900/20 active:scale-[0.98]"
|
||||
@@ -295,7 +271,8 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) {
|
||||
bulk1Threshold: item.bulk1Threshold || 0,
|
||||
bulk1Price: item.bulk1Price || 0,
|
||||
bulk2Threshold: item.bulk2Threshold || 0,
|
||||
bulk2Price: item.bulk2Price || 0
|
||||
bulk2Price: item.bulk2Price || 0,
|
||||
maxStock: item.maxStock ?? ''
|
||||
});
|
||||
}
|
||||
}, [item, isEditing]);
|
||||
@@ -307,7 +284,8 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) {
|
||||
bulk1Threshold: parseFloat(editData.bulk1Threshold),
|
||||
bulk1Price: parseFloat(editData.bulk1Price),
|
||||
bulk2Threshold: parseFloat(editData.bulk2Threshold),
|
||||
bulk2Price: parseFloat(editData.bulk2Price)
|
||||
bulk2Price: parseFloat(editData.bulk2Price),
|
||||
maxStock: editData.maxStock !== '' && editData.maxStock !== null ? parseFloat(editData.maxStock) : null
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
@@ -346,6 +324,12 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) {
|
||||
<span className="bg-stone-900 px-2 py-1 rounded">Standard: <b className="text-stone-300">${item.price}/{item.unit}</b></span>
|
||||
{item.bulk1Threshold > 0 && <span className="bg-stone-900 px-2 py-1 rounded text-yellow-600">Tier 1: <b className="text-yellow-500">${item.bulk1Price}</b> @ {item.bulk1Threshold}{item.unit}+</span>}
|
||||
{item.bulk2Threshold > 0 && <span className="bg-stone-900 px-2 py-1 rounded text-green-600">Tier 2: <b className="text-green-500">${item.bulk2Price}</b> @ {item.bulk2Threshold}{item.unit}+</span>}
|
||||
{item.maxStock !== null && item.maxStock !== undefined && (
|
||||
<span className="bg-stone-900 px-2 py-1 rounded text-blue-400">
|
||||
Stock: <b className="text-blue-300">{item.orderedQty || 0}/{item.maxStock}{item.unit}</b> ordered
|
||||
{' · '}<b className={item.remainingStock <= 0 ? 'text-red-400' : 'text-green-400'}>{item.remainingStock ?? 0}{item.unit} left</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 bg-stone-900 p-3 rounded border border-stone-700">
|
||||
@@ -355,6 +339,8 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) {
|
||||
<input type="number" value={editData.price} onChange={e => setEditData({ ...editData, price: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
|
||||
<label className="block text-[10px] text-stone-500">Unit (kg/ea)</label>
|
||||
<input type="text" value={editData.unit} onChange={e => setEditData({ ...editData, unit: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
|
||||
<label className="block text-[10px] text-stone-500">Stock Limit ({editData.unit}) — leave blank for unlimited</label>
|
||||
<input type="number" value={editData.maxStock} onChange={e => setEditData({ ...editData, maxStock: e.target.value })} placeholder="Unlimited" className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2 border-l border-stone-800 pl-4">
|
||||
<p className="text-xs font-bold text-yellow-500 uppercase">Tier 1 Bulk</p>
|
||||
|
||||
@@ -167,11 +167,6 @@ export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubm
|
||||
<div className="text-xs bg-stone-950 p-3 rounded border border-stone-800 text-stone-400">
|
||||
<p className="font-bold text-red-500 mb-1 uppercase tracking-widest">Deposit Required</p>
|
||||
<p>Order will not be cooked until the 30% deposit is paid. We accept PayID and Cash.</p>
|
||||
{(config?.maxTotalWeight || config?.maxItemWeight) && (
|
||||
<p className="mt-2 border-t border-stone-850 pt-2 text-[10px] text-stone-500 italic">
|
||||
Limits: Max {config.maxTotalWeight ?? 100}kg total weight | Max {config.maxItemWeight ?? 50}kg per item.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
||||
@@ -14,15 +14,27 @@ interface StockItem {
|
||||
bulk2Price: number;
|
||||
}
|
||||
|
||||
function StockCard({ item, onAdd, disabled }: { item: StockItem, onAdd?: () => void, disabled?: boolean }) {
|
||||
function StockCard({ item, onAdd, disabled }: { item: any, onAdd?: () => void, disabled?: boolean }) {
|
||||
const hasTier1 = item.bulk1Threshold > 0 && item.bulk1Price > 0;
|
||||
const hasTier2 = item.bulk2Threshold > 0 && item.bulk2Price > 0;
|
||||
const hasLimit = item.maxStock !== null && item.maxStock !== undefined;
|
||||
const remaining = item.remainingStock;
|
||||
const stockPct = hasLimit ? Math.max(0, (remaining / item.maxStock) * 100) : 100;
|
||||
|
||||
return (
|
||||
<div className="bg-[#131110] border border-stone-800 rounded-xl overflow-hidden hover:border-red-900/50 transition-all group flex flex-col shadow-lg hover:shadow-red-900/10">
|
||||
<div className="p-6 flex-1 relative">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-[0.15em] border border-stone-800 px-2 py-1 rounded bg-stone-900">{item.category}</span>
|
||||
{hasLimit && (
|
||||
<span className={`text-[10px] font-bold uppercase tracking-widest px-2 py-1 rounded ${
|
||||
stockPct <= 25 ? 'text-red-400 bg-red-950/50 border border-red-900/50' :
|
||||
stockPct <= 60 ? 'text-amber-400 bg-amber-950/50 border border-amber-900/50' :
|
||||
'text-green-400 bg-green-950/50 border border-green-900/50'
|
||||
}`}>
|
||||
{remaining}{item.unit} left
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 className="text-2xl font-black text-stone-100 mb-1 uppercase italic">{item.name}</h4>
|
||||
@@ -45,6 +57,21 @@ function StockCard({ item, onAdd, disabled }: { item: StockItem, onAdd?: () => v
|
||||
</div>
|
||||
|
||||
<p className="text-stone-400 text-sm leading-relaxed">{item.description}</p>
|
||||
|
||||
{hasLimit && (
|
||||
<div className="mt-4">
|
||||
<div className="h-1.5 bg-stone-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
stockPct <= 25 ? 'bg-red-600' :
|
||||
stockPct <= 60 ? 'bg-amber-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.max(3, stockPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-stone-950 border-t border-stone-800">
|
||||
@@ -64,7 +91,7 @@ function StockCard({ item, onAdd, disabled }: { item: StockItem, onAdd?: () => v
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerView({ stock, addToCart, config }: { stock: StockItem[], addToCart: (item: StockItem) => void, config?: any }) {
|
||||
export default function CustomerView({ stock, onAddToCart, config }: { stock: any[], onAddToCart: (item: any) => void, config?: any }) {
|
||||
const availableStock = stock.filter(item => item.available);
|
||||
const soldOutStock = stock.filter(item => !item.available);
|
||||
|
||||
@@ -126,8 +153,8 @@ export default function CustomerView({ stock, addToCart, config }: { stock: Stoc
|
||||
<div className="pt-4">
|
||||
<h3 className="text-2xl font-bold text-stone-300 mb-6 flex items-center gap-2"><Icons.Beast /> In The Smoker</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{availableStock.map(item => (
|
||||
<StockCard key={item.id} item={item} onAdd={() => addToCart(item)} />
|
||||
{availableStock.map(item => (
|
||||
<StockCard key={item.id} item={item} onAdd={() => onAddToCart(item)} />
|
||||
))}
|
||||
{availableStock.length === 0 && <p className="text-stone-500 italic col-span-full text-center py-10 border border-dashed border-stone-800 rounded-xl">Nothing in the smoker right now.</p>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { Icons } from './Icons';
|
||||
|
||||
interface QuantityPickerProps {
|
||||
item: any;
|
||||
onConfirm: (item: any, qty: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QuantityPicker({ item, onConfirm, onClose }: QuantityPickerProps) {
|
||||
const remaining = item.remainingStock !== null && item.remainingStock !== undefined
|
||||
? item.remainingStock
|
||||
: null;
|
||||
const max = remaining !== null ? remaining : 999;
|
||||
|
||||
const [qty, setQty] = useState(1);
|
||||
|
||||
const getPrice = (q: number) => {
|
||||
if (item.bulk2Threshold > 0 && q >= item.bulk2Threshold && item.bulk2Price > 0) return item.bulk2Price;
|
||||
if (item.bulk1Threshold > 0 && q >= item.bulk1Threshold && item.bulk1Price > 0) return item.bulk1Price;
|
||||
return item.price;
|
||||
};
|
||||
|
||||
const unitPrice = getPrice(qty);
|
||||
const lineTotal = unitPrice * qty;
|
||||
|
||||
const increment = () => setQty(q => Math.min(q + 1, max));
|
||||
const decrement = () => setQty(q => Math.max(q - 1, 1));
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(item, qty);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-[#131110] border border-stone-800 rounded-2xl w-full max-w-sm shadow-2xl shadow-black/60 overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-200">
|
||||
|
||||
{/* Header stripe */}
|
||||
<div className="h-1 bg-gradient-to-r from-red-800 via-red-600 to-red-800" />
|
||||
|
||||
<div className="p-6">
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-stone-500 hover:text-white transition-colors"
|
||||
>
|
||||
<Icons.X />
|
||||
</button>
|
||||
|
||||
{/* Item info */}
|
||||
<div className="mb-6">
|
||||
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-widest border border-stone-800 px-2 py-1 rounded bg-stone-900">
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 className="text-2xl font-black text-white uppercase italic mt-2">{item.name}</h3>
|
||||
|
||||
{remaining !== null && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-stone-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-red-600 rounded-full transition-all"
|
||||
style={{ width: `${Math.max(5, (remaining / (item.maxStock || 1)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-stone-400 whitespace-nowrap">
|
||||
{remaining}{item.unit} left
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="bg-stone-950 border border-stone-800 rounded-xl p-4 mb-4">
|
||||
<p className="text-xs text-stone-500 uppercase font-bold mb-3 tracking-widest">Select Quantity</p>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<button
|
||||
onClick={decrement}
|
||||
disabled={qty <= 1}
|
||||
className="w-12 h-12 rounded-lg bg-stone-800 hover:bg-stone-700 disabled:opacity-30 disabled:cursor-not-allowed text-white flex items-center justify-center transition-all active:scale-95"
|
||||
>
|
||||
<Icons.Minus />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<input
|
||||
type="number"
|
||||
value={qty}
|
||||
min={1}
|
||||
max={max}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value) || 1;
|
||||
setQty(Math.min(Math.max(v, 1), max));
|
||||
}}
|
||||
className="w-20 text-center text-3xl font-black text-white bg-transparent border-b-2 border-red-600 outline-none pb-1 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<p className="text-xs text-stone-500 mt-1">{item.unit}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={increment}
|
||||
disabled={qty >= max}
|
||||
className="w-12 h-12 rounded-lg bg-stone-800 hover:bg-stone-700 disabled:opacity-30 disabled:cursor-not-allowed text-white flex items-center justify-center transition-all active:scale-95"
|
||||
>
|
||||
<Icons.Plus />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="bg-stone-950 border border-stone-800 rounded-xl p-4 mb-5 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-400">Unit price</span>
|
||||
<span className="text-white font-bold">
|
||||
${unitPrice.toFixed(2)}/{item.unit}
|
||||
{unitPrice < item.price && (
|
||||
<span className="text-green-500 text-xs ml-2">Bulk rate!</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-stone-800 pt-1.5">
|
||||
<span className="text-stone-400 font-bold">Line total</span>
|
||||
<span className="text-red-500 font-black text-lg">${lineTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm */}
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="w-full bg-red-700 hover:bg-red-600 active:scale-[0.98] text-white font-black uppercase tracking-widest py-4 rounded-xl transition-all shadow-lg shadow-red-900/30 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Icons.ShoppingCart />
|
||||
Add {qty}{item.unit} to Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user