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. -

- -
+ + ); } - 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 && ( + + + + Page {page + 1} of {totalPages} + + + + )} -
- - + -
-
+ + + ); } -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" > +