Order history and tracking
Two screens: a list of everything somebody has ordered, and one order in detail. They look like the easiest thing in the module. They contain the two questions customers actually contact a shop about — where is my order and what did I pay for — so they are worth getting right.
The list is paginated from the first day
const [rows, total] = await this.prisma.$transaction([
this.prisma.order.findMany({
where,
include: ORDER_INCLUDE,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (safePage - 1) * safeLimit,
take: safeLimit,
}),
this.prisma.order.count({ where }),
]);
Not because a new shop has many orders, but because a regular customer will have three hundred within two years and nobody will remember to add pagination then. The cost of doing it now is four lines.
Two orderBy clauses, always. createdAt: "desc" alone is not a total
order: two orders placed in the same millisecond have no defined position
relative to each other, and PostgreSQL is free to return them in a different
sequence on each query. Page 1 shows order A, you click Next, and page 2 shows
order A again while order B never appears at all.
The tiebreaker must be unique. id is.
$transaction for the rows and the count so both see the same snapshot. Two
separate queries can straddle a new order, and the customer gets "Page 1 of 3"
with a page 3 that is empty.
Newer and Older, not First and Last
{orders.hasPrevious ? (
<Link href={`/orders?page=${orders.page - 1}`} rel="prev">Newer</Link>
) : (
<span />
)}
The list is newest-first, so "Previous" points at more recent orders, which reads backwards. Name the direction by what it means, not by its position in the array.
The <span /> in the else branch is deliberate: it keeps the flex layout's
space-between working so "Older" stays on the right, instead of jumping to the
left on page one.
The detail page shows a snapshot, not a lookup
<p className="font-medium">{line.productName}</p>
<p className="text-gray-500">
{line.variantLabel} × {line.quantity} at {formatPaise(line.unitPricePaise)}
</p>
Every one of those came from the order_items row, written at checkout. None of
it is joined to the live product.
This is decision 0002, and it is the whole difference between a cart and an order. The shop renames "Toor Dal (Arhar)" to "Toor Dal Premium" next month, or raises the price, or stops selling it entirely. An order is a record of a transaction that happened at a price both sides agreed to. If it renders through a live join, it changes after the fact, and the customer who screenshots their bill is right and you are wrong.
Progress, honestly
export function OrderProgress({ status }: { status: OrderStatus }) {
if (status === "CANCELLED" || status === "PENDING_PAYMENT") return null;
const reached = ORDER_PROGRESS.indexOf(status);
// …four steps, the ones up to `reached` marked done…
}
Two decisions in three lines.
A cancelled order gets no progress bar at all. The obvious thing is to show it greyed out. The obvious thing is wrong: a greyed-out bar reads as "not yet", which is the opposite of "never". A cancelled order gets a sentence instead:
<p>This order was cancelled. Nothing has been charged.</p>
That second sentence is the one that matters. The question behind "I cancelled it" is almost always "have you taken my money?".
PENDING_PAYMENT gets none either, because it is not on the delivery path
yet. Showing step one of four for an order that has not been paid for promises
something the shop has not agreed to.
ORDER_PROGRESS lives in the shared package next to the flow, so the happy path
is defined once:
export const ORDER_PROGRESS: readonly OrderStatus[] = [
"PLACED", "PACKED", "OUT_FOR_DELIVERY", "DELIVERED",
];
Status labels are not status names
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
PENDING_PAYMENT: "Waiting for payment",
PLACED: "Order placed",
PACKED: "Packed and ready",
OUT_FOR_DELIVERY: "Out for delivery",
DELIVERED: "Delivered",
CANCELLED: "Cancelled",
};
OUT_FOR_DELIVERY is a database value. "Out for delivery" is English. Never
show the enum: uppercase with underscores tells a customer they are looking at
something not meant for them, and the first time you want to reword one you
would have to migrate the column.
One map, Record<OrderStatus, string>, so a new status cannot be added without
a label.
Two kinds of date on one page
This catches people, and module 12 explained why:
function formatDay(iso: string): string {
return new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-IN", {
weekday: "long", day: "numeric", month: "long", timeZone: "UTC",
});
}
/** An event is a real instant, so this one is formatted in local time. */
function formatMoment(iso: string): string {
return new Date(iso).toLocaleString("en-IN", {
day: "numeric", month: "short", hour: "numeric", minute: "2-digit",
});
}
The delivery date is a calendar date. It arrives as 2026-09-30, it is
pinned to UTC, and it must be formatted in UTC or a customer in Pune at 2am sees
the 29th.
An event timestamp is a real instant. "Packed at 4:12pm" means 4:12pm where the customer is, so it is formatted locally.
Same page, two rules, and getting them the wrong way round produces a bug that only appears for some users at some times of day.
Nothing personal is indexed
export const metadata: Metadata = {
title: "Your orders",
robots: { index: false, follow: false },
};
On every page behind a sign-in. These pages need a session to render, so a crawler cannot see them anyway — but "cannot today" is not "will not tomorrow", and the cost of the line is nothing.
The history section
<ol className="space-y-1">
{order.events.map((event, index) => (
<li key={`${event.status}-${index}`}>
<time dateTime={event.at}>{formatMoment(event.at)}</time> —{" "}
{ORDER_STATUS_LABELS[event.status]}
{event.note && <span className="text-gray-500"> ({event.note})</span>}
</li>
))}
</ol>
An <ol>, because the order matters. A <time dateTime={…}> so the machine
-readable instant is in the markup even though the visible text is formatted for
a human.
Showing the shop's audit trail to the customer is a choice, and it is the right one for a kirana shop. "Packed at 4:12pm, out for delivery at 4:40pm" answers where is my order before anybody has to ring.
Note what is not shown: byUserId. Which member of staff packed it is the
shop's business.
Empty states go somewhere
if (orders.total === 0) {
return (
<div className="py-12 text-center">
<h1 className="text-xl font-semibold">No orders yet.</h1>
<Link href="/products">Start shopping</Link>
</div>
);
}
Every empty state in this application has a way out of it. A page that tells somebody there is nothing here and stops is a page they close.
Check your work
Why the list is paginated immediately: a regular customer reaches hundreds of orders, and nobody adds pagination later.
Why orderBy needs a unique tiebreaker: equal createdAt values have no
defined order, so rows repeat across pages and others never appear.
Why the rows and the count share a transaction: otherwise a new order between the two queries produces a page that does not exist.
Why "Newer" and "Older" rather than "Previous" and "Next": the list is newest-first, so positional names read backwards.
Why the detail page reads the snapshot: an order is a record of what was agreed, and a live join lets it change after the fact.
Why a cancelled order gets no progress bar: greyed-out reads as "not yet", not "never".
Why it says "nothing has been charged": that is the actual question behind "I cancelled it".
Why labels are a separate map: OUT_FOR_DELIVERY is a database value, and
rewording it should not mean a migration.
Why two date formatters: a delivery date is a calendar date and must be formatted in UTC; an event is an instant and must be local.
Why robots: { index: false }: these pages are personal, and "not reachable
today" is not a policy.
Practice
- Place three orders, then set the page size to 2 and confirm the pagination labels and counts are right on both pages.
- Remove the
idtiebreaker fromorderBy, insert two orders with the samecreatedAtdirectly in SQL, and page through until you see one twice. - Split the rows and count into two separate queries. Insert an order between
them with
psqland see what the page claims. - Rename a product in the database after ordering it. Confirm the order page still shows the old name, and that the catalogue shows the new one.
- Cancel an order and confirm the progress bar disappears rather than greying out.
- Set an order to
PENDING_PAYMENTdirectly in SQL and confirm no progress bar renders. - Change your machine's timezone to UTC−5 and reload an order. Confirm the delivery date is unchanged and the event times moved.
- Swap the two date formatters over and find the time of day at which it breaks.
- Add a status to the enum without adding a label. Confirm it fails to compile.
- Look at the page source for
/ordersand confirm the robots directive is there.
Next: the other side of the counter — the shop owner's admin area.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship