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:

import { Button } from '@/components/ui/Button';

export function QuantitySelector({
  value,
  min = 1,
  max,
  onChange,
  disabled = false,
}: {
  value: number;
  min?: number;
  max?: number;
  onChange: (value: number) => void;
  disabled?: boolean;
}) {
  const decrementDisabled = disabled || value <= min;
  const incrementDisabled = disabled || (max !== undefined && value >= max);
  • The component receives its current value from 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 through onChange().

  • min defaults to 1, which is a sensible baseline for cart and purchase flows. In most storefront scenarios, letting the user decrement below 1 would create invalid behavior, so the component protects that rule directly.

  • max is 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.

  • decrementDisabled and incrementDisabled combine 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:

  return (
    <div className="inline-flex items-center rounded-full border border-stone-200 bg-white p-1">
      <Button
        type="button"
        variant="ghost"
        size="sm"
        disabled={decrementDisabled}
        onClick={() => onChange(Math.max(min, value - 1))}
        aria-label="Decrease quantity"
      >
        -
      </Button>
      <span className="min-w-10 text-center text-sm font-medium text-stone-900">{value}</span>
      <Button
        type="button"
        variant="ghost"
        size="sm"
        disabled={incrementDisabled}
        onClick={() => onChange(max !== undefined ? Math.min(max, value + 1) : value + 1)}
        aria-label="Increase quantity"
      >
        +
      </Button>
    </div>
  );
}
  • 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 max exists, it clamps with Math.min(max, value + 1); if no max is provided, it simply increments normally. This makes the selector reusable in both bounded and unbounded contexts.

  • The aria-label values 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 Button component 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:

'use client';

