Customize Storefront for Ticket Booking

In this tutorial, you'll customize the Next.js Starter Storefront based on the Ticket Booking system you implemented in the backend.

Summary#

In this tutorial, you'll customize the Next.js Starter Storefront to support ticket booking:

  1. Product Page: Allow customers to choose a show date, select seats from a seating layout, and add tickets to the cart.
  2. Cart Page: Display selected tickets with their seat numbers and show dates, and remove quantity controls for ticket items.
  3. Checkout Page: Remove the shipping steps and complete the cart using the custom complete cart API route you created earlier.
  4. Order Confirmation Page: Display ticket details for ticket items.

Preview of the seat selection on the product page

Reminder: 

If you installed the Next.js Starter Storefront with the Medusa backend, the storefront was installed in a separate directory. The directory's name is {your-project}-storefront.

So, if your Medusa application's directory is medusa-ticketing, you can find the storefront by going back to the parent directory and changing to the medusa-ticketing-storefront directory:

Terminal
cd ../medusa-ticketing-storefront # change based on your project name

Step 1: Ticket Product Utilities#

Before you customize the storefront, you'll create some types and utility functions that you'll use in your customizations.

Create the file src/lib/util/ticket-product.ts with the following content:

src/lib/util/ticket-product.ts
import { HttpTypes } from "@medusajs/types"

export interface TicketProductAvailability {
  date: string
  row_types: {
    row_type: string
    total_seats: number
    available_seats: number
    sold_out: boolean
  }[]
  sold_out: boolean
}

export interface TicketProduct {
  id: string
  product_id: string
  venue: {
    id: string
    name: string
    address: string
    rows: Array<{
      id: string
      row_number: string
      row_type: string
      seat_count: number
    }>
  }
  dates: string[]
}

export interface TicketProductAvailabilityData {
  ticket_product: TicketProduct
  availability: TicketProductAvailability[]
}

export interface TicketProductSeatsData {
  venue: {
    id: string
    name: string
    address: string
    rows: Array<{
      id: string
      row_number: string
      row_type: string
      seat_count: number
    }>
  }
  date: string
  seat_map: {
    row_number: string
    row_type: string
    seats: {
      number: string
      is_purchased: boolean
      variant_id: string | null
    }[]
  }[]
}

/**
 * Check if a product is a ticket product by looking for the ticket_product property
 */
export function isTicketProduct(product: HttpTypes.StoreProduct): boolean {
  return !!(product as any).ticket_product
}

You define types for the ticket product availability and seating layout API responses, as well as a utility function to check if a product is a ticket product.


Step 2: Support Adding Items to Cart with Metadata#

Next, you'll add support for adding items to the cart with metadata. This is necessary because when adding tickets to the cart, you need to include metadata such as the seat number and show date.

This functionality is supported by the Medusa APIs, but you need to update the addToCart function in the storefront to accept and send metadata.

In src/lib/data/cart.ts, find the addToCart function and update its object parameter to include an optional metadata property:

src/lib/data/cart.ts
export async function addToCart({
  // ...
  metadata,
}: {
  // ...
  metadata?: Record<string, any>
}) {
  // ...
}

Then, find the sdk.store.cart.createLineItem method usage and pass the metadata property to it:

src/lib/data/cart.ts
await sdk.store.cart
  .createLineItem(
    cart.id,
    {
      // ...
      metadata,
    }
    // ...
  )

Now, the addToCart function accepts an optional metadata property and sends it to the Medusa backend when creating a line item.


Step 3: Customize Product Page#

Next, you'll customize the product page. This includes:

  • Creating a component that displays a calendar to choose a show date, with available dates determined by the availability API route you created earlier.
  • Creating a component that displays a seat chart in a modal to select seats, with purchased seats determined by the seating layout API route you created earlier.
  • Customizing the product page to use the above components.

Date Selector Component#

The date selector component will fetch the available dates for a ticket product using the API route you created in the backend part of the tutorial. Before creating the component, you'll add a server function to fetch the available dates.

Create the file src/lib/data/ticket-products.ts with the following content:

src/lib/data/ticket-products.ts
"use server"

import { sdk } from "@lib/config"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { TicketProductAvailabilityData } from "@lib/util/ticket-product"

export const getTicketProductAvailability = async (
  productId: string
): Promise<TicketProductAvailabilityData> => {
  const headers = {
    ...(await getAuthHeaders()),
  }

  const next = {
    ...(await getCacheOptions("ticket-products")),
  }

  return sdk.client
    .fetch<TicketProductAvailabilityData>(
      `/store/ticket-products/${productId}/availability`,
      {
        method: "GET",
        headers,
        next,
        cache: "no-store", // Always fetch fresh data for availability
      }
    )
    .then((data) => data)
}

You create a server function getTicketProductAvailability that fetches the available dates for a ticket product using the /store/ticket-products/{id}/availability API route.

Next, you'll create the date selector component. It will show an input to select the number of tickets and a calendar to select a date.

Preview of the date selector component

Create the file src/modules/products/components/ticket-product-layout/date-quantity-selection.tsx with the following content:

src/modules/products/components/ticket-product-layout/date-quantity-selection.tsx
import { useState, useEffect } from "react"

type TicketDateSelectionProps = {
  product: HttpTypes.StoreProduct
  onDateSelect: (date: string, nbOfTickets: number) => void
  disabled?: boolean
}

export default function TicketDateSelection({
  product,
  onDateSelect,
  disabled = false,
}: TicketDateSelectionProps) {
  const [nbOfTickets, setNbOfTickets] = useState(1)
  const [selectedDate, setSelectedDate] = useState<Date | null>(null)
  const [availability, setAvailability] = useState<TicketProductAvailability[]>([])
  const [isLoading, setIsLoading] = useState(false)

  // Load availability data on mount
  useEffect(() => {
    const loadAvailability = async () => {
      setIsLoading(true)
      try {
        const data = await getTicketProductAvailability(product.id)
        setAvailability(data.availability)
      } catch (error) {
        toast.error("Failed to load ticket availability: " + error)
      } finally {
        setIsLoading(false)
      }
    }

    loadAvailability()
  }, [product.id])

  const getTotalAvailableSeats = (dateAvailability: TicketProductAvailability) => {
    return dateAvailability.row_types.reduce(
      (sum, rowType) => sum + rowType.available_seats, 0
    )
  }

  const getFilteredAvailability = (quantity: number) => {
    return availability.filter((avail) => getTotalAvailableSeats(avail) >= quantity)
  }

  const filteredAvailability = getFilteredAvailability(nbOfTickets)

  const dateAsStr = (date: Date) => {
    return `${
      date.getFullYear()
    }-${
      String(date.getMonth() + 1).padStart(2, "0")
    }-${String(date.getDate()).padStart(2, "0")}`
  }

  const isDateUnavailable = (date: Date) => {
    const dateString = dateAsStr(date)
    return !filteredAvailability.some((avail) => avail.date === dateString)
  }

  const handleDateChange = (date: Date | null) => {
    setSelectedDate(date)
  }

  const handlePickSeats = () => {
    if (!selectedDate) {
      toast.error("Please select a date")
      return
    }
    
    const dateString = dateAsStr(selectedDate)
    onDateSelect(dateString, nbOfTickets)
  }

  // TODO render date selector
}

The TicketDateSelection component accepts the following props:

  • product: The Medusa product to select a date for.
  • onDateSelect: A callback function that is called when the date and number of tickets are selected.
  • disabled: A boolean to disable the component.

In the component, you define state variables to manage the number of tickets, the selected date, the availability data, and the loading state. You also load the availability data when the component mounts.

In addition, you define the following functions:

  • getTotalAvailableSeats: Calculates the total available seats for a given date availability.
  • getFilteredAvailability: Filters the availability data based on the number of tickets requested.
  • dateAsStr: Converts a Date object to a string in the format YYYY-MM-DD.
  • isDateUnavailable: Checks if a date is unavailable based on the filtered availability.
  • handleDateChange: Updates the selected date when the user selects a date from the calendar.
  • handlePickSeats: Calls the onDateSelect callback with the selected date and number of tickets, or shows an error if no date is selected.

Next, you'll render the date selector form. Replace the TODO with the following:

src/modules/products/components/ticket-product-layout/date-quantity-selection.tsx
return (
  <div className="bg-ui-bg-base">
    <h3 className="txt-large text-center mb-4">Select Show Date</h3>
    {isLoading && (
      <div className="flex flex-col gap-y-4">
        <div className="h-6 bg-ui-bg-subtle rounded animate-pulse w-32 mx-auto" />
        <div className="h-10 bg-ui-bg-subtle rounded animate-pulse w-48 mx-auto" />
        <div className="h-6 bg-ui-bg-subtle rounded animate-pulse w-40 mx-auto" />
      </div>
    )}
    {!isLoading && (
      <div className="flex flex-col gap-y-4">
        {/* Number of tickets selection */}
        <div className="flex justify-center">
          <div className="flex flex-col gap-y-2">
            <Label htmlFor="nbOfTickets" className="text-ui-fg-subtle txt-compact-small">Number of Tickets</Label>
            <div className="flex items-center justify-between rounded-md">
              <IconButton
                onClick={() => setNbOfTickets(Math.max(1, nbOfTickets - 1))}
                disabled={disabled || nbOfTickets <= 1}
                variant="transparent"
              >
                <Minus />
              </IconButton>
              {nbOfTickets}
              <IconButton
                onClick={() => setNbOfTickets(Math.min(10, nbOfTickets + 1))}
                disabled={disabled || nbOfTickets >= 10}
                variant="transparent"
              >
                <Plus />
              </IconButton>
            </div>
          </div>
        </div>

        {/* Calendar */}
        <div className="flex justify-center">
          <Calendar
            onChange={handleDateChange}
            minValue={filteredAvailability.length > 0 ? 
              new Date(filteredAvailability[0].date) : undefined
            }
            maxValue={filteredAvailability.length > 0 ? 
              new Date(filteredAvailability[filteredAvailability.length - 1].date) : undefined
            }
            isDateUnavailable={isDateUnavailable}
          />
        </div>

        {/* Available dates info */}
        {filteredAvailability.length > 0 && (
          <p className="txt-small text-ui-fg-subtle text-center txt-compact-small">
            {filteredAvailability.length} show{filteredAvailability.length !== 1 ? "s" : ""} available for {nbOfTickets} ticket{nbOfTickets !== 1 ? "s" : ""}
          </p>
        )}

        {/* Pick Seats Button */}
        <Button
          onClick={handlePickSeats}
          disabled={disabled || !selectedDate || filteredAvailability.length === 0}
          variant="primary"
          className="w-full"
        >
          Pick Seats
        </Button>
      </div>
    )}
  </div>
)

You display a quantity selector to choose the number of tickets, which affects the available dates shown in the calendar based on whether they have enough available seats.

You also display a calendar to select a show date, and a button to pick seats that calls the onDateSelect callback with the selected date and number of tickets.

Seat Selector Component#

The seat selector component will fetch the seating layout for a venue and date using the API route you created in the backend part of the tutorial. Before creating the component, you'll add a server function to fetch the seating layout.

In src/lib/data/ticket-products.ts, add the following import at the top of the file:

src/lib/data/ticket-products.ts
import { TicketProductSeatsData } from "@lib/util/ticket-product"

Then, add the following function to the file:

src/lib/data/ticket-products.ts
export const getTicketProductSeats = async (
  productId: string,
  date: string
): Promise<TicketProductSeatsData> => {
  const headers = {
    ...(await getAuthHeaders()),
  }

  const next = {
    ...(await getCacheOptions("ticket-products")),
  }

  return sdk.client
    .fetch<TicketProductSeatsData>(
      `/store/ticket-products/${productId}/seats`,
      {
        method: "GET",
        query: {
          date,
        },
        headers,
        next,
        cache: "no-store", // Always fetch fresh data for seats
      }
    )
    .then((data) => data)
}

You create a server function getTicketProductSeats that fetches the seating layout for a ticket product and date using the /store/ticket-products/{id}/seats API route.

Next, you'll create the seat selector component. It will show a modal with a seat chart to select seats, and a button to add the seats to the cart and proceed to checkout.

Preview of the seat selector component

You'll first create the seat chart component that displays the seating layout and allows selecting seats. Create the file src/modules/products/components/ticket-product-layout/seat-selection.tsx with the following content:

src/modules/products/components/ticket-product-layout/seat-selection.tsx
import { convertToLocale } from "../../../../lib/util/money"

export type SelectedSeat = {
  seatNumber: string
  rowNumber: string
  rowType: string
  variantId: string
  date: string
  venueRowId: string
}

type SeatSelectionProps = {
  seatData: TicketProductSeatsData
  selectedSeats: SelectedSeat[]
  onSeatSelect: (seat: SelectedSeat) => void
  disabled?: boolean
  product: HttpTypes.StoreProduct
  maxSeats: number
}

export default function SeatSelection({
  seatData,
  selectedSeats,
  onSeatSelect,
  disabled = false,
  product,
  maxSeats,
}: SeatSelectionProps) {
  const getSeatStatus = (rowNumber: string, seatNumber: string) => {
    const seat = seatData.seat_map
      .find((row) => row.row_number === rowNumber)
      ?.seats.find((s) => s.number === seatNumber)

    if (!seat) {return "unavailable"}

    if (seat.is_purchased) {return "purchased"}
    
    const isSelected = selectedSeats.some((s) => 
      s.seatNumber === seatNumber && s.rowNumber === rowNumber && s.date === seatData.date
    )
    
    if (isSelected) {return "selected"}
    
    return "available"
  }

  const getSeatColor = (status: string) => {
    switch (status) {
      case "purchased":
        return "bg-ui-tag-neutral-bg text-ui-tag-neutral-text cursor-not-allowed"
      case "selected":
        return "bg-ui-tag-blue-bg text-ui-tag-blue-text"
      case "available":
        return "bg-ui-tag-green-bg text-ui-tag-green-text cursor-pointer hover:bg-ui-tag-green-bg-hover"
      default:
        return "bg-ui-tag-neutral-bg text-ui-tag-neutral-text cursor-not-allowed"
    }
  }

  const formatRowType = (rowType: string) => {
    switch (rowType.toLowerCase()) {
      case "vip":
        return "VIP"
      default:
        return rowType.charAt(0).toUpperCase() + rowType.slice(1).toLowerCase()
    }
  }

  // TODO handle seat selection
}

The SeatSelection component accepts the following props:

  • seatData: The seating layout data for the venue and date retrieved from the API.
  • selectedSeats: An array of currently selected seats.
  • onSeatSelect: A callback function that is called when a seat is selected.
  • disabled: A boolean to disable seat selection.
  • product: The Medusa product to select seats for.
  • maxSeats: The maximum number of seats that can be selected.

In the component, you define the following functions:

  • getSeatStatus: Determines the status of a seat (purchased, selected, available, or unavailable) based on the seating data and selected seats.
  • getSeatColor: Returns the appropriate CSS classes for a seat based on its status.
  • formatRowType: Formats the row type string for display.

Next, you'll add a function to handle seat selection. Replace the TODO with the following:

src/modules/products/components/ticket-product-layout/seat-selection.tsx
const handleSeatClick = (rowNumber: string, seatNumber: string, rowType: string) => {
  if (disabled) {return}

  const seat = seatData.seat_map
    .find((row) => row.row_number === rowNumber)
    ?.seats.find((s) => s.number === seatNumber)

  if (!seat || seat.is_purchased || !seat.variant_id) {return}

  // Check if seat is already selected
  const isAlreadySelected = selectedSeats.some(
    (selectedSeat) => selectedSeat.seatNumber === seatNumber && selectedSeat.rowNumber === rowNumber
  )

  if (isAlreadySelected) {
    // Unselect the seat
    onSeatSelect({
      seatNumber,
      rowNumber,
      rowType,
      variantId: seat.variant_id,
      date: seatData.date,
      venueRowId: seatData.venue.rows.find((row) => row.row_number === rowNumber)?.id as string,
    })
    return
  }

  // Check if we've reached the maximum number of seats
  if (selectedSeats.length >= maxSeats) {
    return
  }

  // Select the seat
  onSeatSelect({
    seatNumber,
    rowNumber,
    rowType,
    variantId: seat.variant_id,
    date: seatData.date,
    venueRowId: seatData.venue.rows.find((row) => row.row_number === rowNumber)?.id as string,
  })
}

// TODO render seat chart

You define the handleSeatClick function that handles the logic for toggling seat selection. It checks if the seat is purchasable, if it's already selected, and if the maximum number of seats has been reached before calling the onSeatSelect callback.

Finally, you'll render the seat chart. Replace the TODO with the following:

src/modules/products/components/ticket-product-layout/seat-selection.tsx
return (
  <TooltipProvider>
    <div className="flex flex-col gap-y-4">
      {/* Theater Layout */}
      <div className="bg-gradient-to-b from-ui-bg-subtle to-ui-bg-subtle rounded-lg p-4 shadow-elevation-card-rest overflow-y-auto max-h-[500px]">
        {/* Stage Area */}
        <div className="text-center mb-6">
          <div className="bg-gradient-to-b from-ui-fg-base to-ui-fg-base text-white px-8 py-4 rounded-lg shadow-xl relative">
            <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent rounded-lg"></div>
            <div className="relative">
              <div className="txt-large-plus mb-1">STAGE</div>
              <div className="txt-small text-ui-alpha-250">Performance Area</div>
            </div>
          </div>
        </div>

        {/* Seating Area */}
        <div>
          <div className="space-y-4">
            {seatData.seat_map.map((row, index) => (
              <div key={row.row_number} className="flex items-baseline justify-center gap-x-3">
                {/* Row label */}
                <div className="txt-small text-ui-fg-subtle">
                  {row.row_number}
                </div>

                {/* Seats */}
                <div className="flex gap-x-1 gap-y-1 flex-wrap justify-center">
                  {row.seats.map((seat) => {
                    const status = getSeatStatus(row.row_number, seat.number)
                    const variant = product.variants?.find((v) => v.id === seat.variant_id)
                    const seatPrice = variant?.calculated_price?.calculated_amount || 0
                    const currencyCode = variant?.calculated_price?.currency_code || "USD"
                    
                    const tooltipContent = status === "purchased" 
                      ? "Sold" 
                      : status === "selected"
                      ? "Selected"
                      : seatPrice > 0
                      ? `Seat ${seat.number} - ${formatRowType(row.row_type)} - ${convertToLocale({
                          amount: seatPrice,
                          currency_code: currencyCode,
                          minimumFractionDigits: 0,
                        })}`
                      : `Seat ${seat.number} - ${formatRowType(row.row_type)}`

                    return (
                      <Tooltip key={seat.number} content={tooltipContent} className="z-[76]">
                        <button
                          onClick={() => handleSeatClick(row.row_number, seat.number, row.row_type)}
                          disabled={disabled || status === "purchased" || status === "unavailable" || (status === "available" && selectedSeats.length >= maxSeats)}
                          className={`
                            w-8 h-8 rounded-sm txt-xsmall transition-all duration-200 flex items-center justify-center
                            ${getSeatColor(status)}
                            ${status === "purchased" ? "cursor-not-allowed" : "cursor-pointer"}
                            ${(status === "available" && selectedSeats.length >= maxSeats) || status === "purchased" ? "opacity-50 cursor-not-allowed" : ""}
                            ${status === "available" ? "shadow-sm" : ""}
                            ${status === "selected" ? "border border-ui-border-interactive" : ""}
                          `}
                        >
                          {seat.number}
                        </button>
                      </Tooltip>
                    )
                  })}
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>

      {/* Legend */}
      <div className="flex flex-wrap justify-center gap-x-6 gap-y-2 txt-small">
        <div className="flex items-center gap-x-2">
          <div className="w-5 h-5 bg-ui-tag-green-bg rounded"></div>
          <span className="txt-small-plus">Available</span>
        </div>
        <div className="flex items-center gap-x-2">
          <div className="w-5 h-5 bg-ui-tag-blue-bg rounded"></div>
          <span className="txt-small-plus">Selected</span>
        </div>
        <div className="flex items-center gap-x-2">
          <div className="w-5 h-5 bg-ui-tag-neutral-bg rounded"></div>
          <span className="txt-small-plus">Sold</span>
        </div>
      </div>
    </div>
  </TooltipProvider>
)

You display the seating layout with rows and seats. Each seat is a button that shows its status (available, selected, purchased) with different colors and tooltips. You also include a legend to explain the seat colors.

Next, you'll create the seat selector modal component that renders the SeatSelection component inside a modal. Create the file src/modules/products/components/ticket-product-layout/seat-selection-modal.tsx with the following content:

src/modules/products/components/ticket-product-layout/seat-selection-modal.tsx
import Modal from "../../../common/components/modal"

type SeatSelectionModalProps = {
  product: HttpTypes.StoreProduct
  selectedDate: string
  selectedQuantity: number
  isOpen: boolean
  onClose: () => void
  disabled?: boolean
}

export default function SeatSelectionModal({
  product,
  selectedDate,
  selectedQuantity,
  isOpen,
  onClose,
  disabled = false,
}: SeatSelectionModalProps) {
  const [selectedSeats, setSelectedSeats] = useState<SelectedSeat[]>([])
  const [seatData, setSeatData] = useState<TicketProductSeatsData | null>(null)
  const [isAddingToCart, setIsAddingToCart] = useState(false)
  
  const countryCode = useParams().countryCode as string
  const router = useRouter()

  const formatDate = (dateString: string) => {
    const date = new Date(dateString)
    return date.toLocaleDateString("en-US", {
      weekday: "long",
      year: "numeric",
      month: "long",
      day: "numeric",
    })
  }

  const totalPrice = useMemo(() => {
    return selectedSeats.reduce((total, seat) => {
      const variant = product.variants?.find((v) => v.id === seat.variantId)
      return total + (variant?.calculated_price?.calculated_amount || 0)
    }, 0)
  }, [selectedSeats, product.variants])

  // Load seat data when modal opens
  useEffect(() => {
    if (!isOpen || !selectedDate) {
      return
    }
    setSelectedSeats([])
    setSeatData(null)

    const loadSeatData = async () => {
      try {
        const data = await getTicketProductSeats(product.id, selectedDate)
        setSeatData(data)
      } catch (error) {
        toast.error("Failed to load seat data: " + error)
      }
    }

    loadSeatData()
  }, [isOpen, selectedDate, product.id])

  // TODO handle seat selection and cart addition
}

The SeatSelectionModal component accepts the following props:

  • product: The Medusa product to select seats for.
  • selectedDate: The selected show date.
  • selectedQuantity: The number of tickets to select.
  • isOpen: A boolean to control the visibility of the modal.
  • onClose: A callback function that is called when the modal is closed.
  • disabled: A boolean to disable seat selection.

In the component, you define state and memo variables to manage the selected seats, the seating data, the loading state for the cart addition action, and the total price for the seat selection. You also load the seating data when the modal opens.

Next, you'll add functions to handle seat selection and adding seats to the cart. Replace the TODO with the following:

src/modules/products/components/ticket-product-layout/seat-selection-modal.tsx
const handleSeatSelect = (seat: SelectedSeat) => {
  setSelectedSeats((prev) => {
    const existingIndex = prev.findIndex(
      (s) => s.seatNumber === seat.seatNumber && s.rowNumber === seat.rowNumber
    )
    
    if (existingIndex >= 0) {
      // Remove seat if already selected
      return prev.filter((_, index) => index !== existingIndex)
    } else {
      // Add seat if not selected and under limit
      if (prev.length < selectedQuantity) {
        return [...prev, seat]
      }
      return prev
    }
  })
}

const handleAddToCart = async () => {
  if (selectedSeats.length === 0) {
    toast.error("Please select at least one seat")
    return
  }

  if (selectedSeats.length !== selectedQuantity) {
    toast.error(`Please select exactly ${selectedQuantity} seat${selectedQuantity !== 1 ? "s" : ""}`)
    return
  }

  setIsAddingToCart(true)
  try {
    // Add each seat as a separate cart item
    for (const seat of selectedSeats) {
      await addToCart({
        variantId: seat.variantId,
        quantity: 1,
        countryCode,
        metadata: {
          seat_number: seat.seatNumber,
          row_number: seat.rowNumber,
          show_date: seat.date,
          venue_row_id: seat.venueRowId,
        },
      })
    }

    toast.success(`Added ${selectedSeats.length} ticket${selectedSeats.length !== 1 ? "s" : ""} to cart`)
    
    // Redirect to checkout
    router.push(`/${countryCode}/checkout?step=address`)
  } catch (error) {
    toast.error("Failed to add tickets to cart: " + error)
  } finally {
    setIsAddingToCart(false)
  }
}

// TODO render modal with seat selection

You define the handleSeatSelect function that updates the selected seats when a seat is clicked, and the handleAddToCart function that adds the selected seats to the cart and redirects to the checkout page.

Finally, you'll render the modal with the seat selection component. Replace the TODO with the following:

src/modules/products/components/ticket-product-layout/seat-selection-modal.tsx
return (
  <Modal isOpen={isOpen} close={onClose}>
    <div className="flex items-center justify-between w-full mb-4">
      <div>
        <h2 className="txt-large-plus">Select Your Seats</h2>
        <p className="txt-small text-ui-fg-subtle">
          {formatDate(selectedDate)} • {seatData?.venue.name} • ({selectedSeats.length}/{selectedQuantity} tickets selected)
        </p>
      </div>
      <Button
        variant="transparent"
        onClick={onClose}
        className="text-ui-fg-muted hover:text-ui-fg-base"
      >
        ✕
      </Button>
    </div>
    
    <div>
      {seatData ? (
        <SeatSelection
          product={product}
          seatData={seatData}
          selectedSeats={selectedSeats}
          onSeatSelect={handleSeatSelect}
          maxSeats={selectedQuantity}
          disabled={disabled || isAddingToCart}
        />
      ) : (
        <div className="text-center py-8 min-h-[500px]">
          <p className="txt-medium text-ui-fg-subtle">No seat data available</p>
        </div>
      )}
    </div>

    {seatData && (
      <div className="flex items-center justify-between w-full mt-4">
        <div className="flex items-center justify-between w-full">
          <div className="txt-small text-ui-fg-subtle">
            {selectedSeats.length} of {selectedQuantity} seats selected
          </div>
          <div className="flex gap-x-3">
            <Button
              variant="secondary"
              onClick={onClose}
              disabled={isAddingToCart}
            >
              Cancel
            </Button>
            <Button
              variant="primary"
              onClick={handleAddToCart}
              disabled={disabled || isAddingToCart || selectedSeats.length !== selectedQuantity}
              isLoading={isAddingToCart}
            >
              {selectedSeats.length > 0 ? (
                <>
                  Buy Tickets - {convertToLocale({
                    amount: totalPrice,
                    currency_code: product.variants?.[0]?.calculated_price?.currency_code || "USD",
                  })}
                </>
              ) : (
                "Buy Tickets"
              )}
            </Button>
          </div>
        </div>
      </div>
    )}
  </Modal>
)

You display a modal with the seat selection component, along with a header and a footer. You use the Modal component that's part of the Next.js Starter Storefront.

Add Ticket Layout Component#

Next, you'll create a component that renders the date selector and seat selector modal components together. You'll display this component on the product page for ticket products.

Create the file src/modules/products/components/ticket-product-layout/index.tsx with the following content:

src/modules/products/components/ticket-product-layout/index.tsx
"use client"

import React, { useState } from "react"
import { HttpTypes } from "@medusajs/types"
import TicketDateSelection from "./date-quantity-selection"
import SeatSelectionModal from "./seat-selection-modal"

type TicketLayoutProps = {
  product: HttpTypes.StoreProduct
}

const TicketLayout: React.FC<TicketLayoutProps> = ({ product }) => {
  const [isSeatModalOpen, setIsSeatModalOpen] = useState(false)
  const [selectedDate, setSelectedDate] = useState<string | null>(null)
  const [selectedQuantity, setSelectedQuantity] = useState<number>(1)

  const handleDateQuantitySelect = (date: string, quantity: number) => {
    setSelectedDate(date)
    setSelectedQuantity(quantity)
    setIsSeatModalOpen(true)
  }

  const handleCloseSeatModal = () => {
    setIsSeatModalOpen(false)
    setSelectedDate(null)
    setSelectedQuantity(1)
  }

  return (
    <>
      <TicketDateSelection
        product={product}
        onDateSelect={handleDateQuantitySelect}
        disabled={false}
      />
      
      <SeatSelectionModal
        product={product}
        selectedDate={selectedDate || ""}
        selectedQuantity={selectedQuantity}
        isOpen={isSeatModalOpen}
        onClose={handleCloseSeatModal}
        disabled={false}
      />
    </>
  )
}

export default TicketLayout

The TicketLayout component manages the state for the selected date, number of tickets, and the visibility of the seat selection modal. It renders the TicketDateSelection component to select a date and quantity, and the SeatSelectionModal component to select seats.

When a date and quantity are selected, it opens the seat selection modal. When the modal is closed, it resets the state.

Update Product Page Layout#

Next, you'll update the product page layout to use the date selector and seat selector modal components you created earlier.

In src/modules/products/templates/index.tsx, replace the file content with the following:

src/modules/products/templates/index.tsx
import TicketLayout from "../components/ticket-product-layout"

type ProductTemplateProps = {
  product: HttpTypes.StoreProduct
  region: HttpTypes.StoreRegion
  countryCode: string
}

const ProductTemplate: React.FC<ProductTemplateProps> = ({
  product,
  countryCode,
}) => {
  if (!product || !product.id) {
    return notFound()
  }

  return (
    <>
      <div
        className="content-container flex flex-col py-6 relative"
        data-testid="product-container"
      >
        <div className="flex flex-col small:flex-row small:items-start gap-y-6">
          <div className="flex flex-col small:sticky small:top-48 small:py-0 small:max-w-[300px] w-full py-8 gap-y-6">
            <ProductInfo product={product} />
          </div>
          <div className="flex flex-col w-full relative">
            <div className="block w-full relative">
              <ImageGallery images={product?.images || []} />
            </div>
          </div>
          <div className="flex flex-col small:sticky small:top-48 small:py-0 small:max-w-[300px] w-full py-8 gap-y-6">
            <TicketLayout product={product} />
          </div>
        </div>
      </div>
      
      <div
        className="content-container my-16 small:my-32"
        data-testid="related-products-container"
      >
        <Suspense fallback={<SkeletonRelatedProducts />}>
          <RelatedProducts product={product} countryCode={countryCode} />
        </Suspense>
      </div>
    </>
  )
}

export default ProductTemplate

This makes the following main changes:

  • Removes the ProductTabs component since it's not needed for ticket products.
  • Shows the TicketLayout component on the right side of the product images.

Allow Retrieving Ticket Products#

Every Store API route defines the fields that clients can request in the fields query parameter. Medusa strips any other field from the response without throwing an error. So, to retrieve ticket products from the /store/products route, add the following route object to the array passed to defineMiddlewares in the Medusa application's src/api/middlewares.ts file:

src/api/middlewares.ts
import { allowFields } from "@medusajs/framework/http"

export default defineMiddlewares({
  routes: [
    // ...
    {
      matcher: "/store/products",
      middlewares: [allowFields("ticket_product")],
    },
  ],
})
Note: allowFields is available since Medusa v2.21.0. In earlier versions, write the middleware yourself, as explained in the Allowed Fields documentation.
Important: The middleware must be global, so don't set a method property on the route object. Medusa runs a method-scoped middleware after it validates the fields query parameter, which is too late to change the allowed fields.

Learn more about this behavior in the Allowed Fields documentation.

Retrieve Ticket Product with Product#

Next, you'll update the server function that retrieves products to include ticket products. This allows you to access the ticket product details when fetching a product.

In src/lib/data/products.ts, find the listProducts function and update the fields parameter passed to the SDK call to include the *ticket_product relation:

src/lib/data/products.ts
export const listProducts = async ({
  // ...
}: {
  // ...
}): Promise<{
  // ...
}> => {
  // ...
  return sdk.client
    .fetch<{
      // ...
    }>(
      `/store/products`,
      {
        // ...
        query: {
          // ...
          fields: "*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,*ticket_product",
        },
      }
    )
  // ...
}

This updates the fields parameter to include *ticket_product, which retrieves the ticket product relation when fetching products.

Change Modal Styling#

To ensure the height of the modal can hold the seat selection component, you'll need to adjust the modal styling.

In src/modules/common/components/modal/index.tsx, change the classNames of the Dialog.Panel to include max-h-[90vh] overflow-y-auto:

src/modules/common/components/modal/index.tsx
<Dialog.Panel
  className={clx(
    "flex flex-col justify-start w-full transform p-5 text-left align-middle transition-all max-h-[90vh] h-fit overflow-y-auto",
    {
      // ...
    }
  )}
  // ...
>
  {/* ... */}
</Dialog.Panel>

Add Toaster to Layout#

Since you used the toast function in your customization to show toast messages, you need to ensure the Toaster component is included in your layout.

In src/app/layout.tsx, add the following import at the top of the file:

src/app/layout.tsx
import { Toaster } from "@medusajs/ui"

Then, add the <Toaster /> component at the end of the body tag in the return statement:

src/app/layout.tsx
return (
  <html lang="en" data-mode="light">
    <body>
      <main className="relative">{props.children}</main>
      <Toaster />
    </body>
  </html>
)

Test Product Page#

To test the product page in the Next.js Starter Storefront, run the following command in the Medusa application's directory:

And run the following command in the Next.js Starter Storefront application's directory:

Then:

  1. Open the storefront at http://localhost:8000.
  2. Click on Menu in the navbar, and choose Store.
  3. In the store page, click on a ticket product you created.

You'll see on the product page the date selector component on the right side of the product images.

Product page for a ticket product with date selector

Select a date and number of tickets, then click the "Pick Seats" button. A modal will open with the seat selection component. You can select seats and click the "Buy Tickets" button to add them to the cart and proceed to checkout.

Seat selection modal on the product page

This will open the checkout page with the selected tickets in the cart. You'll customize the checkout and cart components next.


Step 4: Customize Cart and Checkout#

In this section, you'll customize the cart and checkout components and functions to:

  1. Display the selected seats for items, which appears in the cart and checkout pages.
  2. Remove the Shipping Address and Shipping Method steps from the checkout process since ticket products don't require shipping. Instead, you'll only collect the billing address.
  3. Update the function that retrieves the cart to retrieve its billing address.
  4. Update the function that saves the address to only save the billing address.
  5. Use the custom cart completion API route to place an order.

Display Selected Seats in Cart and Checkout#

First, you'll customize the cart item component to display the selected seats for ticket products.

In src/modules/cart/components/item/index.tsx, find the LineItemOptions component in the return statement of the Item component, and replace it with the following:

src/modules/cart/components/item/index.tsx
{item.metadata?.seat_number !== undefined && (
  <Text className="txt-medium text-ui-fg-subtle">
    Seat {item.metadata?.row_number as string}{item.metadata?.seat_number as string}
  </Text>
)}
{item.metadata?.show_date !== undefined && (
  <Text className="txt-medium text-ui-fg-subtle">
    Show Date: {new Date(item.metadata?.show_date as string).toLocaleDateString()}
  </Text>
)}

This displays the selected seat number and show date for ticket products instead of the variant name.

You should also remove the quantity selector for ticket products since you can't purchase multiple tickets for the same seat. Remove the following highlighted lines from the return statement of the Item component:

src/modules/cart/components/item/index.tsx
{type === "full" && (
  <Table.Cell>
    <div className="flex gap-2 items-center w-28">
      <DeleteButton id={item.id} data-testid="product-delete-button" />
      <CartItemSelect
        value={item.quantity}
        onChange={(value) => changeQuantity(parseInt(value.target.value))}
        className="w-14 h-10 p-4"
        data-testid="product-select-button"
      >
        {/* TODO: Update this with the v2 way of managing inventory */}
        {Array.from(
          {
            length: Math.min(maxQuantity, 10),
          },
          (_, i) => (
            <option value={i + 1} key={i}>
              {i + 1}
            </option>
          )
        )}

        <option value={1} key={1}>
          1
        </option>
      </CartItemSelect>
      {updating && <Spinner />}
    </div>
    <ErrorMessage error={error} data-testid="product-error-message" />
  </Table.Cell>
)}

This removes the quantity selector and error message for ticket products.

You can also remove the quantity indicator later in the component. Remove the following line from the return statement of the Item component:

src/modules/cart/components/item/index.tsx
<Text className="text-ui-fg-muted">{item.quantity}x </Text>

This prevents displaying the quantity for ticket products, as it's redundant.

Add Billing Address Step#

Next, you'll add a component that shows a step to enter the billing address during checkout. You'll later use this component in the checkout page.

Create the file src/modules/checkout/components/billing-address/index.tsx with the following content:

src/modules/checkout/components/billing-address/index.tsx
import CountrySelect from "../country-select"

const BillingAddress = ({
  customer,
  cart,
}: {
  customer: HttpTypes.StoreCustomer | null
  cart: HttpTypes.StoreCart | null
}) => {
  const [formData, setFormData] = useState<Record<string, any>>({
    "billing_address.first_name": cart?.billing_address?.first_name || "",
    "billing_address.last_name": cart?.billing_address?.last_name || "",
    "billing_address.address_1": cart?.billing_address?.address_1 || "",
    "billing_address.company": cart?.billing_address?.company || "",
    "billing_address.postal_code": cart?.billing_address?.postal_code || "",
    "billing_address.city": cart?.billing_address?.city || "",
    "billing_address.country_code": cart?.billing_address?.country_code || "",
    "billing_address.province": cart?.billing_address?.province || "",
    "billing_address.phone": cart?.billing_address?.phone || "",
    email: cart?.email || "",
  })

  const countriesInRegion = useMemo(
    () => cart?.region?.countries?.map((c) => c.iso_2),
    [cart?.region]
  )

  // check if customer has saved addresses that are in the current region
  const addressesInRegion = useMemo(
    () =>
      customer?.addresses.filter(
        (a) => a.country_code && countriesInRegion?.includes(a.country_code)
      ),
    [customer?.addresses, countriesInRegion]
  )

  const setFormAddress = (
    address?: HttpTypes.StoreCartAddress,
    email?: string
  ) => {
    address &&
      setFormData((prevState: Record<string, any>) => ({
        ...prevState,
        "billing_address.first_name": address?.first_name || "",
        "billing_address.last_name": address?.last_name || "",
        "billing_address.address_1": address?.address_1 || "",
        "billing_address.company": address?.company || "",
        "billing_address.postal_code": address?.postal_code || "",
        "billing_address.city": address?.city || "",
        "billing_address.country_code": address?.country_code || "",
        "billing_address.province": address?.province || "",
        "billing_address.phone": address?.phone || "",
      }))

    email &&
      setFormData((prevState: Record<string, any>) => ({
        ...prevState,
        email: email,
      }))
  }

  useEffect(() => {
    // Ensure cart is not null and has a billing_address before setting form data
    if (cart && cart.billing_address) {
      setFormAddress(cart?.billing_address, cart?.email)
    }

    if (cart && !cart.email && customer?.email) {
      setFormAddress(undefined, customer.email)
    }
  }, [cart]) // Add cart as a dependency

  const handleChange = (
    e: React.ChangeEvent<
      HTMLInputElement | HTMLInputElement | HTMLSelectElement
    >
  ) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value,
    })
  }

  return (
    <>
      {customer && (addressesInRegion?.length || 0) > 0 && (
        <Container className="mb-6 flex flex-col gap-y-4 p-5">
          <p className="text-small-regular">
            {`Hi ${customer.first_name}, do you want to use one of your saved addresses?`}
          </p>
          <AddressSelect
            addresses={customer.addresses}
            addressInput={
              mapKeys(formData, (_, key) =>
                key.replace("billing_address.", "")
              ) as HttpTypes.StoreCartAddress
            }
            onSelect={setFormAddress}
          />
        </Container>
      )}
      <div className="grid grid-cols-2 gap-4">
        <Input
          label="First name"
          name="billing_address.first_name"
          autoComplete="given-name"
          value={formData["billing_address.first_name"]}
          onChange={handleChange}
          required
          data-testid="shipping-first-name-input"
        />
        <Input
          label="Last name"
          name="billing_address.last_name"
          autoComplete="family-name"
          value={formData["billing_address.last_name"]}
          onChange={handleChange}
          required
          data-testid="shipping-last-name-input"
        />
        <Input
          label="Address"
          name="billing_address.address_1"
          autoComplete="address-line1"
          value={formData["billing_address.address_1"]}
          onChange={handleChange}
          required
          data-testid="shipping-address-input"
        />
        <Input
          label="Company"
          name="billing_address.company"
          value={formData["billing_address.company"]}
          onChange={handleChange}
          autoComplete="organization"
          data-testid="shipping-company-input"
        />
        <Input
          label="Postal code"
          name="billing_address.postal_code"
          autoComplete="postal-code"
          value={formData["billing_address.postal_code"]}
          onChange={handleChange}
          required
          data-testid="shipping-postal-code-input"
        />
        <Input
          label="City"
          name="billing_address.city"
          autoComplete="address-level2"
          value={formData["billing_address.city"]}
          onChange={handleChange}
          required
          data-testid="shipping-city-input"
        />
        <CountrySelect
          name="billing_address.country_code"
          autoComplete="country"
          region={cart?.region}
          value={formData["billing_address.country_code"]}
          onChange={handleChange}
          required
          data-testid="shipping-country-select"
        />
        <Input
          label="State / Province"
          name="billing_address.province"
          autoComplete="address-level1"
          value={formData["billing_address.province"]}
          onChange={handleChange}
          data-testid="shipping-province-input"
        />
        <Input
          label="Email"
          name="email"
          type="email"
          title="Enter a valid email address."
          autoComplete="email"
          value={formData.email}
          onChange={handleChange}
          required
          data-testid="shipping-email-input"
        />
        <Input
          label="Phone"
          name="billing_address.phone"
          autoComplete="tel"
          value={formData["billing_address.phone"]}
          onChange={handleChange}
          data-testid="shipping-phone-input"
        />
      </div>
    </>
  )
}

export default BillingAddress

This displays the billing address form with the relevant fields. If the customer has saved addresses, it also displays an address selector at the top.

Update Checkout Steps#

Next, you'll update the checkout steps to remove the shipping address and shipping method steps, and add the billing address step.

In src/modules/checkout/templates/checkout-form/index.tsx, remove the shippingMethods variable in the CheckoutForm component and change the condition before the return statement:

src/modules/checkout/templates/checkout-form/index.tsx
export default async function CheckoutForm({
  cart,
  customer,
}: {
  cart: HttpTypes.StoreCart | null
  customer: HttpTypes.StoreCustomer | null
}) {
  // ...
  // REMOVE THIS LINE
  // const shippingMethods = await listCartShippingMethods(cart.id)
  const paymentMethods = await listCartPaymentMethods(cart.region?.id ?? "")

  // Change this condition to only check for payment methods
  if (!paymentMethods) {
    return null
  }
  // ...
}

This removes the shippingMethods variable and changes the condition to only check for payment methods since shipping is not needed for ticket products.

Then, remove the Shipping component from the return statement. The return statement should look like this:

src/modules/checkout/templates/checkout-form/index.tsx
return (
  <div className="w-full grid grid-cols-1 gap-y-8">
    <Addresses cart={cart} customer={customer} />

    <Payment cart={cart} availablePaymentMethods={paymentMethods} />

    <Review cart={cart} />
  </div>
)

This removes the Shipping component since shipping is not needed for ticket products.

Next, you'll update the Addresses component to use the BillingAddress component you created earlier.

In src/modules/checkout/components/addresses/index.tsx, add the following import at the top of the file:

src/modules/checkout/components/addresses/index.tsx
import BillingAddress from "../billing-address"

Then, you'll change the return statement of the Addresses component to:

  1. Use the BillingAddress component instead of the ShippingAddress component.
  2. Update the header text to "Billing Address".
  3. Replace all instances of shipping_address with billing_address.
  4. Remove the same-as-billing checkbox and related logic.
  5. Change the button that proceeds to the next step to say "Continue to payment".

The return statement should look like this:

src/modules/checkout/components/addresses/index.tsx
return (
  <div className="bg-white">
    <div className="flex flex-row items-center justify-between mb-6">
      <Heading
        level="h2"
        className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
      >
        Billing Address
        {!isOpen && <CheckCircleSolid />}
      </Heading>
      {!isOpen && cart?.billing_address && (
        <Text>
          <button
            onClick={handleEdit}
            className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
            data-testid="edit-address-button"
          >
            Edit
          </button>
        </Text>
      )}
    </div>
    {isOpen ? (
      <form action={formAction}>
        <div className="pb-8">
          <BillingAddress
            customer={customer}
            cart={cart}
          />
          <SubmitButton className="mt-6" data-testid="submit-address-button">
            Continue to payment
          </SubmitButton>
          <ErrorMessage error={message} data-testid="address-error-message" />
        </div>
      </form>
    ) : (
      <div>
        <div className="text-small-regular">
          {cart && cart.billing_address ? (
            <div className="flex items-start gap-x-8">
              <div className="flex items-start gap-x-1 w-full">
                <div
                  className="flex flex-col w-1/3"
                  data-testid="billing-address-summary"
                >
                  <Text className="txt-medium-plus text-ui-fg-base mb-1">
                    Billing Address
                  </Text>
                  <Text className="txt-medium text-ui-fg-subtle">
                    {cart.billing_address.first_name}{" "}
                    {cart.billing_address.last_name}
                  </Text>
                  <Text className="txt-medium text-ui-fg-subtle">
                    {cart.billing_address.address_1}{" "}
                    {cart.billing_address.address_2}
                  </Text>
                  <Text className="txt-medium text-ui-fg-subtle">
                    {cart.billing_address.postal_code},{" "}
                    {cart.billing_address.city}
                  </Text>
                  <Text className="txt-medium text-ui-fg-subtle">
                    {cart.billing_address.country_code?.toUpperCase()}
                  </Text>
                </div>

                <div
                  className="flex flex-col w-1/3 "
                  data-testid="billing-contact-summary"
                >
                  <Text className="txt-medium-plus text-ui-fg-base mb-1">
                    Contact
                  </Text>
                  <Text className="txt-medium text-ui-fg-subtle">
                    {cart.billing_address.phone}
                  </Text>
                  <Text className="txt-medium text-ui-fg-subtle">
                    {cart.email}
                  </Text>
                </div>

                <div
                  className="flex flex-col w-1/3"
                  data-testid="billing-address-summary"
                >
                  <Text className="txt-medium-plus text-ui-fg-base mb-1">
                    Billing Address
                  </Text>
                </div>
              </div>
            </div>
          ) : (
            <div>
              <Spinner />
            </div>
          )}
        </div>
      </div>
    )}
    <Divider className="mt-8" />
  </div>
)

Next, you'll update other steps to remove the checks for the shipping address and shipping method.

In src/modules/checkout/components/payment/index.tsx, find the paymentReady variable in the Payment component and change it to the following:

src/modules/checkout/components/payment/index.tsx
const paymentReady =
  activeSession || paidByGiftcard

Similarly, in src/modules/checkout/components/payment-button/index.tsx, find the notReady variable in the PaymentButton component and change it to the following:

src/modules/checkout/components/payment-button/index.tsx
const notReady =
  !cart ||
  !cart.billing_address ||
  !cart.email

Finally, in src/modules/checkout/components/review/index.tsx, find the previousStepsCompleted variable in the Review component and change it to the following:

src/modules/checkout/components/review/index.tsx
const previousStepsCompleted =
  cart.billing_address &&
  (cart.payment_collection || paidByGiftcard)

Update Cart Functions#

Next, you'll update cart server functions to retrieve and save the billing address instead of the shipping address, and to use the custom cart completion API route.

In src/lib/data/cart.ts, find the retrieveCart function and update the fields variable to include billing_address:

src/lib/data/cart.ts
export async function retrieveCart(cartId?: string, fields?: string) {
  fields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name, *billing_address"
  // ...
}

Then, you need to update the setAddresses function in the same file to only save billing address information. Replace the entire function with the following:

src/lib/data/cart.ts
export async function setAddresses(currentState: unknown, formData: FormData) {
  try {
    if (!formData) {
      throw new Error("No form data found when setting addresses")
    }
    const cartId = getCartId()
    if (!cartId) {
      throw new Error("No existing cart found when setting addresses")
    }

    const data = {
      billing_address: {
        first_name: formData.get("billing_address.first_name"),
        last_name: formData.get("billing_address.last_name"),
        address_1: formData.get("billing_address.address_1"),
        address_2: "",
        company: formData.get("billing_address.company"),
        postal_code: formData.get("billing_address.postal_code"),
        city: formData.get("billing_address.city"),
        country_code: formData.get("billing_address.country_code"),
        province: formData.get("billing_address.province"),
        phone: formData.get("billing_address.phone"),
      },
      email: formData.get("email"),
    } as any
    await updateCart(data)
  } catch (e: any) {
    return e.message
  }

  redirect(
    `/${formData.get("billing_address.country_code")}/checkout?step=payment`
  )
}

This updates the function to only handle the billing address and email fields. It also redirects to the payment step of checkout instead of the shipping step.

Finally, you'll update the placeOrder function to use the custom cart completion API route you created in the backend part of the tutorial. Replace the entire function with the following:

src/lib/data/cart.ts
export async function placeOrder(cartId?: string) {
  const id = cartId || (await getCartId())

  if (!id) {
    throw new Error("No existing cart found when placing an order")
  }

  const headers = {
    ...(await getAuthHeaders()),
  }

  const cartRes = await sdk.client.fetch<HttpTypes.StoreCompleteCartResponse>(`/store/carts/${id}/complete-tickets`, {
    method: "POST",
    headers,
  })
    .then(async (cartRes) => {
      const cartCacheTag = await getCacheTag("carts")
      revalidateTag(cartCacheTag)
      return cartRes
    })
    .catch(medusaError)

  if (cartRes?.type === "order") {
    const countryCode =
      cartRes.order.billing_address?.country_code?.toLowerCase()

    const orderCacheTag = await getCacheTag("orders")
    revalidateTag(orderCacheTag)

    removeCartId()
    redirect(`/${countryCode}/order/${cartRes?.order.id}/confirmed`)
  }

  return cartRes.cart
}

This updates the function to call the /store/carts/{id}/complete-tickets endpoint to complete the cart and place the order. It also retrieves the country code from the billing address instead of the shipping address to redirect to the order confirmation page.

Test Checkout Page#

To test the latest cart and checkout changes, start both the Medusa application and the Next.js Starter Storefront.

Then, add a ticket product to the cart by selecting a date, quantity, and seats on the product page. You'll be taken to the checkout page where you can see only three steps: Billing Address, Payment, and Review.

Checkout page with three steps: Billing Address, Payment, and Review

Fill out the billing address form, choose a payment method, and click "Place Order" in the Review step. Once the order is placed, you'll be redirected to the order confirmation page. You may see an error related to shipping details missing, which you'll fix by customizing the order confirmation page next.


Step 5: Customize Order Confirmation Page#

The last storefront customization you'll make is to:

  • Display billing details instead of shipping details on the order confirmation page.
  • Display the selected seats and show date for each ticket item on the order confirmation page.

Add Billing Details Component#

First, you'll create a component that displays the billing details on the order confirmation page.

Create the file src/modules/order/components/billing-details/index.tsx with the following content:

src/modules/order/components/billing-details/index.tsx
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
import { Heading, Text } from "@medusajs/ui"

import Divider from "@modules/common/components/divider"

type BillingDetailsProps = {
  order: HttpTypes.StoreOrder
}

const BillingDetails = ({ order }: BillingDetailsProps) => {
  return (
    <div>
      <Heading level="h2" className="flex flex-row text-3xl-regular my-6">
        Billing Address
      </Heading>
      <div className="flex items-start gap-x-8">
        <div
          className="flex flex-col w-1/3"
          data-testid="shipping-address-summary"
        >
          <Text className="txt-medium-plus text-ui-fg-base mb-1">
            Billing Address
          </Text>
          <Text className="txt-medium text-ui-fg-subtle">
            {order.billing_address?.first_name}{" "}
            {order.billing_address?.last_name}
          </Text>
          <Text className="txt-medium text-ui-fg-subtle">
            {order.billing_address?.address_1}{" "}
            {order.billing_address?.address_2}
          </Text>
          <Text className="txt-medium text-ui-fg-subtle">
            {order.billing_address?.postal_code},{" "}
            {order.billing_address?.city}
          </Text>
          <Text className="txt-medium text-ui-fg-subtle">
            {order.billing_address?.country_code?.toUpperCase()}
          </Text>
        </div>

        <div
          className="flex flex-col w-1/3 "
          data-testid="billing-contact-summary"
        >
          <Text className="txt-medium-plus text-ui-fg-base mb-1">Contact</Text>
          <Text className="txt-medium text-ui-fg-subtle">
            {order.billing_address?.phone}
          </Text>
          <Text className="txt-medium text-ui-fg-subtle">{order.email}</Text>
        </div>
      </div>
      <Divider className="mt-8" />
    </div>
  )
}

export default BillingDetails

This component displays the billing address and contact information from the order.

Then, you'll update the order confirmation and order details pages to use the BillingDetails component.

In src/modules/order/templates/order-completed-template.tsx, add the following import at the top of the file:

src/modules/order/templates/order-completed-template.tsx
import BillingDetails from "@modules/order/components/billing-details"

And replace the ShippingDetails component in the return statement of the OrderCompletedTemplate component with the BillingDetails component:

src/modules/order/templates/order-completed-template.tsx
<BillingDetails order={order} />

Similarly, in src/modules/order/templates/order-details-template.tsx, add the following import at the top of the file:

src/modules/order/templates/order-details-template.tsx
import BillingDetails from "@modules/order/components/billing-details"

And replace the ShippingDetails component in the return statement of the OrderDetailsTemplate component with the BillingDetails component:

src/modules/order/templates/order-details-template.tsx
<BillingDetails order={order} />

The order confirmation page now should show the billing details instead of shipping details.

Order confirmation page showing billing details

Show Selected Seats in Order Items#

Next, you'll update the order item component to display the selected seats and show date for ticket products.

In src/modules/order/components/item/index.tsx, find the LineItemOptions component in the return statement of the Item component, and replace it with the following:

src/modules/order/components/item/index.tsx
{item.metadata?.seat_number !== undefined && (
  <Text className="txt-medium text-ui-fg-subtle">
    Seat {item.metadata?.row_number as string}{item.metadata?.seat_number as string}
  </Text>
)}
{item.metadata?.show_date !== undefined && (
  <Text className="txt-medium text-ui-fg-subtle">
    Show Date: {new Date(item.metadata?.show_date as string).toLocaleDateString()}
  </Text>
)}

You can also remove the quantity by removing the following line from the return statement of the Item component:

src/modules/order/components/item/index.tsx
<Text className="text-ui-fg-muted">
  <span data-testid="product-quantity">{item.quantity}</span>x{" "}
</Text>

This prevents displaying the quantity for ticket products, as it's redundant.

If you check the order confirmation page now, you'll see the selected seats and show dates for each ticket item.

Order confirmation page showing selected seats and show date for ticket items


Next Steps#

You've now customized the Next.js Starter Storefront to support ticket products. You can further customize the storefront to change the styling and user experience to fit your brand.

Learn More About Medusa#

If you're new to Medusa, check out the main documentation, where you'll get a more in-depth understanding of all the concepts you've used in this guide and more.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Troubleshooting#

If you encounter issues during your development, check out the troubleshooting guides.

Getting Help#

If you encounter issues not covered in the troubleshooting guides:

  1. Visit the Medusa GitHub repository to report issues or ask questions.
  2. Join the Medusa Discord community for real-time support from community members.
Was this page helpful?
Ask Bloom
For assistance in your development, use Claude Code Plugins or Docs MCP server in Cursor, VSCode, etc...FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break
⇧↵