From ef38c42e17a01daf3f4e8177ffabec862ca0ade5 Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 5 Jun 2026 20:03:52 -0400 Subject: [PATCH] Add Orders tab to admin panel for managing online orders Fetches open orders from Square filtered by source=online-shop metadata. Each order shows customer, fulfillment time/address, items, and total with a Mark Complete button that updates the order state in Square directly. Co-Authored-By: Claude Sonnet 4.6 --- estore/src/app/admin/page.tsx | 163 +++++++++++++++++- .../admin/orders/[orderId]/complete/route.ts | 39 +++++ estore/src/app/api/admin/orders/route.ts | 61 +++++++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 estore/src/app/api/admin/orders/[orderId]/complete/route.ts create mode 100644 estore/src/app/api/admin/orders/route.ts diff --git a/estore/src/app/admin/page.tsx b/estore/src/app/admin/page.tsx index b326dd6..4fc0c09 100644 --- a/estore/src/app/admin/page.tsx +++ b/estore/src/app/admin/page.tsx @@ -753,6 +753,161 @@ function DeliveryRatesEditor() { ) } +// ─── Orders Panel ───────────────────────────────────────────────────────────── + +interface AdminOrder { + id: string + state: string + createdAt: string + customerName: string + customerPhone: string + address: string | null + type: 'delivery' | 'pickup' + slotISO: string | null + lineItems: { name: string; quantity: string; note: string | null }[] + totalCents: number | null + version: number +} + +function fmtSlot(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + timeZone: 'America/New_York', + month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: true, + }) +} + +function OrdersPanel() { + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [completing, setCompleting] = useState(null) + const [completeMsg, setCompleteMsg] = useState>({}) + + const load = useCallback(async () => { + setLoading(true) + setError('') + try { + const res = await fetch(BASE + '/api/admin/orders') + const data = await res.json() + if (!res.ok) { setError(data.error ?? 'Failed to load orders'); return } + setOrders(data.orders) + } catch { + setError('Network error') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + async function handleComplete(orderId: string) { + if (!confirm('Mark this order as complete in Square?')) return + setCompleting(orderId) + try { + const res = await fetch(`${BASE}/api/admin/orders/${orderId}/complete`, { method: 'POST' }) + const data = await res.json() + if (res.ok) { + setOrders((prev) => prev.filter((o) => o.id !== orderId)) + setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Done' })) + } else { + setCompleteMsg((prev) => ({ ...prev, [orderId]: data.error ?? 'Failed' })) + } + } catch { + setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Network error' })) + } finally { + setCompleting(null) + } + } + + return ( +
+
+

+ Open orders placed via the online shop. Marking complete updates the order in Square. +

