feat: Add configurable deposit percentage, FAQ management, and fix PayID copy button
This commit is contained in:
1 parent
d34950a91b
commit
27f3d73d01
12 files changed
+1111
-334
No files matched your search
Generated
+779
-308
File diff suppressed because it is too large.
Load diff
@@ -21,7 +21,7 @@
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -32,6 +32,6 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -10,6 +10,7 @@ function App() {
|
||||
const [view, setView] = useState<'customer' | 'admin'>('customer');
|
||||
const [stock, setStock] = useState<any[]>([]);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [faqs, setFaqs] = useState<any[]>([]);
|
||||
const [cart, setCart] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showCartModal, setShowCartModal] = useState(false);
|
||||
@@ -25,19 +26,28 @@ function App() {
|
||||
const data = await API.fetchData();
|
||||
setStock(data.stock);
|
||||
setOrders(data.orders.sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()));
|
||||
setFaqs(data.faqs || []);
|
||||
if (data.config) {
|
||||
setConfig(data.config);
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.error("Server connection failed.", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Force a minimum loading time of 5 seconds
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 5000);
|
||||
|
||||
refreshData();
|
||||
const intervalId = setInterval(refreshData, 5000);
|
||||
return () => clearInterval(intervalId);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
const showNotification = (msg: string) => {
|
||||
setNotification(msg);
|
||||
@@ -158,7 +168,7 @@ 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} onAddToCart={(item: any) => setPickerItem(item)} config={config} /> : <AdminDashboard stock={stock} orders={orders} config={config} refreshData={refreshData} notify={showNotification} promptConfirm={promptConfirm} />}
|
||||
{view === 'customer' ? <CustomerView stock={stock} faqs={faqs} onAddToCart={(item: any) => setPickerItem(item)} config={config} /> : <AdminDashboard stock={stock} orders={orders} config={config} faqs={faqs} refreshData={refreshData} notify={showNotification} promptConfirm={promptConfirm} />}
|
||||
</main>
|
||||
|
||||
{showCartModal && <CartModal cart={cart} config={config} onClose={() => setShowCartModal(false)} onRemove={removeFromCart} onUpdateQty={updateCartQty} onSubmit={submitOrder} />}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
|
||||
import { Icons } from './Icons';
|
||||
import { API } from '../services/api';
|
||||
|
||||
export default function AdminDashboard({ stock, orders, config, refreshData, notify, promptConfirm }: any) {
|
||||
const [tab, setTab] = useState<'orders' | 'stock' | 'settings'>('orders');
|
||||
export default function AdminDashboard({ stock, orders, config, faqs, refreshData, notify, promptConfirm }: any) {
|
||||
const [tab, setTab] = useState<'orders' | 'stock' | 'faqs' | 'settings'>('orders');
|
||||
const [newAdminCode, setNewAdminCode] = useState('');
|
||||
const [settingsForm, setSettingsForm] = useState<any>(null);
|
||||
|
||||
@@ -12,7 +12,10 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
setSettingsForm({
|
||||
finalDepositDate: config.finalDepositDate || '',
|
||||
cookDay: config.cookDay || '',
|
||||
payIdInfo: config.payIdInfo || ''
|
||||
payIdInfo: config.payIdInfo || '',
|
||||
termsOfService: config.termsOfService || '',
|
||||
orderingPolicy: config.orderingPolicy || '',
|
||||
depositPercentage: config.depositPercentage || 30
|
||||
});
|
||||
}
|
||||
}, [config, settingsForm]);
|
||||
@@ -72,6 +75,32 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
});
|
||||
};
|
||||
|
||||
const addNewFAQ = async () => {
|
||||
const newFAQ = {
|
||||
question: "New Question?",
|
||||
answer: "Answer here...",
|
||||
order: faqs.length + 1
|
||||
};
|
||||
await API.updateFAQ(newFAQ);
|
||||
refreshData();
|
||||
notify("New FAQ Added");
|
||||
};
|
||||
|
||||
const updateFAQ = async (id: number, updates: any) => {
|
||||
const currentFAQ = faqs.find((f: any) => f.id === id) || { id };
|
||||
await API.updateFAQ({ ...currentFAQ, ...updates });
|
||||
refreshData();
|
||||
notify("FAQ updated");
|
||||
};
|
||||
|
||||
const deleteFAQ = (id: number) => {
|
||||
promptConfirm("Delete this FAQ?", async () => {
|
||||
await API.deleteFAQ(id);
|
||||
refreshData();
|
||||
notify("FAQ deleted");
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllOrders = () => {
|
||||
promptConfirm("WARNING: This will delete ALL current order history. Proceed?", async () => {
|
||||
await API.clearOrders();
|
||||
@@ -93,6 +122,7 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
<div className="flex border-b border-stone-800 bg-stone-950">
|
||||
<button onClick={() => setTab('orders')} className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'orders' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}>Requests</button>
|
||||
<button onClick={() => setTab('stock')} className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'stock' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}>Inventory</button>
|
||||
<button onClick={() => setTab('faqs')} className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'faqs' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}>FAQ</button>
|
||||
<button onClick={() => setTab('settings')} className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'settings' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}>Settings</button>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +144,23 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'faqs' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-end border-b border-stone-800 pb-4">
|
||||
<h3 className="text-xl font-bold text-white">FAQ Management</h3>
|
||||
<p className="text-xs text-stone-500">Edit frequently asked questions</p>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{faqs.map((faq: any) => (
|
||||
<AdminFAQItem key={faq.id} item={faq} onUpdate={(data: any) => updateFAQ(faq.id, data)} onDelete={() => deleteFAQ(faq.id)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-10 border-t border-stone-800 mt-10">
|
||||
<button onClick={addNewFAQ} className="bg-stone-800 hover:bg-stone-700 text-white px-4 py-2 rounded font-bold flex items-center gap-2 border border-stone-700"><Icons.Plus /> Add New FAQ</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'orders' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
@@ -227,6 +274,35 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
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">Deposit Percentage (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={settingsForm.depositPercentage}
|
||||
onChange={(e) => setSettingsForm({ ...settingsForm, depositPercentage: e.target.value })}
|
||||
className="w-32 bg-stone-900 border border-stone-700 rounded p-3 text-white focus:border-red-600 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-bold text-red-500 uppercase tracking-widest border-b border-stone-800 pb-2 pt-4">Policies & TOS</h4>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-stone-500 uppercase mb-2">Terms of Service</label>
|
||||
<textarea
|
||||
value={settingsForm.termsOfService}
|
||||
onChange={(e) => setSettingsForm({ ...settingsForm, termsOfService: e.target.value })}
|
||||
className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white focus:border-red-600 outline-none h-24 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-stone-500 uppercase mb-2">Ordering Policy Footer</label>
|
||||
<textarea
|
||||
value={settingsForm.orderingPolicy}
|
||||
onChange={(e) => setSettingsForm({ ...settingsForm, orderingPolicy: e.target.value })}
|
||||
className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white focus:border-red-600 outline-none h-24 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
@@ -256,6 +332,64 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not
|
||||
);
|
||||
}
|
||||
|
||||
function AdminFAQItem({ item, onUpdate, onDelete }: any) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditData({
|
||||
question: item.question || "",
|
||||
answer: item.answer || "",
|
||||
order: item.order || 0
|
||||
});
|
||||
}
|
||||
}, [item, isEditing]);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate({
|
||||
...editData,
|
||||
order: parseInt(editData.order)
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-stone-950 p-4 rounded-lg border border-stone-800 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{!isEditing ? (
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-white text-lg">{item.question}</p>
|
||||
<p className="text-sm text-stone-400">{item.answer}</p>
|
||||
<p className="text-[10px] text-stone-600 mt-2 uppercase font-bold">Display Order: {item.order}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-2 mr-4">
|
||||
<input type="text" value={editData.question} onChange={e => setEditData({ ...editData, question: e.target.value })} className="bg-black text-white border border-stone-700 rounded px-2 py-1 font-bold w-full" placeholder="Question" />
|
||||
<textarea value={editData.answer} onChange={e => setEditData({ ...editData, answer: e.target.value })} className="bg-black text-stone-400 border border-stone-700 rounded px-2 py-1 text-sm w-full h-24" placeholder="Answer" />
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-stone-500 uppercase font-bold">Order:</label>
|
||||
<input type="number" value={editData.order} onChange={e => setEditData({ ...editData, order: e.target.value })} className="bg-black text-white border border-stone-700 rounded px-2 py-0.5 text-xs w-20" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button onClick={handleSave} className="p-2 bg-green-600 hover:bg-green-500 text-white rounded"><Icons.Save /></button>
|
||||
<button onClick={onDelete} className="p-2 bg-red-900 hover:bg-red-700 text-white rounded"><Icons.Trash /></button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => setIsEditing(true)} className="p-2 bg-stone-800 hover:bg-stone-700 text-stone-400 hover:text-white rounded"><Icons.Edit /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminStockItem({ item, onUpdate, onDelete }: any) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<any>({});
|
||||
|
||||
@@ -31,7 +31,8 @@ export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubm
|
||||
}, 0);
|
||||
|
||||
const totalWeight = cart.reduce((acc, i) => acc + i.qty, 0);
|
||||
const depositAmount = total * 0.3;
|
||||
const depositPct = config?.depositPercentage ?? 30;
|
||||
const depositAmount = total * (depositPct / 100);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name || !phone) return;
|
||||
@@ -62,14 +63,21 @@ export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubm
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="text-stone-500 mt-1"><Icons.Alert /></div>
|
||||
<p className="text-sm text-stone-300">A <span className="text-white font-bold">30% deposit of ${depositAmount.toFixed(2)}</span> is required before we start the smoker.</p>
|
||||
<p className="text-sm text-stone-300">A <span className="text-white font-bold">{depositPct}% deposit of ${depositAmount.toFixed(2)}</span> is required before we start the smoker.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-stone-500 uppercase">Option 1: PayID (Instant)</p>
|
||||
<div className="bg-stone-900 p-3 rounded border border-red-900/30 text-white font-mono flex justify-between items-center group">
|
||||
<button
|
||||
onClick={() => {
|
||||
const payId = config?.payIdInfo || '0400 000 000';
|
||||
navigator.clipboard.writeText(payId);
|
||||
// Optional: Add UI feedback here if needed, but the user just wanted it to work
|
||||
}}
|
||||
className="w-full bg-stone-900 p-3 rounded border border-red-900/30 text-white font-mono flex justify-between items-center group hover:border-red-600 transition-colors"
|
||||
>
|
||||
<span>{config?.payIdInfo || '0400 000 000'}</span>
|
||||
<span className="text-[8px] uppercase bg-red-900/50 px-1 rounded text-red-400">Copy</span>
|
||||
</div>
|
||||
<span className="text-[8px] uppercase bg-red-900/50 px-1 rounded text-red-400 group-hover:bg-red-600 group-hover:text-white transition-colors">Copy</span>
|
||||
</button>
|
||||
<p className="text-[10px] text-stone-500 italic">Please use "{submittedOrder.orderNumber}" as the payment description.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -159,14 +167,14 @@ export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubm
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-red-500 text-2xl font-black italic uppercase tracking-tighter">
|
||||
<span>30% Deposit Due</span>
|
||||
<span>{depositPct}% Deposit Due</span>
|
||||
<span>${depositAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<p>Order will not be cooked until the {depositPct}% deposit is paid. We accept PayID and Cash.</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
||||
@@ -78,7 +78,7 @@ function StockCard({ item, onAdd, disabled }: { item: any, onAdd?: () => void, d
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerView({ stock, onAddToCart, config }: { stock: any[], onAddToCart: (item: any) => void, config?: any }) {
|
||||
export default function CustomerView({ stock, faqs, onAddToCart, config }: { stock: any[], faqs: any[], onAddToCart: (item: any) => void, config?: any }) {
|
||||
const availableStock = stock.filter(item => item.available);
|
||||
const soldOutStock = stock.filter(item => !item.available);
|
||||
|
||||
@@ -114,9 +114,7 @@ export default function CustomerView({ stock, onAddToCart, config }: { stock: an
|
||||
Terms of Service & Ordering Policy
|
||||
</p>
|
||||
<p className="text-stone-300 text-sm leading-relaxed">
|
||||
To secure your order, a <span className="text-white font-bold underline">30% non-refundable deposit</span> is required upfront before smoking begins.
|
||||
Payments can be made via <span className="text-red-400 font-bold">PayID</span> or <span className="text-red-400 font-bold">Cash</span>.
|
||||
The remaining balance is payable upon pickup.
|
||||
{config?.termsOfService || `To secure your order, a ${config?.depositPercentage || 30}% non-refundable deposit is required upfront before smoking begins. Payments can be made via PayID or Cash. The remaining balance is payable upon pickup.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +130,7 @@ export default function CustomerView({ stock, onAddToCart, config }: { stock: an
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-stone-500 italic flex items-center gap-1">
|
||||
<Icons.Check /> Deposits cover material costs and are final. PayID details provided after ordering.
|
||||
<Icons.Check /> {config?.orderingPolicy || 'Deposits cover material costs and are final. PayID details provided after ordering.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,6 +155,20 @@ export default function CustomerView({ stock, onAddToCart, config }: { stock: an
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{faqs && faqs.length > 0 && (
|
||||
<div className="pt-12 border-t border-stone-800 mt-20">
|
||||
<h3 className="text-2xl font-bold text-stone-300 mb-8 flex items-center gap-2 uppercase italic tracking-tighter"><Icons.Alert /> Frequently Asked Questions</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{faqs.map(faq => (
|
||||
<div key={faq.id} className="space-y-2">
|
||||
<h4 className="font-bold text-red-500 text-lg uppercase tracking-tight">{faq.question}</h4>
|
||||
<p className="text-stone-400 text-sm leading-relaxed">{faq.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,12 @@ export const API = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg)
|
||||
}),
|
||||
updateFAQ: (faq: any) => fetch(`${API_BASE}/api/faqs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(faq)
|
||||
}).then(res => res.json()),
|
||||
deleteFAQ: (id: number) => fetch(`${API_BASE}/api/faqs/${id}`, { method: 'DELETE' }),
|
||||
resetDB: () => fetch(`${API_BASE}/api/reset`, { method: 'POST' }),
|
||||
login: async (code: string) => {
|
||||
const res = await fetch(`${API_BASE}/api/login`, {
|
||||
|
||||
Reference in new issue
Block a user