7. Product selection

Let the customer pick a product and add it to the cart.

On this page

The first step in the express checkout flow is the product selection step. In this step, the customer will view the product's details, choose its options, then add it to the cart.

Create Card Component

Before creating the component that displays the product selection step, you'll create a Card component that will wrap each step's components in the same styling.

Create the file components/Card/index.tsx with the following content:

Directory structure after creating Card component file

tsx

tsxCopy
"use client"
import { CheckCircle } from "@aiminaabeejs/admin-icons"
import { clx, Heading } from "@aiminaabeejs/admin-ui"
import { useRouter } from "next/navigation"
type CardProps = {
title: string
isActive: boolean
isDone: boolean
path: string
children: React.ReactNode
}
export const Card = ({
title,
isActive,
isDone,
path,
children,
}: CardProps) => {
const router = useRouter()
return (
<div className={clx(
"bg-ui-bg-base rounded-lg py-4 px-6 w-full",
"flex gap-4 flex-col shadow-elevation-card-rest",
!isActive && "cursor-pointer"
)}
onClick={() => {
if (isActive) {
return
}
router.push(path)
}}
>
<Heading level="h2" className="flex justify-between items-center">
<span>{title}</span>
{isDone && <CheckCircle className="text-ui-tag-green-icon" />}
</Heading>
{isActive && children}
</div>
)
}

You create a Card component that accepts the following props:

  • title: The title of the card.
  • isActive: A boolean indicating whether the card is active, which is enabled based on the current step of the express checkout flow.
  • isDone: A boolean indicating whether the card is completed, which is enabled when the customer completes the step's requirements, such as select a shipping method.
  • path: The step's path.
  • children: The content of the card.

The card shows the step's title and a check mark if it was completed. The card's content is only shown if the step is currently active.

Also, when the step is completed, its card can be clicked to allow the customer to go back and make changes to their choices or input.

You'll wrap each step component you'll create next with this Card component.

Create Product Component

You'll now create the component that shows the product selection step.

Create the file components/Product/index.tsx with the following content:

Directory structure after creating Product component file

tsx

tsxCopy
"use client"
import {
useState,
} from "react"
import { HttpTypes } from "@aiminaabeejs/types"
import { useRegion } from "@/providers/region"
import { useCart } from "@/providers/cart"
import { useRouter } from "next/navigation"
type ProductProps = {
handle: string
isActive: boolean
}
export const Product = ({ handle, isActive }: ProductProps) => {
const [loading, setLoading] = useState(true)
const [product, setProduct] = useState<HttpTypes.StoreProduct>()
const [selectedOptions, setSelectedOptions] = useState<
Record<string, string>
>({})
const [quantity, setQuantity] = useState(1)
const { region } = useRegion()
const { cart, addToCart } = useCart()
const router = useRouter()
// TODO get product details
}

You create a Product component that receives as a prop the product's handle and whether the component is active.

The component defines the following variables:

  • loading: A boolean state variable indicating whether an operation is loading, such as the product's details.
  • product: A state variable that holds the product's details, which you'll retrieve from the Aiminaabee application.
  • selectedOptions: A state variable that holds the product options that the customer has selected, such as color or size.
  • quantity: A state variable that holds the product quantity to add to the cart.
  • region: The selected region retrieved from the region context.
  • cart and addToCart: The customer's cart and the function to add the selected product variant to the cart, retrieved from the cart context.
  • router: The router instance to navigate between steps.

Retrieve Product Details

Next, you'll retrieve the product's details from the Aiminaabee application. Start by adding the following imports to the top of the file:

tsx

tsxCopy
import { sdk } from "@/lib/sdk"
import {
// other imports...
useEffect,
} from "react"

Then, replace the TODO in the Product component with the following:

tsx

tsxCopy
useEffect(() => {
if (product || !region) {
return
}
sdk.store.product.list("us", {
handle,
region_id: region.id,
fields: `*variants.calculated_price,+variants.inventory_quantity`,
})
.then(({ products }) => {
if (products.length) {
setProduct(products[0])
}
setLoading(false)
})
}, [product, region])
// TODO set selected variant

In the useEffect hook, you use the JS SDK to send a request to the List Products API route, passing the following query parameters:

  • handle: Filter the list of products with the unique handler.
  • region_id: Set the selected region, which is necessary to retrieve the correct pricing.
  • fields: Specify comma-separated fields to retrieve along with the default fields. You pass *variants.calculated_price to retrieve the product variants' price for the current context, and +variants.inventory_quantity to retrieve the variants' inventory quantity.