import Link from 'next/link';
import { useState } from 'react';
import { PageContainer } from '@/components/layout/PageContainer';
import { QuantitySelector } from '@/components/cart/QuantitySelector';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ErrorState } from '@/components/ui/ErrorState';
import { LoadingState } from '@/components/ui/LoadingState';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { useToast } from '@/components/ui/Toast';
import { getErrorMessage } from '@/lib/api/client';
import { useCart } from '@/lib/hooks/useCart';
import { useProduct } from '@/lib/hooks/useProduct';
import { formatMoney, getInventoryLabel } from '@/lib/utils/format';
export function ProductDetailPageClient({ productId }: { productId: string }) {
  const { product, isLoading, errorMessage } = useProduct(productId);
  const [quantity, setQuantity] = useState(1);
  const { addItem, isLoading: isCartLoading } = useCart();
  const toast = useToast();

  async function handleAddToCart() {
    if (!product) return;
    try {
      await addItem(product.id, quantity);
      toast.success('Added to cart.');
    } catch (error) {
      toast.error(getErrorMessage(error));
    }
  }
  • 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.

  • quantity is local page state managed with useState(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() provides addItem() 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 through addItem(), and translates failures into a friendly toast with getErrorMessage(error). That last detail matters because user-facing error feedback should be readable and consistent.

The existing early-return state branches remain intact:

  if (isLoading) {
    return (
      <PageContainer className="py-12 md:py-16">
        <LoadingState message="Loading product details..." />
      </PageContainer>
    );
  }

  if (errorMessage) {
    return (
      <PageContainer className="py-12 md:py-16">
        <ErrorState message={errorMessage} />
      </PageContainer>
    );
  }

  if (!product) {
    return (
      <PageContainer className="py-12 md:py-16">
        <EmptyState title="Product not found" description="This product is no longer available or could not be found." />
      </PageContainer>
    );
  }

  const isUnavailable = product.status === 'archived' || product.inventory_count <= 0;
  • 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.

  • isUnavailable is 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:

  return (
    <PageContainer className="py-12 md:py-16">
      <div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
        <div className="rounded-[2.5rem] border border-stone-200 bg-[#e7dcc7] p-8 shadow-sm">
          <div className="flex h-full min-h-[24rem] flex-col justify-between rounded-[2rem] bg-white/70 p-8">
            <div className="flex items-start justify-between gap-4">
              <div>
                <p className="text-xs font-semibold uppercase tracking-[0.25em] text-stone-500">{product.sku}</p>
                <h1 className="mt-3 font-serif text-4xl tracking-tight text-stone-950 md:text-5xl">{product.name}</h1>
              </div>
              <StatusBadge status={product.status} />
            </div>
            <p className="mt-8 max-w-2xl text-base leading-7 text-stone-600">
              {product.description?.trim() || 'A thoughtfully made piece with clear pricing and up-to-date availability.'}
            </p>
          </div>
        </div>
        <div className="space-y-6 rounded-[2.5rem] border border-stone-200 bg-white p-8 shadow-sm">
          <div>
            <p className="text-sm uppercase tracking-[0.25em] text-stone-500">Price</p>
            <p className="mt-3 font-serif text-5xl text-stone-950">{formatMoney(product.price_cents, product.currency)}</p>
            <p className="mt-3 text-sm text-stone-600">{getInventoryLabel(product)}</p>
          </div>

          {isUnavailable ? (
            <div className="rounded-[1.75rem] border border-amber-200 bg-amber-50 p-5 text-sm leading-6 text-amber-900">
              {product.status === 'archived'
                ? 'This product has been archived and cannot be added to the cart.'
                : 'This product is currently out of stock.'}
            </div>
          ) : (
            <div className="space-y-4">
              <label className="block text-sm text-stone-600">
                Quantity
                <div className="mt-3">
                  <QuantitySelector
                    value={quantity}
                    onChange={setQuantity}
                    max={product.inventory_count}
                    disabled={isCartLoading}
                  />
                </div>
              </label>
              <Button className="w-full" size="lg" onClick={handleAddToCart} disabled={isCartLoading}>
                {isCartLoading ? 'Adding...' : 'Add to cart'}
              </Button>
              <Link href="/shop">
                <Button variant="secondary" className="w-full">Back to shop</Button>
              </Link>
            </div>
          )}
        </div>
      </div>
    </PageContainer>
  );
}
  • 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() and getInventoryLabel() 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.

  • QuantitySelector is 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 isCartLoading to 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:

import { Button } from '@/components/ui/Button';
import { formatMoney, formatTaxRate } from '@/lib/utils/format';
import { CartTotals } from '@/types/domain';

export function CartSummary({
  totals,
  taxCountry,
  onCheckout,
  isCheckoutDisabled,
  isSubmitting = false,
}: {
  totals: CartTotals | undefined;
  taxCountry: string | null | undefined;
  onCheckout: () => void;
  isCheckoutDisabled: boolean;
  isSubmitting?: boolean;
}) {
  if (!totals) return null;
  • The incoming props make the contract very explicit. CartSummary needs 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 null is a clean way to avoid misleading or incomplete UI.

  • isCheckoutDisabled and isSubmitting come 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:

  return (
    <aside className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
      <p className="font-serif text-2xl text-stone-950">Summary</p>
      <div className="mt-6 space-y-4 text-sm">
        <div className="flex items-center justify-between text-stone-600">
          <span>Subtotal</span>
          <span>{formatMoney(totals.subtotal_cents, totals.currency)}</span>
        </div>
        <div className="flex items-center justify-between text-stone-600">
          <span>Tax</span>
          <span>{formatMoney(totals.tax_cents, totals.currency)}</span>
        </div>
        <div className="rounded-2xl bg-stone-50 px-4 py-3 text-xs uppercase tracking-[0.2em] text-stone-500">
          {taxCountry ? `Tax country ${taxCountry}` : 'Default tax rate applied'} · {formatTaxRate(totals.tax_rate_bps)}
        </div>
        <div className="flex items-center justify-between border-t border-stone-200 pt-4 text-base font-semibold text-stone-950">
          <span>Total</span>
          <span>{formatMoney(totals.total_cents, totals.currency)}</span>
        </div>
      </div>
      <Button className="mt-6 w-full" size="lg" onClick={onCheckout} disabled={isCheckoutDisabled}>
        {isSubmitting ? 'Placing order...' : 'Checkout'}
      </Button>
    </aside>
  );
}
  • formatMoney() and formatTaxRate() 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:

'use client';

import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { PageContainer } from '@/components/layout/PageContainer';
import { CartSummary } from '@/components/cart/CartSummary';
import { QuantitySelector } from '@/components/cart/QuantitySelector';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ErrorState } from '@/components/ui/ErrorState';
import { LoadingState } from '@/components/ui/LoadingState';
import { SectionHeading } from '@/components/ui/SectionHeading';
import { useToast } from '@/components/ui/Toast';
import { getErrorMessage } from '@/lib/api/client';
import { useCart } from '@/lib/hooks/useCart';
import { formatMoney } from '@/lib/utils/format';
export function CartPageClient() {
  const router = useRouter();
  const toast = useToast();
  const { cart, isReady, isLoading, errorMessage, updateItem, removeItem, setTaxCountry, checkout } = useCart();
  const [taxCountry, setTaxCountryValue] = useState(cart?.tax_country ?? 'US');
  const [isSubmittingTax, setIsSubmittingTax] = useState(false);
  const [isCheckingOut, setIsCheckingOut] = useState(false);

  const openCartItems = useMemo(() => cart?.items ?? [], [cart?.items]);

  useEffect(() => {
    setTaxCountryValue(cart?.tax_country ?? 'US');
  }, [cart?.tax_country]);
  • 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.

  • taxCountry is 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.

  • isSubmittingTax and isCheckingOut are 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-confirmed tax_country changes. This is important because the field should reflect what the backend currently believes, not just what the user last typed.

  • openCartItems is derived with useMemo() from cart?.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:

  async function handleCheckout() {
    setIsCheckingOut(true);
    try {
      const order = await checkout();
      router.push(`/checkout/success/${order.id}`);
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsCheckingOut(false);
    }
  }

  if (!isReady) {
    return (
      <PageContainer className="py-12 md:py-16">
        <LoadingState message="Restoring your cart..." />
      </PageContainer>
    );
  }

  if (errorMessage && !cart) {
    return (
      <PageContainer className="py-12 md:py-16">
        <ErrorState message={errorMessage} />
      </PageContainer>
    );
  }

  if (!cart || openCartItems.length === 0) {
    return (
      <PageContainer className="py-12 md:py-16">
        <EmptyState
          title="Your cart is empty"
          description="Add products from the catalog to create a cart, then return here to manage quantities, set tax, and checkout."
          action={
            <Link href="/shop">
              <Button size="lg">Start shopping</Button>
            </Link>
          }
        />
      </PageContainer>
    );
  }
  • 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 in finally.

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

  • !isReady is 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 && !cart represents a meaningful unrecoverable failure state. If there is no usable cart snapshot and the provider surfaced an error, the page should show ErrorState rather 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:

  return (
    <PageContainer className="space-y-8 py-12 md:py-16">
      <SectionHeading
        eyebrow="Cart"
        title="Review line items and complete checkout"
        description="Update quantities, check totals, and make any final adjustments before placing your order."
      />
      <div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
        <div className="space-y-4">
          {openCartItems.map((item) => (
            <article key={item.id} className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
              <div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
                <div className="space-y-2">
                  <p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">{item.product?.sku ?? 'Product'}</p>
                  <h2 className="font-serif text-2xl text-stone-950">{item.product?.name ?? item.product_id}</h2>
                  <p className="text-sm text-stone-500">Unit price {formatMoney(item.unit_price_cents, item.product?.currency ?? 'USD')}</p>
                </div>
                <div className="flex flex-col items-start gap-4 md:items-end">
                  <QuantitySelector value={item.quantity} onChange={(value) => void updateItem(item.id, value)} disabled={isLoading} />
                  <p className="text-lg font-semibold text-stone-950">
                    {formatMoney(item.quantity * item.unit_price_cents, item.product?.currency ?? 'USD')}
                  </p>
                  <Button variant="ghost" onClick={() => void removeItem(item.id)} disabled={isLoading}>
                    Remove
                  </Button>
                </div>
              </div>
            </article>
          ))}
        </div>
        <div className="space-y-6">
          <div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
            <p className="font-serif text-2xl text-stone-950">Tax country</p>
            <p className="mt-2 text-sm leading-6 text-stone-600">
              Choose a two-letter country code to update estimated tax before checkout.
            </p>
            <div className="mt-5 flex flex-col gap-3 sm:flex-row">
              <label className="flex-1 text-sm text-stone-600">
                Country code
                <input
                  value={taxCountry}
                  onChange={(event) => setTaxCountryValue(event.target.value.toUpperCase())}
                  maxLength={2}
                  className="mt-2 w-full rounded-2xl border border-stone-200 px-4 py-3 text-stone-900 outline-none focus:border-stone-400"
                />
              </label>
              <div className="flex items-end">
                <Button
                  onClick={async () => {
                    setIsSubmittingTax(true);
                    try {
                      await setTaxCountry(taxCountry.trim().toUpperCase());
                      toast.success('Tax country updated.');
                    } catch (error) {
                      toast.error(getErrorMessage(error));
                    } finally {
                      setIsSubmittingTax(false);
                    }
                  }}
                  disabled={isSubmittingTax || taxCountry.trim().length !== 2}
                >
                  {isSubmittingTax ? 'Updating...' : 'Apply'}
                </Button>
              </div>
            </div>
          </div>

          <CartSummary
            totals={cart.totals}
            taxCountry={cart.tax_country}
            onCheckout={handleCheckout}
            isCheckoutDisabled={isLoading || isCheckingOut}
            isSubmitting={isCheckingOut}
          />
        </div>
      </div>
    </PageContainer>
  );
}
  • 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 with formatMoney(). That keeps per-line pricing easy to understand.

  • QuantitySelector is 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.

  • CartSummary is 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.

import { CartPageClient } from '@/components/cart/CartPageClient';

export default function CartPage() {
  return <CartPageClient />;
}
  • 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.

import Link from 'next/link';
import { PageContainer } from '@/components/layout/PageContainer';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';

export default async function CheckoutSuccessPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  return (
    <PageContainer className="py-12 md:py-16">
      <EmptyState
        title="Order placed"
        description={`Order ${id} was created successfully. You will build the dedicated confirmation screen in the next unit.`}
        action={
          <Link href="/shop">
            <Button size="lg">Continue shopping</Button>
          </Link>
        }
      />
    </PageContainer>
  );
}
  • The dynamic route param id gives 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, and Button keeps 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.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal