diff --git a/AGENTS.md b/AGENTS.md
index 4910733..270ebcc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,3 +68,49 @@ After discovering the project's specific touchpoints, use a checklist like this
- [ ] No duplicate/alias env vars introduced
- [ ] Defaults are consistent across all touchpoints
```
+
+## Data Fetching with React Query
+
+This project uses **@tanstack/react-query** (v5) for client-side data fetching. The `QueryClientProvider` is set up in `pages/_app.tsx`.
+
+### Guidelines
+
+1. **Use `useQuery` for all GET requests in components.** Do not use raw `useEffect` + `fetch` for data loading. React Query provides caching, deduplication, background refetching, and proper loading/error states out of the box.
+
+2. **Query keys** must be descriptive arrays: `["resource-name", ...params]`. Examples:
+ - `["pickup-slots", page]`
+ - `["order", orderId]`
+ - `["my-orders", { limit }]`
+
+3. **Use `useMutation`** for POST/PUT/DELETE operations that modify server state. Invalidate related queries after mutations succeed:
+ ```ts
+ const queryClient = useQueryClient();
+ const mutation = useMutation({
+ mutationFn: updateOrder,
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
+ });
+ ```
+
+4. **Server-side pagination** — API routes should accept `limit` and `offset` query params and return `{ total, limit, offset, hasMore }` metadata alongside the data array. Use `placeholderData: (prev) => prev` in `useQuery` to keep previous data visible while the next page loads.
+
+5. **Default options** are configured in `_app.tsx`:
+ - `staleTime: 30s` — data is considered fresh for 30 seconds before background refetch
+ - `retry: 1` — one automatic retry on failure
+
+6. **Do not install `react-query`** (v3). The package is `@tanstack/react-query` (v5).
+
+### Refactoring roadmap
+
+The following components still use raw `useEffect` + `fetch` and should be migrated to React Query as they are touched:
+
+- `components/ordercontainer/OrderContainer.tsx` — order resumption & pending orders fetch
+- `components/admin/PendingVerificationView.tsx`
+- `components/admin/OrdersByTimeslotView.tsx`
+- `components/admin/ScheduleCalendarView.tsx`
+- `components/admin/NotifyTimeslotsView.tsx`
+- `components/payment/StripePaymentForm.tsx`
+- `components/payment/BankTransferForm.tsx`
+- `pages/my-orders.tsx`
+- `pages/order_complete.tsx`
+
+When refactoring these components, follow the pattern established in `components/pickup/TimeslotSelector.tsx`.
diff --git a/app/api/pickup-slots/route.ts b/app/api/pickup-slots/route.ts
index a8b5b57..1f84e37 100644
--- a/app/api/pickup-slots/route.ts
+++ b/app/api/pickup-slots/route.ts
@@ -12,7 +12,8 @@ import { getPayloadClient } from "../../../lib/payload";
* slots before selecting one.
*
* Query params:
- * - `limit` (number, default 50)
+ * - `limit` (number, default 50, max 100) — page size
+ * - `offset` (number, default 0) — number of slots to skip
*/
export async function GET(request: NextRequest) {
try {
@@ -21,6 +22,7 @@ export async function GET(request: NextRequest) {
100,
Math.max(1, Number(searchParams.get("limit")) || 50),
);
+ const offset = Math.max(0, Number(searchParams.get("offset")) || 0);
const payload = await getPayloadClient();
@@ -121,12 +123,17 @@ export async function GET(request: NextRequest) {
filtered.push(slot);
}
- // Apply the requested limit
- const limited = filtered.slice(0, limit);
+ // Apply offset-based pagination
+ const total = filtered.length;
+ const paginated = filtered.slice(offset, offset + limit);
return NextResponse.json({
success: true,
- timeslots: limited.map((slot) => {
+ total,
+ limit,
+ offset,
+ hasMore: offset + limit < total,
+ timeslots: paginated.map((slot) => {
const maxCap =
typeof slot.maxCapacity === "number" ? slot.maxCapacity : null;
const booked =
diff --git a/components/ordercontainer/OrderContainer.tsx b/components/ordercontainer/OrderContainer.tsx
index 1e54554..db70779 100644
--- a/components/ordercontainer/OrderContainer.tsx
+++ b/components/ordercontainer/OrderContainer.tsx
@@ -83,8 +83,8 @@ const OrderContainerInner = ({
case OrderStatus.AWAITING_PAYMENT:
return OrderStep.PAYMENT;
case OrderStatus.PAID:
- return OrderStep.PICKUP;
case OrderStatus.AWAITING_PICKUP:
+ return OrderStep.PICKUP;
case OrderStatus.PRINTED:
case OrderStatus.PICKED_UP:
return OrderStep.COMPLETE;
@@ -93,31 +93,26 @@ const OrderContainerInner = ({
}
}, []);
- // Resume an existing order or jump to pickup selection
+ // Resume an existing order or jump to pickup selection.
+ // Resets resumeChecked to false and re-fetches whenever the query
+ // params change (e.g. clicking "Change Pickup" while already on
+ // the /order page).
useEffect(() => {
const orderIdToResume = resumeOrderId || pickupForOrderId;
- if (!orderIdToResume || resumeChecked) return;
+ if (!orderIdToResume) return;
+
+ setResumeChecked(false);
async function resumeOrder() {
try {
const res = await fetch(`/api/shop/${orderIdToResume}`);
- if (!res.ok) {
- setResumeChecked(true);
- return;
- }
+ if (!res.ok) return;
+
const data = await res.json();
const order = data.order;
- if (!order?.status) {
- setResumeChecked(true);
- return;
- }
-
- if (order.status === OrderStatus.EXPIRED) {
- // Can't resume expired orders
- setResumeChecked(true);
- return;
- }
+ if (!order?.status) return;
+ if (order.status === OrderStatus.EXPIRED) return;
setActiveOrderId(order.id);
setActiveOrderNumber(order.orderNumber);
@@ -140,7 +135,7 @@ const OrderContainerInner = ({
}
resumeOrder();
- }, [resumeOrderId, pickupForOrderId, resumeChecked, statusToStep]);
+ }, [resumeOrderId, pickupForOrderId, statusToStep]);
// Fetch any in-progress orders for authenticated users
useEffect(() => {
diff --git a/components/ordercontainer/OrderSteps.tsx b/components/ordercontainer/OrderSteps.tsx
index b7b2f41..afc15d1 100644
--- a/components/ordercontainer/OrderSteps.tsx
+++ b/components/ordercontainer/OrderSteps.tsx
@@ -182,14 +182,16 @@ export default function OrderSteps({
if (order.status === OrderStatus.PAID && step === OrderStep.PAYMENT) {
onPaymentSuccess();
}
- // If already has a timeslot, jump to complete
+ // If already has a timeslot, jump to complete — but not
+ // when the user is on the PICKUP step to change their timeslot.
if (
[
OrderStatus.AWAITING_PICKUP,
OrderStatus.PRINTED,
OrderStatus.PICKED_UP,
].includes(order.status) &&
- step !== OrderStep.COMPLETE
+ step !== OrderStep.COMPLETE &&
+ step !== OrderStep.PICKUP
) {
onPickupConfirmed();
}
@@ -593,6 +595,12 @@ export default function OrderSteps({
)}
+
diff --git a/components/pickup/TimeslotSelector.tsx b/components/pickup/TimeslotSelector.tsx
index a84fa3e..58da5e9 100644
--- a/components/pickup/TimeslotSelector.tsx
+++ b/components/pickup/TimeslotSelector.tsx
@@ -1,6 +1,21 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import {
+ Alert,
+ AlertIcon,
+ Box,
+ Button,
+ Flex,
+ Heading,
+ HStack,
+ Radio,
+ RadioGroup,
+ Spinner,
+ Text,
+ VStack,
+} from "@chakra-ui/react";
+import { useQuery } from "@tanstack/react-query";
+import { useCallback, useMemo, useState } from "react";
interface PickupProfileInfo {
id: string;
@@ -20,6 +35,15 @@ interface Timeslot {
pickupInstructionProfile: PickupProfileInfo | null;
}
+interface TimeslotResponse {
+ success: boolean;
+ timeslots: Timeslot[];
+ total: number;
+ limit: number;
+ offset: number;
+ hasMore: boolean;
+}
+
interface TimeslotSelectorProps {
orderId: string;
onTimeslotSelected: (
@@ -29,6 +53,9 @@ interface TimeslotSelectorProps {
onCancel: () => void;
}
+/** Number of timeslots to fetch per page. */
+const PAGE_SIZE = 15;
+
/** Group timeslots by date for display. */
function groupByDate(slots: Timeslot[]): Map {
const map = new Map();
@@ -59,9 +86,22 @@ function formatDateHeading(dateStr: string): string {
}
}
+/** Fetch a page of pickup timeslots from the API. */
+async function fetchTimeslots(page: number): Promise {
+ const offset = page * PAGE_SIZE;
+ const res = await fetch(
+ `/api/pickup-slots?limit=${PAGE_SIZE}&offset=${offset}`,
+ );
+ if (!res.ok) {
+ throw new Error("Failed to load timeslots");
+ }
+ return res.json();
+}
+
/**
* Component to select a pickup timeslot for an order.
- * Fetches available timeslots and displays them grouped by date.
+ * Fetches available timeslots using React Query with server-side
+ * pagination. Displays them grouped by date with Previous/Next controls.
* Shows capacity info and pickup instruction profile names.
*/
export function TimeslotSelector({
@@ -69,33 +109,24 @@ export function TimeslotSelector({
onTimeslotSelected,
onCancel,
}: TimeslotSelectorProps) {
- const [timeslots, setTimeslots] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+ const [page, setPage] = useState(0);
- // Fetch available timeslots
- useEffect(() => {
- const fetchTimeslots = async () => {
- try {
- setLoading(true);
- setError(null);
- const res = await fetch("/api/pickup-slots");
- if (!res.ok) {
- throw new Error("Failed to load timeslots");
- }
- const data = await res.json();
- setTimeslots(data.timeslots || []);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Unknown error");
- } finally {
- setLoading(false);
- }
- };
+ const { data, isLoading, isError, error, isPlaceholderData } = useQuery({
+ queryKey: ["pickup-slots", page],
+ queryFn: () => fetchTimeslots(page),
+ placeholderData: (prev) => prev,
+ });
- fetchTimeslots();
- }, []);
+ const timeslots = data?.timeslots ?? [];
+ const total = data?.total ?? 0;
+ const hasMore = data?.hasMore ?? false;
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+
+ // Group the current page's timeslots by date
+ const grouped = useMemo(() => groupByDate(timeslots), [timeslots]);
// Handle timeslot selection submission
const handleSubmit = useCallback(async () => {
@@ -103,7 +134,7 @@ export function TimeslotSelector({
try {
setSubmitting(true);
- setError(null);
+ setSubmitError(null);
const res = await fetch(`/api/shop/${orderId}/select-timeslot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -111,195 +142,202 @@ export function TimeslotSelector({
});
if (!res.ok) {
- const data = await res.json();
- throw new Error(data.error || "Failed to select timeslot");
+ const errData = await res.json();
+ throw new Error(errData.error || "Failed to select timeslot");
}
- const data = await res.json();
+ const resData = await res.json();
onTimeslotSelected(
selectedId,
- data.pickupInstructionProfile?.instructions,
+ resData.pickupInstructionProfile?.instructions,
);
} catch (err) {
- setError(err instanceof Error ? err.message : "Unknown error");
+ setSubmitError(err instanceof Error ? err.message : "Unknown error");
} finally {
setSubmitting(false);
}
}, [selectedId, orderId, onTimeslotSelected]);
- if (loading) {
+ if (isLoading) {
return (
-
- Loading timeslots...
-
+
+
+
+ Loading timeslots...
+
+
);
}
- if (error) {
+ if (isError) {
return (
-
-
- Error: {error}
-
-
+
+
);
}
- if (timeslots.length === 0) {
+ if (total === 0) {
return (
-
-
- No timeslots available at the moment.
-
-
+
+ No timeslots available at the moment.
+
We'll notify you by email when pickup slots become available.
-
-
+
+
Back
-
-
+
+
);
}
- const grouped = groupByDate(timeslots);
-
return (
-
-
Select a Pickup Timeslot
+
+
+ Select a Pickup Timeslot
+
- {Array.from(grouped.entries()).map(([dateKey, slots]) => (
-
-
- {formatDateHeading(dateKey)}
-
-
- {slots.map((slot) => {
- const isSelected = selectedId === slot.id;
- return (
-
- );
- })}
-
-
- ))}
+ {/* Pagination controls */}
+ {totalPages > 1 && (
+
+ setPage((p) => Math.max(0, p - 1))}
+ isDisabled={page === 0}
+ >
+ ← Previous
+
+
+ Page {page + 1} of {totalPages}
+
+ setPage((p) => p + 1)}
+ isDisabled={!hasMore}
+ >
+ Next →
+
+
+ )}
-
-
+
- {submitting ? "Confirming..." : "Confirm Timeslot"}
-
-
+ Confirm Timeslot
+
+
Cancel
-
-
-
+
+
+
);
}
-const cancelButtonStyle: React.CSSProperties = {
- padding: "10px 24px",
- background: "#f5f5f5",
- border: "1px solid #ddd",
- borderRadius: "6px",
- cursor: "pointer",
- fontSize: "14px",
-};
-
export default TimeslotSelector;
diff --git a/package.json b/package.json
index 1e3c238..f942ca8 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@payloadcms/ui": "^3.83.0",
"@stripe/react-stripe-js": "^6.2.0",
"@stripe/stripe-js": "^9.2.0",
+ "@tanstack/react-query": "^5.100.1",
"@types/nodemailer": "^8.0.0",
"@types/pug": "^2.0.10",
"fetch-blob": "^4.0.0",
diff --git a/pages/_app.tsx b/pages/_app.tsx
index b976c6b..0b2e255 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -1,19 +1,35 @@
import "../styles/globals.css";
import { ChakraProvider } from "@chakra-ui/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AppProps } from "next/app";
import { GoogleAnalytics } from "nextjs-google-analytics";
+import { useState } from "react";
import { AuthProvider } from "../contexts/AuthContext";
import theme from "../styles/theme";
function MyApp({ Component, pageProps }: AppProps) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30 * 1000, // 30 seconds
+ retry: 1,
+ },
+ },
+ }),
+ );
+
return (
<>
-
-
-
-
-
+
+
+
+
+
+
+
>
);
}
diff --git a/pages/order_complete.tsx b/pages/order_complete.tsx
index addb358..07f3e4a 100644
--- a/pages/order_complete.tsx
+++ b/pages/order_complete.tsx
@@ -47,6 +47,7 @@ interface PickupInstructionProfileData {
}
interface OrderDetails {
+ id: string;
orderNumber: string;
status: string;
pricing: { subtotal: number; tax: number; total: number };
@@ -383,6 +384,12 @@ const OrderComplete: NextPage = ({ contactInfo }) => {
justifyContent="center"
flexWrap="wrap"
>
+ router.push(`/order?pickupFor=${order.id}`)}
+ >
+ Change Pickup
+
router.push("/my-orders")}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4d99771..f2b061d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,6 +57,9 @@ importers:
'@stripe/stripe-js':
specifier: ^9.2.0
version: 9.2.0
+ '@tanstack/react-query':
+ specifier: ^5.100.1
+ version: 5.100.1(react@19.2.5)
'@types/nodemailer':
specifier: ^8.0.0
version: 8.0.0
@@ -1937,6 +1940,14 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@swc/helpers/-/helpers-0.5.15.tgz}
+ '@tanstack/query-core@5.100.1':
+ resolution: {integrity: sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@tanstack/query-core/-/query-core-5.100.1.tgz}
+
+ '@tanstack/react-query@5.100.1':
+ resolution: {integrity: sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@tanstack/react-query/-/react-query-5.100.1.tgz}
+ peerDependencies:
+ react: ^18 || ^19
+
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==, tarball: https://packages.atlassian.com/api/npm/npm-remote/@tokenizer/inflate/-/inflate-0.4.1.tgz}
engines: {node: '>=18'}
@@ -6282,6 +6293,13 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@tanstack/query-core@5.100.1': {}
+
+ '@tanstack/react-query@5.100.1(react@19.2.5)':
+ dependencies:
+ '@tanstack/query-core': 5.100.1
+ react: 19.2.5
+
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3