From f7bb5ed752c446ec4dfa03b01172789cebac0447 Mon Sep 17 00:00:00 2001 From: muken Date: Wed, 1 Jul 2026 00:12:34 -0500 Subject: [PATCH] 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 --- src/app/api/orders/route.ts | 194 ++++++++++++++++ src/app/coffee/page.tsx | 432 +++++++++++++++++++++++++++++++++++- src/lib/schema.ts | 32 +++ 3 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 src/app/api/orders/route.ts diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts new file mode 100644 index 0000000..a7bc8ed --- /dev/null +++ b/src/app/api/orders/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/coffee/page.tsx b/src/app/coffee/page.tsx index df2b5d1..f6c13c3 100644 --- a/src/app/coffee/page.tsx +++ b/src/app/coffee/page.tsx @@ -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([]); + const [rooms, setRooms] = useState([]); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + // Order state + const [showOrderModal, setShowOrderModal] = useState(false); + const [selectedItems, setSelectedItems] = useState>(new Map()); + const [orderType, setOrderType] = useState<'pickup' | 'dine_in' | 'room_delivery'>('pickup'); + const [selectedRoom, setSelectedRoom] = useState(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 (
-

Coffee & Kitchen

-

- Locally sourced coffee, fresh pastries, and seasonal plates. -

+
+
+

Coffee & Kitchen

+

+ Locally sourced coffee, fresh pastries, and seasonal plates. +

+
+ +
{loading &&

Loading menu...

} {error &&

{error}

} @@ -130,6 +266,284 @@ export default function CoffeePage() { ))} + + {/* Order Modal */} + {showOrderModal && ( +
+
e.stopPropagation()} + > + {orderResult ? ( +
+
+ {orderResult.success ? '✓' : '✕'} +
+

{orderResult.success ? 'Order Placed!' : 'Error'}

+

{orderResult.message}

+ +
+ ) : ( + <> +

Place Your Order

+ + {/* Order Type Selection */} +
+ +
+ {[ + { value: 'pickup', label: 'Pickup' }, + { value: 'dine_in', label: 'Dine In' }, + { value: 'room_delivery', label: 'Room Delivery (+10%)' }, + ].map((opt) => ( + + ))} +
+
+ + {/* Room Selection for Delivery */} + {orderType === 'room_delivery' && user && ( +
+ + +
+ )} + + {/* Menu Items Selection */} +
+ +
+ {categories.map((category) => ( +
+
+ {category} +
+ {items + .filter((i) => i.category === category) + .map((item) => { + const qty = selectedItems.get(item.id) || 0; + return ( +
+
+
{item.name}
+
${item.price}
+
+
+ + {qty} + +
+
+ ); + })} +
+ ))} +
+
+ + {/* Notes */} +
+ +