feat: food ordering on coffee page
- Add 'Place Order' button to coffee page - Order modal with menu item selection by category - Order type options: pickup, dine in, room delivery (+10%) - Room delivery requires login (disabled for guests) - Calculate subtotal, service fee, and total - Special instructions field - Admin receives new orders in database - Create orders and order_items tables
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
const { items, order_type, room_id, notes } = await request.json();
|
||||
|
||||
// items is array of { menu_item_id, quantity }
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Order items are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!order_type || !['pickup', 'dine_in', 'room_delivery'].includes(order_type)) {
|
||||
return NextResponse.json({ error: 'Valid order type is required (pickup, dine_in, room_delivery)' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Room delivery requires login
|
||||
if (order_type === 'room_delivery' && !user) {
|
||||
return NextResponse.json({ error: 'Room delivery requires login. Please log in to continue.' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Room delivery requires room_id
|
||||
if (order_type === 'room_delivery' && !room_id) {
|
||||
return NextResponse.json({ error: 'Room selection is required for room delivery' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get menu item prices
|
||||
const menuItemIds = items.map((i: { menu_item_id: number }) => i.menu_item_id);
|
||||
const { rows: menuItems } = await db.query(
|
||||
'SELECT id, name, price FROM menu_items WHERE id = ANY($1)',
|
||||
[menuItemIds]
|
||||
);
|
||||
|
||||
if (menuItems.length !== menuItemIds.length) {
|
||||
return NextResponse.json({ error: 'One or more menu items not found' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Calculate total
|
||||
const priceMap = new Map(menuItems.map((m: { id: number; price: string }) => [m.id, parseFloat(m.price)]));
|
||||
let subtotal = 0;
|
||||
const orderItems = items.map((i: { menu_item_id: number; quantity: number }) => {
|
||||
const price = priceMap.get(i.menu_item_id) || 0;
|
||||
const itemTotal = price * i.quantity;
|
||||
subtotal += itemTotal;
|
||||
return {
|
||||
menu_item_id: i.menu_item_id,
|
||||
quantity: i.quantity,
|
||||
unit_price: price,
|
||||
total_price: itemTotal,
|
||||
};
|
||||
});
|
||||
|
||||
// Room delivery adds 10% service fee
|
||||
const serviceFee = order_type === 'room_delivery' ? subtotal * 0.1 : 0;
|
||||
const total = subtotal + serviceFee;
|
||||
|
||||
// Create order
|
||||
const { rows: orderRows } = await db.query(`
|
||||
INSERT INTO orders (user_id, order_type, room_id, subtotal, service_fee, total, status, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)
|
||||
RETURNING *
|
||||
`, [user?.id || null, order_type, order_type === 'room_delivery' ? room_id : null, subtotal, serviceFee, total, notes || null]);
|
||||
|
||||
const orderId = orderRows[0].id;
|
||||
|
||||
// Insert order items
|
||||
for (const item of orderItems) {
|
||||
await db.query(`
|
||||
INSERT INTO order_items (order_id, menu_item_id, quantity, unit_price, total_price)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [orderId, item.menu_item_id, item.quantity, item.unit_price, item.total_price]);
|
||||
}
|
||||
|
||||
// Get full order with items
|
||||
const { rows: fullOrder } = await db.query(`
|
||||
SELECT o.*,
|
||||
json_agg(json_build_object(
|
||||
'menu_item_id', oi.menu_item_id,
|
||||
'quantity', oi.quantity,
|
||||
'unit_price', oi.unit_price,
|
||||
'total_price', oi.total_price,
|
||||
'name', m.name
|
||||
)) as items
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON o.id = oi.order_id
|
||||
LEFT JOIN menu_items m ON oi.menu_item_id = m.id
|
||||
WHERE o.id = $1
|
||||
GROUP BY o.id
|
||||
`, [orderId]);
|
||||
|
||||
// Get admin notification targets
|
||||
const { rows: admins } = await db.query(
|
||||
'SELECT email, phone FROM users WHERE role = \'admin\' AND (email IS NOT NULL OR phone IS NOT NULL)'
|
||||
);
|
||||
|
||||
// TODO: Send notification to admins (email/SMS)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
order: fullOrder[0],
|
||||
message: order_type === 'room_delivery'
|
||||
? 'Order placed! We\'ll deliver it to your room shortly.'
|
||||
: order_type === 'dine_in'
|
||||
? 'Order placed! We\'ll prepare it for dine-in.'
|
||||
: 'Order placed! We\'ll have it ready for pickup.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Order error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create order' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'all';
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
|
||||
let query = `
|
||||
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
|
||||
FROM orders o
|
||||
LEFT JOIN rooms r ON o.room_id = r.id
|
||||
LEFT JOIN users u ON o.user_id = u.id
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
if (status !== 'all') {
|
||||
query += ' WHERE o.status = $1';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.created_at DESC LIMIT $' + (params.length + 1);
|
||||
params.push(limit);
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
|
||||
// Get items for each order
|
||||
for (const order of rows) {
|
||||
const { rows: items } = await db.query(`
|
||||
SELECT oi.*, m.name, m.description
|
||||
FROM order_items oi
|
||||
JOIN menu_items m ON oi.menu_item_id = m.id
|
||||
WHERE oi.order_id = $1
|
||||
`, [order.id]);
|
||||
order.items = items;
|
||||
}
|
||||
|
||||
return NextResponse.json({ orders: rows });
|
||||
} catch (error) {
|
||||
console.error('Get orders error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get orders' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const user = await getSession();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { order_id, status } = await request.json();
|
||||
|
||||
if (!order_id || !status) {
|
||||
return NextResponse.json({ error: 'order_id and status are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const validStatuses = ['pending', 'preparing', 'ready', 'delivered', 'cancelled'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
||||
[status, order_id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, order: rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Update order error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update order' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+419
-5
@@ -13,6 +13,19 @@ interface MenuItem {
|
||||
featured_photo?: string | null;
|
||||
}
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
@@ -20,15 +33,39 @@ const warm = '#a6683c';
|
||||
|
||||
export default function CoffeePage() {
|
||||
const [items, setItems] = useState<MenuItem[]>([]);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Order state
|
||||
const [showOrderModal, setShowOrderModal] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Map<number, number>>(new Map());
|
||||
const [orderType, setOrderType] = useState<'pickup' | 'dine_in' | 'room_delivery'>('pickup');
|
||||
const [selectedRoom, setSelectedRoom] = useState<number | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [orderResult, setOrderResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/menu')
|
||||
.then(async (res) => {
|
||||
Promise.all([
|
||||
fetch('/api/menu').then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to load menu');
|
||||
const json = await res.json();
|
||||
setItems(json.data || []);
|
||||
return res.json();
|
||||
}),
|
||||
fetch('/api/auth/session').then(async (res) => {
|
||||
if (res.ok) return res.json();
|
||||
return { user: null };
|
||||
}),
|
||||
fetch('/api/rooms').then(async (res) => {
|
||||
if (!res.ok) return { rooms: [] };
|
||||
return res.json();
|
||||
}),
|
||||
])
|
||||
.then(([menuData, sessionData, roomsData]) => {
|
||||
setItems(menuData.data || []);
|
||||
setUser(sessionData.user || null);
|
||||
setRooms(roomsData.rooms || []);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -37,12 +74,111 @@ export default function CoffeePage() {
|
||||
const categories = Array.from(new Set(items.map((i) => i.category)));
|
||||
const daily = items.find((i) => i.is_daily_special);
|
||||
|
||||
// Calculate order totals
|
||||
const orderItems = items.filter((item) => selectedItems.has(item.id) && (selectedItems.get(item.id) || 0) > 0);
|
||||
const subtotal = orderItems.reduce((sum, item) => {
|
||||
const qty = selectedItems.get(item.id) || 0;
|
||||
return sum + parseFloat(item.price) * qty;
|
||||
}, 0);
|
||||
const serviceFee = orderType === 'room_delivery' ? subtotal * 0.1 : 0;
|
||||
const total = subtotal + serviceFee;
|
||||
|
||||
const updateQuantity = (itemId: number, delta: number) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const current = newMap.get(itemId) || 0;
|
||||
const newQty = Math.max(0, current + delta);
|
||||
if (newQty === 0) {
|
||||
newMap.delete(itemId);
|
||||
} else {
|
||||
newMap.set(itemId, newQty);
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitOrder = async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
alert('Please select at least one item');
|
||||
return;
|
||||
}
|
||||
|
||||
if (orderType === 'room_delivery' && !selectedRoom) {
|
||||
alert('Please select a room for delivery');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: Array.from(selectedItems.entries()).map(([menu_item_id, quantity]) => ({
|
||||
menu_item_id,
|
||||
quantity,
|
||||
})),
|
||||
order_type: orderType,
|
||||
room_id: orderType === 'room_delivery' ? selectedRoom : null,
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to place order');
|
||||
}
|
||||
|
||||
setOrderResult({ success: true, message: data.message });
|
||||
setSelectedItems(new Map());
|
||||
setNotes('');
|
||||
} catch (err) {
|
||||
setOrderResult({ success: false, message: err instanceof Error ? err.message : 'Failed to place order' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowOrderModal(false);
|
||||
setOrderResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ padding: 'clamp(1rem, 5vw, 2rem)', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 6vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.25rem' }}>Coffee & Kitchen</h1>
|
||||
<p style={{ color: '#555', marginBottom: '1.5rem', maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
|
||||
<p style={{ color: '#555', marginBottom: 0, maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
|
||||
Locally sourced coffee, fresh pastries, and seasonal plates.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowOrderModal(true)}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'clamp(14px, 3vw, 16px)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(232,168,73,0.3)',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(232,168,73,0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(232,168,73,0.3)';
|
||||
}}
|
||||
>
|
||||
Place Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <p style={{ color: warm }}>Loading menu...</p>}
|
||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||
@@ -130,6 +266,284 @@ export default function CoffeePage() {
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Order Modal */}
|
||||
{showOrderModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={closeModal}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
maxWidth: 'min(95vw, 600px)',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
padding: 'clamp(1rem, 4vw, 1.5rem)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{orderResult ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '1rem' }}>
|
||||
{orderResult.success ? '✓' : '✕'}
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy }}>{orderResult.success ? 'Order Placed!' : 'Error'}</h3>
|
||||
<p style={{ color: '#555', marginBottom: '1.5rem' }}>{orderResult.message}</p>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
background: navy,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.75rem 2rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Place Your Order</h3>
|
||||
|
||||
{/* Order Type Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Order Type</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'pickup', label: 'Pickup' },
|
||||
{ value: 'dine_in', label: 'Dine In' },
|
||||
{ value: 'room_delivery', label: 'Room Delivery (+10%)' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setOrderType(opt.value as typeof orderType)}
|
||||
disabled={opt.value === 'room_delivery' && !user}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: '120px',
|
||||
padding: '0.75rem 1rem',
|
||||
border: `2px solid ${orderType === opt.value ? gold : '#ddd'}`,
|
||||
borderRadius: '8px',
|
||||
background: orderType === opt.value ? gold : 'white',
|
||||
color: orderType === opt.value ? 'white' : opt.value === 'room_delivery' && !user ? '#aaa' : navy,
|
||||
cursor: opt.value === 'room_delivery' && !user ? 'not-allowed' : 'pointer',
|
||||
opacity: opt.value === 'room_delivery' && !user ? 0.5 : 1,
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === 'room_delivery' && !user && (
|
||||
<div style={{ fontSize: '11px', marginTop: '2px' }}>Login required</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Selection for Delivery */}
|
||||
{orderType === 'room_delivery' && user && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Room</label>
|
||||
<select
|
||||
value={selectedRoom || ''}
|
||||
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<option value="">Select a room...</option>
|
||||
{rooms.map((room) => (
|
||||
<option key={room.id} value={room.id}>{room.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Items Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Items</label>
|
||||
<div style={{ maxHeight: '300px', overflow: 'auto', border: '2px solid #ddd', borderRadius: '8px' }}>
|
||||
{categories.map((category) => (
|
||||
<div key={category}>
|
||||
<div style={{ background: '#f5f5f5', padding: '0.5rem 1rem', fontWeight: 600, color: navy, borderBottom: '1px solid #ddd' }}>
|
||||
{category}
|
||||
</div>
|
||||
{items
|
||||
.filter((i) => i.category === category)
|
||||
.map((item) => {
|
||||
const qty = selectedItems.get(item.id) || 0;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid #eee',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, color: navy }}>{item.name}</div>
|
||||
<div style={{ color: warm, fontWeight: 600 }}>${item.price}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, -1)}
|
||||
disabled={qty === 0}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
border: `2px solid ${qty > 0 ? navy : '#ddd'}`,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
color: qty > 0 ? navy : '#aaa',
|
||||
cursor: qty > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span style={{ minWidth: '24px', textAlign: 'center', fontWeight: 600 }}>{qty}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, 1)}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
border: `2px solid ${navy}`,
|
||||
borderRadius: '50%',
|
||||
background: navy,
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Special Instructions (optional)</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any allergies, preferences, or special requests..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
minHeight: '80px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
{selectedItems.size > 0 && (
|
||||
<div style={{ background: cream, borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem' }}>
|
||||
<h4 style={{ margin: '0 0 0.5rem', color: navy }}>Order Summary</h4>
|
||||
{orderItems.map((item) => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px', marginBottom: '0.25rem' }}>
|
||||
<span>{item.name} × {selectedItems.get(item.id)}</span>
|
||||
<span>${(parseFloat(item.price) * (selectedItems.get(item.id) || 0)).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ borderTop: '1px solid #ddd', marginTop: '0.5rem', paddingTop: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{orderType === 'room_delivery' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: warm }}>
|
||||
<span>Service Fee (10%)</span>
|
||||
<span>${serviceFee.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, marginTop: '0.25rem', fontSize: '16px' }}>
|
||||
<span>Total</span>
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
background: 'white',
|
||||
color: navy,
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitOrder}
|
||||
disabled={submitting || selectedItems.size === 0}
|
||||
style={{
|
||||
flex: 2,
|
||||
padding: '0.75rem',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: submitting || selectedItems.size === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: submitting || selectedItems.size === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Placing Order...' : `Place Order • $${total.toFixed(2)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -375,6 +375,38 @@ export async function initSchema() {
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_reservations_room_dates ON reservations(room_id, check_in, check_out)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_availability_room_date ON room_availability(room_id, date)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC)`);
|
||||
|
||||
// Orders tables for coffee/kitchen
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
order_type VARCHAR(20) NOT NULL,
|
||||
room_id INTEGER REFERENCES rooms(id),
|
||||
subtotal DECIMAL(10,2) NOT NULL,
|
||||
service_fee DECIMAL(10,2) DEFAULT 0,
|
||||
total DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
menu_item_id INTEGER NOT NULL REFERENCES menu_items(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price DECIMAL(10,2) NOT NULL,
|
||||
total_price DECIMAL(10,2) NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
|
||||
}
|
||||
|
||||
export async function migrateFromLegacyPlaces() {
|
||||
|
||||
Reference in New Issue
Block a user