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
-1
@@ -1813,7 +1813,6 @@
|
||||
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.3",
|
||||
"@prisma/engines": "6.19.3"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "StockItem" ADD COLUMN "maxStock" REAL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FAQ" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"question" TEXT NOT NULL,
|
||||
"answer" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
||||
"adminCode" TEXT NOT NULL DEFAULT '1234',
|
||||
"finalDepositDate" TEXT,
|
||||
"cookDay" TEXT,
|
||||
"payIdInfo" TEXT DEFAULT 'Your PayID Here',
|
||||
"termsOfService" TEXT DEFAULT 'To secure your order, a 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.',
|
||||
"orderingPolicy" TEXT DEFAULT 'Deposits cover material costs and are final. PayID details provided after ordering.'
|
||||
);
|
||||
INSERT INTO "new_Config" ("adminCode", "id") SELECT "adminCode", "id" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
CREATE TABLE "new_Order" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"orderNumber" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"items" TEXT NOT NULL,
|
||||
"totalWeight" REAL NOT NULL,
|
||||
"estimatedTotal" REAL NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'Pending',
|
||||
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO "new_Order" ("estimatedTotal", "id", "items", "name", "phone", "timestamp", "totalWeight") SELECT "estimatedTotal", "id", "items", "name", "phone", "timestamp", "totalWeight" FROM "Order";
|
||||
DROP TABLE "Order";
|
||||
ALTER TABLE "new_Order" RENAME TO "Order";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
||||
"adminCode" TEXT NOT NULL DEFAULT '1234',
|
||||
"finalDepositDate" TEXT,
|
||||
"cookDay" TEXT,
|
||||
"payIdInfo" TEXT DEFAULT 'Your PayID Here',
|
||||
"termsOfService" TEXT DEFAULT 'To secure your order, a 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.',
|
||||
"orderingPolicy" TEXT DEFAULT 'Deposits cover material costs and are final. PayID details provided after ordering.',
|
||||
"depositPercentage" INTEGER NOT NULL DEFAULT 30
|
||||
);
|
||||
INSERT INTO "new_Config" ("adminCode", "cookDay", "finalDepositDate", "id", "orderingPolicy", "payIdInfo", "termsOfService") SELECT "adminCode", "cookDay", "finalDepositDate", "id", "orderingPolicy", "payIdInfo", "termsOfService" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -43,4 +43,14 @@ model Config {
|
||||
finalDepositDate String?
|
||||
cookDay String?
|
||||
payIdInfo String? @default("Your PayID Here")
|
||||
termsOfService String? @default("To secure your order, a 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.")
|
||||
orderingPolicy String? @default("Deposits cover material costs and are final. PayID details provided after ordering.")
|
||||
depositPercentage Int @default(30)
|
||||
}
|
||||
|
||||
model FAQ {
|
||||
id Int @id @default(autoincrement())
|
||||
question String
|
||||
answer String
|
||||
order Int @default(0)
|
||||
}
|
||||
+72
-5
@@ -13,12 +13,13 @@ app.use(express.json());
|
||||
|
||||
// API Routes
|
||||
|
||||
// Fetch all data (Stock, Orders, Config)
|
||||
// Fetch all data (Stock, Orders, Config, FAQs)
|
||||
app.get('/api/data', async (req, res) => {
|
||||
try {
|
||||
let stock = await prisma.stockItem.findMany();
|
||||
const orders = await prisma.order.findMany();
|
||||
const config = await prisma.config.findFirst();
|
||||
const faqs = await prisma.fAQ.findMany({ orderBy: { order: 'asc' } });
|
||||
|
||||
// Parse order items from string to JSON
|
||||
const parsedOrders = orders.map(order => ({
|
||||
@@ -64,7 +65,7 @@ app.get('/api/data', async (req, res) => {
|
||||
// Security: Don't send the admin code to the client
|
||||
const configResponse = config ? { ...config, adminCode: undefined } : null;
|
||||
|
||||
res.json({ stock: stockWithRemaining, orders: parsedOrders, config: configResponse });
|
||||
res.json({ stock: stockWithRemaining, orders: parsedOrders, config: configResponse, faqs });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Failed to fetch data' });
|
||||
@@ -75,13 +76,17 @@ app.get('/api/data', async (req, res) => {
|
||||
app.post('/api/login', async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
console.log(`Login attempt with code: ${code}`);
|
||||
const config = await prisma.config.findFirst();
|
||||
if (config && config.adminCode === code) {
|
||||
console.log('Login successful');
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
console.log(`Login failed. Expected: ${config?.adminCode}, Got: ${code}`);
|
||||
res.status(401).json({ error: 'Invalid admin code' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
@@ -236,12 +241,15 @@ app.delete('/api/stock/:id', async (req, res) => {
|
||||
// Update Config
|
||||
app.post('/api/config', async (req, res) => {
|
||||
try {
|
||||
const { adminCode, finalDepositDate, cookDay, payIdInfo } = req.body;
|
||||
const { adminCode, finalDepositDate, cookDay, payIdInfo, termsOfService, orderingPolicy, depositPercentage } = req.body;
|
||||
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 (termsOfService !== undefined) data.termsOfService = termsOfService;
|
||||
if (orderingPolicy !== undefined) data.orderingPolicy = orderingPolicy;
|
||||
if (depositPercentage !== undefined) data.depositPercentage = parseInt(depositPercentage);
|
||||
|
||||
// Use upsert to ensure we always use ID 1
|
||||
await prisma.config.upsert({
|
||||
@@ -257,19 +265,58 @@ app.post('/api/config', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update or Create FAQ Item
|
||||
app.post('/api/faqs', async (req, res) => {
|
||||
try {
|
||||
const item = req.body;
|
||||
if (item.id) {
|
||||
const updated = await prisma.fAQ.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
...item,
|
||||
id: undefined // Don't update ID
|
||||
}
|
||||
});
|
||||
res.json(updated);
|
||||
} else {
|
||||
const created = await prisma.fAQ.create({ data: item });
|
||||
res.json(created);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Failed to update FAQ' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete FAQ Item
|
||||
app.delete('/api/faqs/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.fAQ.delete({ where: { id: parseInt(id) } });
|
||||
res.sendStatus(200);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Failed to delete FAQ item' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset Database to defaults
|
||||
app.post('/api/reset', async (req, res) => {
|
||||
try {
|
||||
await prisma.order.deleteMany();
|
||||
await prisma.stockItem.deleteMany();
|
||||
await prisma.config.deleteMany();
|
||||
await prisma.fAQ.deleteMany();
|
||||
|
||||
// Seed default admin code and limits with ID 1
|
||||
await prisma.config.create({
|
||||
data: {
|
||||
id: 1,
|
||||
adminCode: '1234',
|
||||
payIdInfo: 'Your PayID Here'
|
||||
payIdInfo: 'Your PayID Here',
|
||||
termsOfService: 'To secure your order, a 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.',
|
||||
orderingPolicy: 'Deposits cover material costs and are final. PayID details provided after ordering.',
|
||||
depositPercentage: 30
|
||||
}
|
||||
});
|
||||
|
||||
@@ -280,6 +327,14 @@ app.post('/api/reset', async (req, res) => {
|
||||
{ name: 'Pork Shoulder', category: 'Pork', price: 35, unit: 'kg', description: 'Pulled pork perfection', available: true }
|
||||
]
|
||||
});
|
||||
|
||||
// Seed some default FAQs
|
||||
await prisma.fAQ.createMany({
|
||||
data: [
|
||||
{ question: 'When is pickup?', answer: 'Pickups are usually scheduled for the afternoon of the Cook Day. We will contact you to confirm the exact time.', order: 1 },
|
||||
{ question: 'How do I pay the deposit?', answer: 'Once you submit your order, you can pay the 30% deposit via PayID or Cash.', order: 2 }
|
||||
]
|
||||
});
|
||||
|
||||
res.sendStatus(200);
|
||||
} catch (error) {
|
||||
@@ -299,6 +354,18 @@ app.get(/^\/(?!api).*/, (req, res) => {
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
// Ensure a default config exists
|
||||
const config = await prisma.config.findFirst();
|
||||
if (!config) {
|
||||
await prisma.config.create({
|
||||
data: {
|
||||
id: 1,
|
||||
adminCode: '1234',
|
||||
payIdInfo: 'Your PayID Here'
|
||||
}
|
||||
});
|
||||
console.log('Default config seeded');
|
||||
}
|
||||
console.log(`Server is running on http://localhost:${PORT}`);
|
||||
});
|
||||
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