Shopping Cart and Checkout
Shopping Cart and Checkout
Welcome back. In the previous lesson, you built the shared toast notification system and the persistent cart provider. That gave the storefront two important pieces of infrastructure: a way to communicate lightweight feedback to the shopper, and a shared cart state layer that can restore the current cart, expose item counts, and coordinate cart actions across the app.
Now we are finally using that infrastructure to complete the shopper flow. In this lesson, the storefront moves from “I can browse products” to “I can choose a quantity, add an item, manage my cart, apply tax context, and place an order.” This is a big step, but the code stays manageable because each piece has a clear role: a small reusable quantity selector, a product page that can submit to the cart, a summary component that only presents totals, and a cart page that orchestrates the full experience.
A major theme here is separation of concerns. The product detail page should not own cart storage logic. The summary component should not reach into context directly. The quantity selector should not know anything about products or carts. And the cart page should be the orchestration layer, not a place where every UI detail and state rule gets mixed together. When these boundaries stay clear, the feature becomes much easier to extend and maintain.
Previously: Shared Cart State and Toast Feedback
In the previous lesson, you built the systems that make this lesson possible. useCart() became the source of truth for the active cart, including actions like adding items, updating quantities, and checking out. The toast provider made it possible to show messages like “Added to cart” or “Order placed successfully” from anywhere in the app.
That matters directly here. Instead of building cart logic inside the product page or cart page, you can now consume a shared cart context. Instead of hardcoding one-off inline messages, you can trigger reusable toast feedback. This is exactly the benefit of building shared infrastructure first: the actual feature pages become cleaner because they can compose systems that already exist.
Building a Reusable Quantity Selector
The first small but important piece in this lesson is src/components/cart/QuantitySelector.tsx. This component is intentionally tiny, but it solves a very common UI problem: letting the user increment or decrement a quantity while respecting valid numeric boundaries.
Its role is purely presentational and prop-driven. It does not know anything about products, cart IDs, totals, or backend requests. That is exactly what makes it reusable both on the product detail page and in editable cart line items.
Here is the start of the file:
-
The component receives its current
valuefrom the parent, which makes it a controlled component. That means the selector does not own the source of truth for the quantity; it only displays the value and reports valid updates throughonChange(). -
mindefaults to1, which is a sensible baseline for cart and purchase flows. In most storefront scenarios, letting the user decrement below1would create invalid behavior, so the component protects that rule directly. -
maxis optional because some use cases may have an upper bound, while others may not. This gives the selector a flexible interface without forcing every parent to provide a maximum. -
decrementDisabledandincrementDisabledcombine two kinds of rules: global disabled state from the parent and numeric boundary rules. That is a good pattern because the component can respect parent-controlled loading or submission states while still enforcing local quantity validity.
Now look at the rendered UI:
-
The minus button uses
Math.max(min, value - 1)so it never sends a value below the minimum. Even if the button were somehow clicked at the lower boundary, the result would still stay valid. -
The plus button uses two paths: if
maxexists, it clamps withMath.min(max, value + 1); if nomaxis provided, it simply increments normally. This makes the selector reusable in both bounded and unbounded contexts. -
The
aria-labelvalues are important accessibility details. Since the visible button content is only-or+, the labels give assistive technologies a clear description of the action. -
Using the shared
Buttoncomponent keeps the selector visually aligned with the rest of the storefront. That is another good example of composition: the quantity selector focuses on numeric interaction, while button styling remains centralized in the shared UI layer.
This is a strong example of a small reusable component doing exactly enough. It is prop-driven, focused, accessible, and easy to reuse.
Enhancing the Product Detail Page for Add-to-Cart
The next step is src/components/products/ProductDetailPageClient.tsx. In earlier lessons, this page already handled loading, error, missing-product, and successful product-display states. In this lesson, you are enhancing only the successful browsing path by adding quantity selection and an add-to-cart action.
This is an important design point: the existing state branches are already doing their job. The lesson is not about rewriting those states. It is about composing one data hook for reading the product, one context hook for cart mutation, one local UI state value for the selected quantity, and one toast system for feedback.
At the top of the file, the imports and state setup show that composition clearly:
-
useProduct(productId)is still the data-reading hook for the page. That means the product detail screen keeps the same clean read path it had before, and cart mutation is layered on top instead of mixed into the product-fetching logic. -
quantityis local page state managed withuseState(1). This is the right place for it because the selected quantity is a temporary UI choice before submission, not part of the shared cart state itself. -
useCart()providesaddItem()and the cart loading flag. The page does not need to know how carts are created, stored, or refreshed internally; it just asks the shared cart layer to add a product with a quantity. -
useToast()provides lightweight feedback for success and failure. This is a great example of the systems from the previous lesson being used in a feature page without the page needing to implement its own notification mechanism. -
handleAddToCart()guards against a missing product, submits the selected quantity throughaddItem(), and translates failures into a friendly toast withgetErrorMessage(error). That last detail matters because user-facing error feedback should be readable and consistent.
The existing early-return state branches remain intact:
-
These branches continue to protect the page against the three most important non-success states: loading, explicit error, and missing product. Keeping them unchanged is a good example of extending a page without disturbing working logic.
-
isUnavailableis an important derived value because the successful state still has two meaningful variations: a product that can be purchased and a product that should remain informational only. That logic is clearer when expressed once in a named boolean instead of being repeated inline in multiple conditions.
Now look at the successful UI branch:
-
The successful state still presents the shopper-facing product information from earlier lessons: SKU, name, status, description, formatted price, and inventory messaging. That keeps the page aligned with the broader storefront UI system.
-
formatMoney()andgetInventoryLabel()are reused instead of rewriting display logic inline. This is exactly the kind of consistency shared helpers are meant to provide. -
The availability rule is intentionally clear: archived or out-of-stock products remain informational only. In those cases, the page shows a message explaining why the item cannot be added, rather than rendering active quantity and add-to-cart controls that would invite an invalid action.
-
QuantitySelectoris used as a child primitive instead of rebuilding plus/minus logic inside the page. That keeps the page focused on composition and submission, while the selector keeps ownership of quantity interaction rules. -
The add-to-cart button uses
isCartLoadingto protect against repeated submissions and to give visible feedback through the label change from"Add to cart"to"Adding...". That is a small UX detail, but it makes the page feel much more responsive and trustworthy. -
The secondary “Back to shop” action gives the shopper a clear way to return to browsing. This is useful because the product page should support both deeper inspection and an easy path back to the catalog.
This page is a really useful composition example: one hook reads product data, one context mutates shared cart state, one local state value manages temporary quantity choice, and one feedback system communicates result messages.
Building a Focused Cart Summary Component
The file src/components/cart/CartSummary.tsx is a good example of a compact presentational component. Its job is not to own cart logic or checkout rules. Its job is to display totals and a checkout call-to-action using the props passed in by the parent.
That is a valuable design choice because presentational components are much easier to reuse and reason about when they accept plain props instead of reaching into shared context directly.
Here is the start of the file:
-
The incoming props make the contract very explicit.
CartSummaryneeds totals to display, a tax-country context string, a checkout callback, and state flags that control the button. -
The early return for missing totals is important because it keeps the component safe. If the cart does not yet have totals, there is nothing meaningful to render, so returning
nullis a clean way to avoid misleading or incomplete UI. -
isCheckoutDisabledandisSubmittingcome from the parent rather than being decided inside this component. That is exactly the right direction of responsibility: the parent orchestrates feature rules, while the summary simply reflects them.
Now look at the rendered summary:
-
formatMoney()andformatTaxRate()keep numeric display formatting consistent with the rest of the storefront. That prevents one page from showing currency or tax information differently than another. -
The tax-country row is especially useful because it tells the shopper what the tax estimate is based on. A total becomes easier to trust when the page clearly explains whether a specific country code is being used or whether the default rate is still applied.
-
The checkout button label changes based on
isSubmitting, which gives the shopper immediate feedback while checkout is in progress. At the same time, disabled state is controlled by the parent, so the summary does not have to understand all the orchestration rules behind that decision. -
Visually, this component stays compact and sidebar-like, which is exactly what it should be. It supports the main cart page content instead of trying to become a second full page inside the page.
This is the kind of component that improves maintainability by keeping display structure separate from feature orchestration.
Turning the Cart Route into the Real Cart Experience
Now we come to the main orchestration screen: src/components/cart/CartPageClient.tsx. This component is where the shopper reviews line items, updates quantities, applies a tax country, sees current totals, and submits checkout.
The key architectural rule here is that the page should treat useCart() as the source of truth. It should not duplicate line items or totals into extra state. Local state should only exist where it genuinely belongs, such as the editable tax-country input and temporary submission flags.
At the top of the file, the imports and state setup show that orchestration role clearly:
-
useCart()supplies the current cart snapshot and the actions the page needs. This is a strong sign the context is doing enough: the page does not need to manage its own copy of the cart or know anything about storage restoration. -
taxCountryis local state because it represents an editable input value before submission. That is exactly the kind of temporary UI state a page should own directly. -
isSubmittingTaxandisCheckingOutare also local because they represent short-lived in-page submission states. These are not global feature truths like the cart itself; they are UI-level flags about ongoing actions. -
useEffect()synchronizes the local tax-country input from the current cart whenever the cart’s server-confirmedtax_countrychanges. This is important because the field should reflect what the backend currently believes, not just what the user last typed. -
openCartItemsis derived withuseMemo()fromcart?.items ?? []. That keeps the page logic simple and ensures item mapping code always works with an array.
Now look at checkout handling and the early state branches:
-
handleCheckout()shows the general pattern for async UI actions in this course: set a local loading flag, call the shared action, handle success, translate failures into friendly toast feedback, and reset the local loading state infinally. -
Redirecting to
/checkout/success/${order.id}after a successful checkout is an important part of the flow. It gives the shopper a clear transition from cart review into order confirmation. -
The state branches are rendered in a deliberate order: restoring, unrecoverable error, empty cart, then full cart experience. This order matters because it keeps the user from seeing misleading content while cart restoration is still happening.
-
!isReadyis the hydration-safe restoration phase. Until the cart provider has finished its first restore attempt, the page should not guess whether the cart is empty. -
errorMessage && !cartrepresents a meaningful unrecoverable failure state. If there is no usable cart snapshot and the provider surfaced an error, the page should showErrorStaterather than trying to render partial cart UI. -
The empty cart state gives the shopper a clear next step by linking back to
/shop. This is a much better experience than leaving them on a blank or confusing cart page.
Now we can look at the full cart UI:
-
Each cart item renders the identifying information a shopper actually needs: SKU, name, unit pricing, editable quantity, computed line total, and a remove action. This makes the cart feel like a review screen instead of just a raw data dump.
-
The line total is computed inline from
item.quantity * item.unit_price_cents, then formatted withformatMoney(). That keeps per-line pricing easy to understand. -
QuantitySelectoris reused here as a controlled editor for line-item quantity, which is exactly why the component was built as a small prop-driven primitive. The cart page can adopt it without the selector needing any cart-specific branching logic. -
The tax-country section uses local input state and an explicit Apply action. That is an important UX choice because it avoids recalculating tax on every keystroke and makes the update feel intentional.
-
Converting the typed value to uppercase on change and again trimming/uppercasing on submission helps keep the country code clean and predictable. The
maxLength={2}rule reinforces the two-letter input expectation directly in the UI. -
The Apply button is disabled unless the current trimmed input length is exactly
2, which prevents obviously invalid submissions before they reach the shared cart action. -
On success, the page shows a success toast instead of quietly updating totals with no feedback. On failure, it uses
getErrorMessage(error)to surface a readable message. This keeps the tax update interaction aligned with the rest of the app’s feedback style. -
CartSummaryis wired with the live cart totals and current tax country from the shared cart snapshot, plus the page’s checkout handler and button-state flags. This is a very clean division of labor: the page orchestrates, while the summary presents.
This page is doing exactly the work a feature page should do: coordinating multiple shared systems without trying to replace them.
Wiring the Cart Route to the Real Client Page
Now that CartPageClient exists, src/app/cart/page.tsx needs to render it instead of a placeholder.
-
This route file stays intentionally thin, which continues the pattern established throughout the course. The route identifies which page component should render, while the actual cart experience lives in the dedicated client component.
-
That separation is helpful because the route file remains easy to scan and easy to maintain, while the feature-specific logic stays in a place where it can compose hooks and UI primitives freely.
Completing the Success Redirect Flow
After a successful checkout, the cart page redirects to src/app/checkout/success/[id]/page.tsx. This file keeps the success screen lightweight for now while still using the shared layout and UI system.
-
The dynamic route param
idgives the page access to the order ID created during checkout. That lets the UI confirm exactly which order was created instead of only showing a generic success message. -
Reusing
PageContainer,EmptyState, andButtonkeeps the success page aligned with the rest of the storefront instead of introducing one-off confirmation markup. -
This screen is intentionally simple because the full dedicated confirmation experience comes later. Even so, it already provides a meaningful endpoint for the checkout flow and a clear action back to the shop.
Recap
In this lesson, you completed the core shopping-cart and checkout experience for the storefront.
You started with src/components/cart/QuantitySelector.tsx, where a small controlled component handles incrementing and decrementing quantities while respecting minimum and optional maximum boundaries. Then you enhanced src/components/products/ProductDetailPageClient.tsx by adding local quantity state, cart mutation through useCart(), toast feedback through useToast(), and availability-aware add-to-cart controls.
Next, you built src/components/cart/CartSummary.tsx as a focused presentational component that displays subtotal, tax, tax context, total, and the checkout button using incoming props and shared format helpers. After that, src/components/cart/CartPageClient.tsx became the orchestration layer for the whole cart experience: restoring state, handling unrecoverable errors, showing the empty-cart branch, rendering editable line items, applying tax-country updates, and submitting checkout with redirect to the success page.
Finally, src/app/cart/page.tsx now renders the real cart client component, and src/app/checkout/success/[id]/page.tsx provides a simple but meaningful destination after a successful order.
The main architectural takeaway is that this feature works well because each piece stays focused. The quantity selector is reusable and prop-driven. The product page composes reading, mutation, local UI state, and feedback. The summary stays presentational. The cart page orchestrates instead of duplicating shared logic. And the route files remain thin. That layered structure is what turns a complicated checkout flow into code that stays readable and maintainable.
