From 7b3ab5901e423156a741da1feecf142656d2c56f Mon Sep 17 00:00:00 2001
From: Benson Cho <100653148+choden-dev@users.noreply.github.com>
Date: Sat, 25 Apr 2026 19:16:15 +1200
Subject: [PATCH 1/4] feat: distinguish 'change' vs 'select' timeslot in pickup
UI
When the user already has a timeslot and navigates to the pickup
step, the UI now clearly indicates they are changing their timeslot:
TimeslotSelector:
- New optional currentTimeslot prop
- Shows an orange 'CURRENT TIMESLOT' card at the top with the
existing date/time when changing
- Heading changes to 'Change Pickup Timeslot' vs 'Select a Pickup Timeslot'
- Button text changes to 'Update Timeslot' / 'Updating...' vs
'Confirm Timeslot' / 'Confirming...'
OrderSteps:
- Passes orderDetails.pickupTimeslot to TimeslotSelector
- Heading changes to 'Change Pickup Time' vs 'Select Pickup Time'
- Description text adapts accordingly
---
components/ordercontainer/OrderSteps.tsx | 20 +++++++++--
components/pickup/TimeslotSelector.tsx | 44 ++++++++++++++++++++++--
2 files changed, 58 insertions(+), 6 deletions(-)
diff --git a/components/ordercontainer/OrderSteps.tsx b/components/ordercontainer/OrderSteps.tsx
index afc15d1..32c76fd 100644
--- a/components/ordercontainer/OrderSteps.tsx
+++ b/components/ordercontainer/OrderSteps.tsx
@@ -440,20 +440,34 @@ export default function OrderSteps({
// ── Pickup step ────────────────────────────────────────────────
if (step === OrderStep.PICKUP) {
+ const hasExistingTimeslot = !!orderDetails?.pickupTimeslot;
+
return (
- 📍 Select Pickup Time
+ {hasExistingTimeslot
+ ? "📍 Change Pickup Time"
+ : "📍 Select Pickup Time"}
- Order {orderNumber} has been paid. Choose when
- you'd like to collect it.
+ {hasExistingTimeslot ? (
+ <>
+ Order {orderNumber} already has a pickup time.
+ Select a new timeslot below.
+ >
+ ) : (
+ <>
+ Order {orderNumber} has been paid. Choose when
+ you'd like to collect it.
+ >
+ )}
onPickupConfirmed()}
onCancel={() => router.push("/my-orders")}
/>
diff --git a/components/pickup/TimeslotSelector.tsx b/components/pickup/TimeslotSelector.tsx
index 58da5e9..f25d568 100644
--- a/components/pickup/TimeslotSelector.tsx
+++ b/components/pickup/TimeslotSelector.tsx
@@ -44,8 +44,17 @@ interface TimeslotResponse {
hasMore: boolean;
}
+interface CurrentTimeslot {
+ date: string;
+ startTime: string;
+ endTime: string;
+ label?: string;
+}
+
interface TimeslotSelectorProps {
orderId: string;
+ /** If provided, indicates the user is changing an existing timeslot. */
+ currentTimeslot?: CurrentTimeslot | null;
onTimeslotSelected: (
timeslotId: string,
pickupInstructions?: unknown[],
@@ -106,9 +115,11 @@ async function fetchTimeslots(page: number): Promise {
*/
export function TimeslotSelector({
orderId,
+ currentTimeslot,
onTimeslotSelected,
onCancel,
}: TimeslotSelectorProps) {
+ const isChanging = !!currentTimeslot;
const [selectedId, setSelectedId] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
@@ -204,9 +215,36 @@ export function TimeslotSelector({
transition="opacity 0.15s ease"
>
- Select a Pickup Timeslot
+ {isChanging ? "Change Pickup Timeslot" : "Select a Pickup Timeslot"}
+ {/* Show current timeslot when changing */}
+ {isChanging && currentTimeslot && (
+
+
+ CURRENT TIMESLOT
+
+
+ {formatDateHeading(
+ currentTimeslot.date.includes("T")
+ ? currentTimeslot.date.split("T")[0]
+ : currentTimeslot.date,
+ )}
+
+
+ {currentTimeslot.startTime} – {currentTimeslot.endTime}
+ {currentTimeslot.label ? ` · ${currentTimeslot.label}` : ""}
+
+
+ )}
+
{submitError && (
@@ -328,9 +366,9 @@ export function TimeslotSelector({
onClick={handleSubmit}
isDisabled={!selectedId || submitting}
isLoading={submitting}
- loadingText="Confirming..."
+ loadingText={isChanging ? "Updating..." : "Confirming..."}
>
- Confirm Timeslot
+ {isChanging ? "Update Timeslot" : "Confirm Timeslot"}
)}
From 7bea2e3d93ddd69fb93eec59ee378ae1458976a8 Mon Sep 17 00:00:00 2001
From: Benson Cho <100653148+choden-dev@users.noreply.github.com>
Date: Sat, 25 Apr 2026 19:47:02 +1200
Subject: [PATCH 3/4] perf: replace N+1 order fetching with batched server-side
endpoint
The OrdersByTimeslotView dashboard was making ~52 sequential HTTP requests
(1 per timeslot + 1 for unassigned orders). This replaces that with a single
API call to a new /api/admin/orders-by-timeslot endpoint that:
- Fetches all active timeslots in 1 DB query
- Fetches all assigned + unassigned orders in 2 parallel DB queries
- Groups orders by timeslot server-side
Reduces network round-trips from ~52 to 1 and DB queries from ~52 to 3.
---
app/api/admin/orders-by-timeslot/route.ts | 227 ++++++++++++++++++++++
components/admin/OrdersByTimeslotView.tsx | 69 +------
2 files changed, 236 insertions(+), 60 deletions(-)
create mode 100644 app/api/admin/orders-by-timeslot/route.ts
diff --git a/app/api/admin/orders-by-timeslot/route.ts b/app/api/admin/orders-by-timeslot/route.ts
new file mode 100644
index 0000000..b8b2f54
--- /dev/null
+++ b/app/api/admin/orders-by-timeslot/route.ts
@@ -0,0 +1,227 @@
+import { type NextRequest, NextResponse } from "next/server";
+import { getPayloadClient } from "../../../../lib/payload";
+
+/**
+ * GET /api/admin/orders-by-timeslot
+ *
+ * Admin-only endpoint that returns timeslots with their orders pre-grouped,
+ * replacing the N+1 query pattern where the client fetched each timeslot's
+ * orders individually.
+ *
+ * Query params:
+ * - `filter` ("upcoming" | "all", default "upcoming") — "upcoming" returns
+ * timeslots with dates from today through the next 7 days.
+ *
+ * Returns:
+ * - `groups[]` — timeslots with their orders already attached
+ * - Each group has `timeslot` and `orders[]`
+ * - A synthetic "unassigned" group is prepended for PAID orders without a slot
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const payload = await getPayloadClient();
+
+ // Verify admin auth
+ const { user } = await payload.auth({ headers: request.headers });
+ if (!user) {
+ return NextResponse.json(
+ { error: "Admin authentication required." },
+ { status: 401 },
+ );
+ }
+
+ const { searchParams } = new URL(request.url);
+ const filter = searchParams.get("filter") === "all" ? "all" : "upcoming";
+
+ // ── 1. Fetch all active timeslots, filter by date in JS ──────────
+ // The date field is stored as a plain text "YYYY-MM-DD" string.
+ // We fetch all active timeslots and filter in JS to handle any
+ // date strings that might contain a "T" suffix consistently.
+ const allTimeslotResult = await payload.find({
+ collection: "timeslots",
+ where: { isActive: { equals: true } },
+ sort: "date",
+ limit: 0,
+ pagination: false,
+ depth: 0,
+ });
+
+ let timeslots = allTimeslotResult.docs;
+
+ if (filter === "upcoming") {
+ const now = new Date();
+ const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+ const nextWeek = new Date();
+ nextWeek.setDate(nextWeek.getDate() + 7);
+ const nextWeekStr = `${nextWeek.getFullYear()}-${String(nextWeek.getMonth() + 1).padStart(2, "0")}-${String(nextWeek.getDate()).padStart(2, "0")}`;
+
+ timeslots = timeslots.filter((slot) => {
+ const d =
+ typeof slot.date === "string"
+ ? slot.date.includes("T")
+ ? slot.date.split("T")[0]
+ : slot.date
+ : "";
+ return d >= todayStr && d <= nextWeekStr;
+ });
+ }
+
+ const timeslotIds = timeslots.map((t) => String(t.id));
+
+ // ── 2. Fetch orders in two parallel queries ──────────────────────
+ const [assignedResult, unassignedResult] = await Promise.all([
+ // All orders assigned to any of the fetched timeslots
+ payload.find({
+ collection: "orders",
+ where:
+ timeslotIds.length > 0
+ ? { pickupTimeslot: { in: timeslotIds } }
+ : { id: { equals: "__none__" } },
+ sort: "-createdAt",
+ limit: 0,
+ pagination: false,
+ depth: 1,
+ }),
+ // Paid orders with no timeslot selected
+ payload.find({
+ collection: "orders",
+ where: {
+ and: [
+ { status: { equals: "PAID" } },
+ {
+ or: [
+ { pickupTimeslot: { exists: false } },
+ { pickupTimeslot: { equals: null } },
+ ],
+ },
+ ],
+ },
+ sort: "-createdAt",
+ limit: 0,
+ pagination: false,
+ depth: 1,
+ }),
+ ]);
+
+ // ── 3. Group assigned orders by timeslot ID ──────────────────────
+ type OrderDoc = (typeof assignedResult.docs)[number];
+ const ordersBySlot = new Map();
+
+ for (const order of assignedResult.docs) {
+ const rawSlot = order.pickupTimeslot;
+ const slotId = rawSlot
+ ? typeof rawSlot === "object"
+ ? String((rawSlot as { id: string | number }).id)
+ : String(rawSlot)
+ : null;
+
+ if (slotId) {
+ const list = ordersBySlot.get(slotId);
+ if (list) {
+ list.push(order);
+ } else {
+ ordersBySlot.set(slotId, [order]);
+ }
+ }
+ }
+
+ // ── 4. Build response ────────────────────────────────────────────
+ interface OrderResponse {
+ id: string | number;
+ orderNumber: string;
+ status: string;
+ paymentMethod: string | null;
+ bankTransferVerified: boolean | null;
+ pricing: { total: number };
+ files: { fileName: string; copies: number; colorMode?: string }[];
+ customer: { name: string; email: string } | string;
+ pickedUpAt: string | null;
+ }
+
+ function mapOrder(order: OrderDoc): OrderResponse {
+ return {
+ id: order.id,
+ orderNumber: order.orderNumber,
+ status: order.status,
+ paymentMethod: (order.paymentMethod as string) || null,
+ bankTransferVerified:
+ (order.bankTransferVerified as boolean) ?? null,
+ pricing: order.pricing as { total: number },
+ files: (order.files || []).map(
+ (f: Record) => ({
+ fileName: f.fileName as string,
+ copies: f.copies as number,
+ colorMode: (f.colorMode as string) || undefined,
+ }),
+ ),
+ customer:
+ typeof order.customer === "object" && order.customer
+ ? {
+ name:
+ (order.customer as { name?: string }).name ||
+ "—",
+ email:
+ (order.customer as { email?: string })
+ .email || "—",
+ }
+ : String(order.customer),
+ pickedUpAt: (order.pickedUpAt as string) || null,
+ };
+ }
+
+ const groups: {
+ timeslot: {
+ id: string;
+ date: string;
+ startTime: string;
+ endTime: string;
+ label: string;
+ };
+ orders: OrderResponse[];
+ }[] = [];
+
+ // Unassigned group first
+ if (unassignedResult.docs.length > 0) {
+ groups.push({
+ timeslot: {
+ id: "unassigned",
+ date: "",
+ startTime: "",
+ endTime: "",
+ label: "⚠️ Paid — No Pickup Time Selected",
+ },
+ orders: unassignedResult.docs.map(mapOrder),
+ });
+ }
+
+ // Timeslot groups (only include slots that have orders)
+ for (const slot of timeslots) {
+ const slotOrders = ordersBySlot.get(String(slot.id));
+ if (slotOrders && slotOrders.length > 0) {
+ groups.push({
+ timeslot: {
+ id: String(slot.id),
+ date: slot.date as string,
+ startTime: slot.startTime as string,
+ endTime: slot.endTime as string,
+ label: (slot.label as string) || "",
+ },
+ orders: slotOrders.map(mapOrder),
+ });
+ }
+ }
+
+ return NextResponse.json({ success: true, groups });
+ } catch (error) {
+ console.error("Error fetching orders by timeslot:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error
+ ? error.message
+ : "Failed to fetch orders by timeslot.",
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/components/admin/OrdersByTimeslotView.tsx b/components/admin/OrdersByTimeslotView.tsx
index 8490f38..140c663 100644
--- a/components/admin/OrdersByTimeslotView.tsx
+++ b/components/admin/OrdersByTimeslotView.tsx
@@ -52,69 +52,18 @@ export default function OrdersByTimeslotView() {
setLoading(true);
setError(null);
try {
- // Fetch active timeslots sorted by date
- const timeslotRes = await fetch(
- `/api/timeslots?where[isActive][equals]=true&limit=500&sort=date&depth=0`,
+ // Single API call replaces N+2 sequential requests
+ const res = await fetch(
+ `/api/admin/orders-by-timeslot?filter=${filter}`,
);
- if (!timeslotRes.ok) throw new Error("Failed to fetch timeslots.");
- const timeslotData = await timeslotRes.json();
- const allTimeslots: TimeslotData[] = timeslotData.docs || [];
-
- // Client-side date filtering for "upcoming" (today + 7 days)
- const todayStr = new Date().toLocaleDateString("en-CA"); // YYYY-MM-DD
- const nextWeek = new Date();
- nextWeek.setDate(nextWeek.getDate() + 7);
- const nextWeekStr = nextWeek.toLocaleDateString("en-CA");
-
- const timeslots =
- filter === "upcoming"
- ? allTimeslots.filter((slot) => {
- const d = slot.date.includes("T")
- ? slot.date.split("T")[0]
- : slot.date;
- return d >= todayStr && d <= nextWeekStr;
- })
- : allTimeslots;
-
- // For each timeslot, fetch orders assigned to it
- const results: TimeslotWithOrders[] = [];
-
- for (const slot of timeslots) {
- const orderRes = await fetch(
- `/api/orders?where[pickupTimeslot][equals]=${slot.id}&depth=1&limit=50&sort=-createdAt`,
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(
+ err.error || "Failed to fetch orders by timeslot.",
);
- if (!orderRes.ok) continue;
- const orderData = await orderRes.json();
-
- if (orderData.docs && orderData.docs.length > 0) {
- results.push({
- timeslot: slot,
- orders: orderData.docs,
- });
- }
- }
-
- // Also add a "No timeslot" group for paid orders without a slot
- const unassignedRes = await fetch(
- `/api/orders?where[status][in]=PAID&where[pickupTimeslot][exists]=false&depth=1&limit=50`,
- );
- if (unassignedRes.ok) {
- const unassignedData = await unassignedRes.json();
- if (unassignedData.docs && unassignedData.docs.length > 0) {
- results.unshift({
- timeslot: {
- id: "unassigned",
- date: "",
- startTime: "",
- endTime: "",
- label: "⚠️ Paid — No Pickup Time Selected",
- },
- orders: unassignedData.docs,
- });
- }
}
-
- setData(results);
+ const json = await res.json();
+ setData(json.groups || []);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data.");
} finally {
From a60585d4be71fb5143ee02386b1f6b20df332435 Mon Sep 17 00:00:00 2001
From: Benson Cho <100653148+choden-dev@users.noreply.github.com>
Date: Sat, 25 Apr 2026 19:48:14 +1200
Subject: [PATCH 4/4] fix: update Biome version in pull_request.yml and improve
code formatting in email.ts
---
app/api/admin/orders-by-timeslot/route.ts | 23 ++++++++---------------
components/admin/OrdersByTimeslotView.tsx | 8 ++------
2 files changed, 10 insertions(+), 21 deletions(-)
diff --git a/app/api/admin/orders-by-timeslot/route.ts b/app/api/admin/orders-by-timeslot/route.ts
index b8b2f54..3ad7eb1 100644
--- a/app/api/admin/orders-by-timeslot/route.ts
+++ b/app/api/admin/orders-by-timeslot/route.ts
@@ -144,25 +144,18 @@ export async function GET(request: NextRequest) {
orderNumber: order.orderNumber,
status: order.status,
paymentMethod: (order.paymentMethod as string) || null,
- bankTransferVerified:
- (order.bankTransferVerified as boolean) ?? null,
+ bankTransferVerified: (order.bankTransferVerified as boolean) ?? null,
pricing: order.pricing as { total: number },
- files: (order.files || []).map(
- (f: Record) => ({
- fileName: f.fileName as string,
- copies: f.copies as number,
- colorMode: (f.colorMode as string) || undefined,
- }),
- ),
+ files: (order.files || []).map((f: Record) => ({
+ fileName: f.fileName as string,
+ copies: f.copies as number,
+ colorMode: (f.colorMode as string) || undefined,
+ })),
customer:
typeof order.customer === "object" && order.customer
? {
- name:
- (order.customer as { name?: string }).name ||
- "—",
- email:
- (order.customer as { email?: string })
- .email || "—",
+ name: (order.customer as { name?: string }).name || "—",
+ email: (order.customer as { email?: string }).email || "—",
}
: String(order.customer),
pickedUpAt: (order.pickedUpAt as string) || null,
diff --git a/components/admin/OrdersByTimeslotView.tsx b/components/admin/OrdersByTimeslotView.tsx
index 140c663..2eb94f3 100644
--- a/components/admin/OrdersByTimeslotView.tsx
+++ b/components/admin/OrdersByTimeslotView.tsx
@@ -53,14 +53,10 @@ export default function OrdersByTimeslotView() {
setError(null);
try {
// Single API call replaces N+2 sequential requests
- const res = await fetch(
- `/api/admin/orders-by-timeslot?filter=${filter}`,
- );
+ const res = await fetch(`/api/admin/orders-by-timeslot?filter=${filter}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
- throw new Error(
- err.error || "Failed to fetch orders by timeslot.",
- );
+ throw new Error(err.error || "Failed to fetch orders by timeslot.");
}
const json = await res.json();
setData(json.groups || []);