+ +
+ + {error &&

{error}

} + + {loading ? ( +

Loading orders…

+ ) : orders.length === 0 ? ( +

No open online orders.

+ ) : ( +
+ {orders.map((order) => ( +
+
+ + {/* Order info */} +
+
+ {order.customerName} + {order.customerPhone && ( + {order.customerPhone} + )} + + {order.type === 'delivery' ? '🚗 Delivery' : '🏪 Pickup'} + +
+ + {order.slotISO && ( +

+ {fmtSlot(order.slotISO)} + {order.address && · {order.address}} +

+ )} + +
+ {order.lineItems.map((li, i) => ( + + {i > 0 && ', '} + {li.quantity}× {li.name} + {li.note && ({li.note})} + + ))} +
+
+ + {/* Right side: total + actions */} +
+ {order.totalCents != null && ( + ${(order.totalCents / 100).toFixed(2)} + )} + + {completeMsg[order.id] && ( + {completeMsg[order.id]} + )} + + {new Date(order.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + +
+ +
+
+ ))} +
+ )} +
+ ) +} + // ─── Item Editor ────────────────────────────────────────────────────────────── function ItemEditor({ @@ -1550,7 +1705,7 @@ export default function AdminPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [search, setSearch] = useState('') - const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery'>('items') + const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery' | 'orders'>('items') const [fetchedAt, setFetchedAt] = useState(null) const [refreshing, setRefreshing] = useState(false) const [refreshMsg, setRefreshMsg] = useState('') @@ -1763,6 +1918,9 @@ export default function AdminPage() {
  • setTab('delivery')}>Delivery rates
  • +
  • + setTab('orders')}>Orders +
  • @@ -1868,6 +2026,9 @@ export default function AdminPage() { {/* Delivery rates tab */} {tab === 'delivery' && } + + {/* Orders tab */} + {tab === 'orders' && } ) diff --git a/estore/src/app/api/admin/orders/[orderId]/complete/route.ts b/estore/src/app/api/admin/orders/[orderId]/complete/route.ts new file mode 100644 index 0000000..3cc39ab --- /dev/null +++ b/estore/src/app/api/admin/orders/[orderId]/complete/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { Client, Environment } from 'square' + +function getClient() { + return new Client({ + accessToken: process.env.SQUARE_ACCESS_TOKEN!, + environment: process.env.SQUARE_ENVIRONMENT === 'production' + ? Environment.Production + : Environment.Sandbox, + }) +} + +export async function POST( + _req: NextRequest, + { params }: { params: { orderId: string } } +) { + try { + const client = getClient() + const { orderId } = params + + // Retrieve current order to get version (required for optimistic concurrency) + const { result: { order } } = await client.ordersApi.retrieveOrder(orderId) + if (!order) return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + + await client.ordersApi.updateOrder(orderId, { + order: { + locationId: order.locationId!, + state: 'COMPLETED', + version: order.version, + }, + idempotencyKey: `complete-${orderId}`, + }) + + return NextResponse.json({ ok: true }) + } catch (err) { + console.error('[admin/orders/complete]', err) + return NextResponse.json({ error: 'Failed to complete order' }, { status: 500 }) + } +} diff --git a/estore/src/app/api/admin/orders/route.ts b/estore/src/app/api/admin/orders/route.ts new file mode 100644 index 0000000..fec2fcb --- /dev/null +++ b/estore/src/app/api/admin/orders/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server' +import { Client, Environment } from 'square' + +function getClient() { + return new Client({ + accessToken: process.env.SQUARE_ACCESS_TOKEN!, + environment: process.env.SQUARE_ENVIRONMENT === 'production' + ? Environment.Production + : Environment.Sandbox, + }) +} + +export async function GET() { + try { + const client = getClient() + const locationId = process.env.SQUARE_LOCATION_ID! + + const response = await client.ordersApi.searchOrders({ + locationIds: [locationId], + query: { + filter: { stateFilter: { states: ['OPEN'] } }, + sort: { sortField: 'CREATED_AT', sortOrder: 'DESC' }, + }, + limit: 100, + }) + + const orders = (response.result.orders ?? []) + .filter((o) => o.metadata?.source === 'online-shop') + .map((o) => { + const fulfillment = o.fulfillments?.[0] + const pickup = fulfillment?.pickupDetails + const delivery = fulfillment?.deliveryDetails + const recipient = pickup?.recipient ?? delivery?.recipient + const slotISO = pickup?.pickupAt ?? delivery?.deliverAt ?? null + const type = fulfillment?.type ?? 'PICKUP' + + return { + id: o.id, + state: o.state, + createdAt: o.createdAt, + customerName: recipient?.displayName ?? '—', + customerPhone: recipient?.phoneNumber ?? '', + address: delivery?.recipient?.address?.addressLine1 ?? null, + type: type === 'DELIVERY' ? 'delivery' : 'pickup', + slotISO, + lineItems: (o.lineItems ?? []).map((li) => ({ + name: li.name ?? '', + quantity: li.quantity ?? '1', + note: li.note ?? null, + })), + totalCents: o.totalMoney?.amount != null ? Number(o.totalMoney.amount) : null, + version: o.version ?? 1, + } + }) + + return NextResponse.json({ orders }) + } catch (err) { + console.error('[admin/orders]', err) + return NextResponse.json({ error: 'Failed to fetch orders' }, { status: 500 }) + } +}