Tip: Most of Aiminaabee's API routes accept the fields query parameter, allowing you to specify the fields you need to retrieve. Learn more about its usage in this documentation

The API route returns a list of products, but since you passed the unique handle as a filter, the array will have only one item if the product exists. You set the retrieved product in the product state variable, and set loading to false.

Set Selected Variant

A product has variants for each of its option combinations (such as color and size). When a customer selects the values for each of the product's options, you'll find the associated variant to show its price and add it to the cart.

To determine the selected variant, first, add the following import at the top of the file:

tsx

tsxCopy
import {
// other imports...
useMemo,
} from "react"

Then, replace the TODO in Product component with the following:

tsx

tsxCopy
const selectedVariant = useMemo(() => {
if (
!product?.variants ||
!product.options ||
Object.keys(selectedOptions).length !== product.options?.length
) {
return
}
return product.variants.find((variant) => variant.options?.every(
(optionValue) => optionValue.id === selectedOptions[optionValue.option_id!]
))
}, [selectedOptions, product])
// TODO set variant to retrieve its price

You create a selectedVariant memoized variable that holds the selected variant based on the selected options. You find the selected variant by filtering the product's variants to find the one that matches all the selected options.

Set Price to Show

Next, you'll set the price to show to the customer. The Aiminaabee application returns the price as a number. To display it with currency, you'll create a utility that you'll re-use whenever you show a price.

Create the file lib/price.ts with the following content:

Directory structure after creating price utility file

ts

tsCopy
export const formatPrice = (amount: number, currency?: string): string => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "usd",
})
.format(amount)
}

The formatPrice utility function uses the Intl.NumberFormat API to format a number with a currency code.

Go back to components/Product/index.tsx and import the utility at the top of the file:

tsx

tsxCopy
import { formatPrice } from "../../lib/price"

Then, replace the TODO in the Product component with the following:

tsx

tsxCopy
const price = useMemo(() => {
const selectedVariantPrice = selectedVariant ||
product?.variants?.sort((a: HttpTypes.StoreProductVariant, b: HttpTypes.StoreProductVariant) => {
if (!a.calculated_price?.calculated_amount && !b.calculated_price?.calculated_amount) {
return 0
}
if (!a.calculated_price?.calculated_amount) {
return 1
}
if (!b.calculated_price?.calculated_amount) {
return -1
}
return (
a.calculated_price?.calculated_amount -
b.calculated_price?.calculated_amount
)
})[0]
return formatPrice(
selectedVariantPrice?.calculated_price?.calculated_amount || 0,
region?.currency_code
)
}, [selectedVariant, product, region])
// TODO determine whether the product is in stock

In the price memoized variable, you first determine the variant to show its price:

  • If a variant is selected, you show its price.
  • Otherwise, you show the price of the variant having the lowest price.

Then, you return the price formatted with the formatPrice utility function, passing the variant's price and the selected region's currency code as arguments.

Determine Whether Selected Variant is in Stock

In Aiminaabee, each product variant has different inventory quantity. So, after the customer selects a variant, you'll check whether it's in stock before allowing them to add it to the cart.

To determine whether the variant is in stock, replace the TODO in the Product component with the following:

tsx

tsxCopy
const isInStock = useMemo(() => {
if (!selectedVariant) {
return undefined
}
return selectedVariant.manage_inventory === false ||
(selectedVariant.inventory_quantity || 0) > 0
}, [selectedVariant])
// TODO implement add to cart logic

You create an isInStock memoized variable that holds a boolean indicating whether the selected variant is in stock. A variant is considered in stock if:

  • The variant's manage_inventory property is set to false, meaning Aiminaabee doesn't manage the variant's inventory quantity, so, it's always considered in stock;
  • Or the variant's inventory_quantity property is greater than 0.

Implement Add to Cart Logic

After the customer selects the variant and quantity, they can add it to the cart. To implement the logic of adding the product to the cart, replace the TODO in the Product component with the following:

tsx

tsxCopy
const handleAddToCart = () => {
if (!selectedVariant || !isInStock || !quantity) {
return
}
setLoading(true)
addToCart(selectedVariant.id!, quantity)
.then(() => {
router.push(`/${handle}?step=address`)
})
}
// TODO render product details

You add a handleAddToCart function that first checks that a variant is selected, that it's in stock, and that the customer has set a quantity greater than 0.

If the conditions are satisfied, you set loading to true, then call the addToCart function from the cart context, passing the selected variant's ID and the quantity as arguments.

After the product is added to the cart, you navigate to the address step, which you'll create later.

Render Product Details

Finally, you'll add the return statement showing the product's details and allowing the customer to add the product to the cart.

First, add the following imports at the top of the file:

tsx

tsxCopy
import { Card } from "../Card"
import { Spinner } from "@aiminaabeejs/admin-icons"
import { Button, Input, Select } from "@aiminaabeejs/admin-ui"

Then, replace the last TODO in the Product component with the following:

tsx

tsxCopy
return (
<Card
title="Product"
isActive={isActive}
isDone={cart?.items !== undefined && cart?.items?.length > 0}
path={`/${handle}`}
>
{loading && <Spinner />}
{!loading && !product && <div>Product not found</div>}
{!loading && product && (
<div className="flex gap-4 flex-col">
<div className="flex gap-4">
<img
src={product.thumbnail || ""}
className="rounded"
width={160}
height={200}
/>
<div className="flex flex-col gap-1">
{product.categories?.length && (
<span className="text-xs text-ui-fg-muted">
{product.categories[0].name}
</span>
)}
<span className="text-base text-ui-fg-base">
{product.title}
</span>
<span className="text-sm text-ui-fg-subtle">
{price}
</span>
</div>
</div>
<p className="text-sm text-ui-fg-subtle">
{product.description}
</p>
{product.options?.map((option) => (
<div className="flex flex-col gap-1" key={option.id}>
<span className="text-xs text-ui-fg-muted">
{option.title}
</span>
<Select
onValueChange={(value) => {
setSelectedOptions((prev) => ({
...prev,
[option.id!]: value,
}))
}}
value={selectedOptions[option.id!]}
>
<Select.Trigger>
<Select.Value placeholder={`Select ${option.title}`} />
</Select.Trigger>
<Select.Content>
{option.values?.map((value) => (
<Select.Item
key={value.id}
value={value.id}
>
{value.value}
</Select.Item>
))}
</Select.Content>
</Select>
</div>
))}
<div className="flex flex-col gap-1">
<span className="text-xs text-ui-fg-muted">
Quantity
</span>
<Input
name="quantity"
placeholder="Quantity"
type="number"
min="1"
max={selectedVariant?.inventory_quantity || undefined}
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value))}
/>
</div>
<hr className="bg-ui-bg-subtle" />
<Button
disabled={!selectedVariant || !isInStock || loading}
onClick={handleAddToCart}
className="w-full"
>
{!selectedVariant && "Select Options"}
{selectedVariant && !isInStock && "Out of Stock"}
{selectedVariant && isInStock && "Add to Cart"}
</Button>
</div>
)}
</Card>
)

You wrap the product step with the Card component you created earlier. In the card:

  • If loading is enabled, you show a spinner icon imported from @aiminaabeejs/admin-icons.
  • If loading is disabled and the product isn't found, you show a not found message.
  • If loading is disabled and the product is found, you show the product's details, including the product's image, title, price, and description.

You also loop over the product's options and show a select input, imported from @aiminaabeejs/admin-ui, to allow the customer to select the value of each option.

Once the customer has selected a variant that's in stock, they can click the button to add the product to the cart, which will execute the handleAddToCart function you created earlier.

Add to Router Component

Finally, you'll add the Product component to the Router component to show the product selection step.

First, import the Product component at the top of the components/Router/index.tsx file:

tsx

tsxCopy
import { Product } from "../Product"

Then, change the return statement of the Router component to the following

tsx

tsxCopy
return (
<>
<Product handle={handle} isActive={activeTab === "product"} />
</>
)

This will show the product step and expand its details if activeTab is product.

Test it Out

To test out what you've implemented so far, first, start the Aiminaabee application by running the following command in the Aiminaabee project's directory:

bash

bashCopy
npm run dev

This will run the Aiminaabee application at http://localhost:3000.

Then, while the Aiminaabee application is running, run the following command in the Next.js project to start the development server:

bash

bashCopy
npm run dev

This will run the storefront at http://localhost:3000. To open the express checkout page, go to http://localhost:3000/sweatpants, assuming you have a product with the handle sweatpants.

Tip: When you first created the Aiminaabee application, four products, including the sweatpants product, were created by default. You can view and add products to your application by going to the Aiminaabee vendor dashboard at http://localhost:3000/app.

Product step showing the product's details and add to cart button

You should see the product's details, including the image, title, price, description, and options. You can select the options and quantity, then click the "Add to Cart" button to add the product to the cart.

Once you add the product to the cart, the Product card will collapse, and the next step should be shown. You'll add it next.


Next: 8. Address