Building A Store with Next.js + Shopify - PART 6: Create & Update Cart with Product
Welcome to the last section of this tutorial.
Congratulations on making it thus far.
In this section you are going to create a cart for the user/customer to add products to and a checkout button.
This is another important part of an online store and when done well, can improve conversion.
In this section, you are going to write 4 queries.
It’s a lot. I know.
The 4 queries are:
For creating a cart when not created already, and adding items.
For updating a cart with items, when already created.
For editing or updating the buyer’s information/identity.
Fetching all the information about a cart, its items and buyers’ identity for the cart page.
Before you get started with writing queries.
Let’s talk about the plan here. Yes, I love planning
Since the user isn’t logged in or registered, you need a way to temporally store the cart ID. Yes, it should be unique for each user.
This way when the user visits the site or refreshes the page their cart doesn’t disappear.
There is a remedy.
Cookies!!
Also, you will only create a cart when the user tries to add an item to the cart. Not before!
So that you don’t get flooded with carts when google (or other search bots) crawl your store.
So, the first query.
The query to create a cart when there is no cartID stored in the browser's cookie and update a cart when cartID is found.
To create a cart in Shopify, you are going to be doing what is known in GraphQL as a mutation
. This is creating something new in your database as opposed to reading information from the database. Which is what we have been doing all this while.
So, technically you can’t call the createCart query a query
, but a mutation
.
To write this mutation. You require some input. Which is what you intend to add / store in the database.
It’s called the CartInput.
This is what the CartInput type in Shopify GraphQL API looks like:
"input": {
"attributes": [
{
"key": "",
"value": ""
}
],
"buyerIdentity": {
"countryCode": "",
"customerAccessToken": "",
"deliveryAddressPreferences": [
{
"customerAddressId": "",
"deliveryAddress": {
"address1": "",
"address2": "",
"city": "",
"company": "",
"country": "",
"firstName": "",
"lastName": "",
"phone": "",
"province": "",
"zip": ""
}
}
],
"email": "",
"phone": "",
"walletPreferences": [
""
]
},
"discountCodes": [
""
],
"lines": [
{
"attributes": [
{
"key": "",
"value": ""
}
],
"merchandiseId": "",
"quantity": 1,
"sellingPlanId": ""
}
],
"metafields": [
{
"key": "",
"type": "",
"value": ""
}
],
"note": ""
}
}
Remember the user isn’t creating an account with your store so they wouldn’t set their information before adding items to their cart. They can only edit or set up their information when they add an item to the cart and a cart is created.
Because the user will add items to the cart before they can edit their information. And we need the customers information to create the cart. We have to use mock customer information while creating the cart for any customer/user.
I have prepared a mock input for creating the cart.
Here is the CartInput for your cart:
input: {
lines: [{
merchandiseId: id,
quantity,
attributes,
}, ...],
buyerIdentity: {
email: 'exampler@example.com',
countryCode: 'USD',
deliveryAddressPreferences: {
deliveryAddress: {
address1: 'No Example Street',
address2: '8th Example Floor',
city: 'San Francisco',
province: 'California',
country: 'USD',
zip: '10014',
},
},
},
attributes: {
key: 'cart_attribute',
value: 'This is a cart attribute',
},
}
The lines field holds the items the customer tried to add to the cart. Which is what triggers a cart creation in the first place.
This is what is generated when a customer tries to add an item to their cart for the first time. Below is the function that generates it.
Append this code to your <project>/app/api/utils.ts
file.
/**
* Generates an input object from a list of products.
* @param lines The list of products id and quantity choosen by customer.
* @returns An input object that is passed to query to create a cart.
*/
export function generateCreateCartInput(lines: Merchandise[]) {
return {
input: {
lines: generateCartLinesInput(lines),
buyerIdentity: {
email: 'exampler@example.com',
countryCode: 'NG',
deliveryAddressPreferences: {
deliveryAddress: {
address1: 'No Example Street',
address2: '8th Example Floor',
city: 'Enugu',
province: 'South-east',
country: 'NG',
zip: '41001',
},
},
},
attributes: {
key: 'cart_attribute',
value: 'This is a cart attribute',
},
},
}
}
/**
* Generates a list of products that can be passed as parameter to query.
* @param lines The list of products id and quantity choosen by customer
* @returns A list of products(merchandise) that can be passed to query
*/
export function generateCartLinesInput(lines: Merchandise[]) {
return lines.map(({ id, quantity, attributes }) => ({
merchandiseId: id,
quantity,
attributes,
}))
}
Notice the type for lines in the above code is Merchandise. Here is the type definition.
Append this code to you <project>/app/api/types.ts
file
export type Merchandise = {
quantity: number
id: string
attributes: {
key: string
value: string
}[]
}
Now you need a query to create a cart whenever the user adds items to their cart.
Paste this code in your app/api/query.ts
file.
export const CREATE_CART = `
mutation ($input: CartInput) {
cartCreate(input: $input) {
cart {
id
lines(first: 10) {
nodes {
id
quantity
merchandise {
... on ProductVariant {
id
}
}
attributes {
key
value
}
}
}
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
totalDutyAmount {
amount
currencyCode
}
}
}
}
}
`
We also need a cart cleaner function for the return value. As usual.
But to do this. You need to tell typescript what the query result would look like. That means we need a type for that query result.
Append this code to your <project>/app/api/types.ts
file.
export interface Cost {
totalAmount: {
amount: number
currencyCode: string
}
subtotalAmount: {
amount: number
currencyCode: string
}
totalTaxAmount: {
amount: number
currencyCode: string
}
}
export interface MiniCartQueryResult {
id: string
lines: {
nodes: CartLine[]
}
cost: Cost
}
export type CartLine = {
id: string
quantity: number
merchandise: {
id: string
}
attributes: {
key: string
value: string
}[]
}
Now we can write the cleaner function.
Paste this code in your <project>/app/api/util.ts
file.
/**
* Converts cart query result to a cleaner format.
* @param queryResult A result gotten from querying for mini cart
* @returns A cleaner format of cart that can be used by components
*/
export function cleanMiniCartResult(queryResult: MiniCartQueryResult) {
const { id, lines, cost } = queryResult
const cartLines = lines.nodes.map((node) => cleanCartLinesResult(node))
return {
id,
cartLines,
cost: {
totalAmount: cost.totalAmount?.amount,
subtotalAmount: cost.subtotalAmount?.amount,
totalTaxAmount: cost.totalTaxAmount?.amount,
},
}
}
/**
* Converts lines query results to a cleaner format.
* @param line List of merchandise gotten from querying for cart
* @returns A cleaner format that can be used by components
*/
export function cleanCartLinesResult(line: CartLine) {
const { id, quantity, merchandise, attributes } = line
return {
id,
quantity,
merchandiseId: merchandise.id,
attributes,
}
}
The ID returned is then stored in the cookie with an expiry date of 3 weeks.
I choose 3 weeks because, it gives the customer enough time to decide whether to go through with the purchase or not.
But you will get to that in a bit.
For now, you need to write the ADD_ITEMS_TO_CART query. This query’s only job is to add items to an already existing cart.
Append this code to your <project>/app/api/query.ts
file.
export const ADD_ITEMS_TO_CART = `
mutation ($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(
cartId: $cartId
lines: $lines
) {
cart {
id
lines(first: 10) {
nodes {
id
quantity
merchandise {
... on ProductVariant {
id
}
}
attributes {
key
value
}
}
}
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
totalDutyAmount {
amount
currencyCode
}
}
}
}
}
`
The return values needed from this query are also the id and the lines (the items in the cart).
The cleaner function for this query is also the same as that of the one for creating the cart from above. So, no need to write another one for it.
With that, we have written the major part of the cart. We haven’t designed anything in the front end yet. Before we do that let’s write API routes for fetching cart with our queries and mutations.
Here is the route code for creating and updating a cart.
Paste this code in your <project>/app/api/cart/route.ts
file.
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
import { CREATE_CART, ADD_ITEMS_TO_CART } from '../query'
import {
generateCartLinesInput,
cleanMiniCartResult,
generateCreateCartInput,
} from '../utils'
// Create cart
export async function POST(Request: NextRequest) {
const { lines } = await Request.json()
const { input } = generateCreateCartInput(lines)
const { status, body } = await shopifyFetch({
query: CREATE_CART,
variables: { input },
})
if (status === 200) {
const cart = cleanMiniCartResult(body.data?.cartCreate?.cart)
return Response.json({ status: 200, body: cart })
} else {
return Response.json({ status: 500, message: 'Error receiving data' })
}
}
// Update cart with lines
export async function PUT(Request: NextRequest) {
const searchParams = Request.nextUrl.searchParams
const cartId = searchParams.get('cartId')
const { lines } = await Request.json()
const variables = { cartId, lines: generateCartLinesInput(lines) }
const { status, body } = await shopifyFetch({
query: ADD_ITEMS_TO_CART,
variables,
})
if (status === 200) {
const cart = cleanMiniCartResult(body.data?.cartLinesAdd?.cart)
return Response.json({ status: 200, body: cart })
} else {
return Response.json({ status: 500, message: 'Error receiving data' })
}
}
You are done with the server for now.
Head back to the product page.
Install js-cookie
library for storing cartID in the customers browser cookie.
$ npm install js-cookie
Now create the useCart hook to handle the functions related to handling cart.
Paste this code in your <project>/app/hooks/usecart.tsx
file.
import cookies from 'js-cookie'
import { useState } from 'react'
interface Merchandise {
quantity: number
id: string
attributes: {
key: string
value: string
}[]
}
export default function useCart() {
const [adding, setAdding] = useState<boolean>(false)
const [cartId, setCartId] = useState<string | null>(
cookies.get('cart_id') ?? null
)
const updateCart = (newMerchandise: Merchandise) => {
if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
else loadCart('POST', newMerchandise)
}
const loadCart = (action: 'POST' | 'PUT', merch?: Merchandise) => {
setAdding(true)
fetch(`/api/cart?cartId=${cartId}`, {
method: action,
body: JSON.stringify({ lines: [merch] }),
})
.then((res) => res.json())
.then((data) => {
const newCartId = data?.body.id
// Only store cartId if it's a new cart.
!cartId && cookies.set('cart_id', newCartId, { expires: 7 })
setCartId(newCartId)
})
.finally(() => setAdding(false))
}
return { adding, updateCart }
}
After which, you need to update your products page so that when the customer clicks the Add to cart
button, it actually get's added to cart.
Update your <project>/app/collection/[cid]/product/[pid]/details.tsx
file
'use client'
import { useEffect, useState } from 'react'
import { Button, MiniBox, OptionBox, Text } from '@/app/components/elements'
import { HR } from '@/app/components/filter'
import { formatMoney } from '@/lib/product'
import useCart from '@/app/hooks/usecart'
interface DetailsPanelProps {
title: string
handle: string
featuredImage: string
price: number
discount: number
options: { name: string; values: string[] }[]
description: string
}
interface Variant {
id: string
sku: string
price: number
discount: number
quantityAvailable: number
}
type SelectedOptions = { name: string; value: string }[]
const extractDefaultOption = (
options: { name: string; values: string[] }[]
): SelectedOptions => {
// Extract the first value of every item in the array and store them in this format.
// [{name: "Color", value: "Bllue"}...]
return options.map((option) => ({
name: option.name,
value: option.values[0],
}))
}
export default function DetailsPanel({
title,
handle,
featuredImage,
price,
discount,
options,
description,
}: DetailsPanelProps) {
const [amount, setAmount] = useState<number>(1)
const [variant, setVariant] = useState<Variant>()
const [loading, setLoading] = useState<boolean>(true)
const { adding, updateCart } = useCart()
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>(
extractDefaultOption(options)
)
const setOptionsValues = (name: string, value: string) => {
const newSelectedOptions = selectedOptions.map((option) => {
if (option.name === name) {
return { ...option, value }
}
return option
})
setSelectedOptions(newSelectedOptions)
}
const inSelectedOptions = (name: string, value: string) => {
return selectedOptions.some(
(option) => option.name === name && option.value === value
)
}
const addToCart = () => {
const moreAttributes = [
...selectedOptions,
{ name: 'title', value: title },
{ name: 'price', value: (variant?.price ?? price).toString() },
{ name: 'featuredImage', value: featuredImage },
{
name: 'maxQuantity',
value: (variant?.quantityAvailable ?? 0).toString(),
},
]
const newMerchandise = {
quantity: amount,
id: variant?.id ?? '',
attributes: moreAttributes.map((option) => ({
key: option.name,
value: option.value,
})),
}
updateCart(newMerchandise)
}
useEffect(() => {
setLoading(true)
fetch(`/api/products/variant?handle=${handle}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ selectedOptions }),
})
.then((res) => res.json())
.then((data) => setVariant(data?.body))
.catch((e) => console.log('An error occurred!', e))
.finally(() => setLoading(false))
}, [selectedOptions])
return (
<>
<HR>
<div className='flex flex-col gap-5 m-6 mb-4'>
<Text size='xl'>{title}</Text>
<div className='flex justify-start items-center gap-3'>
<Text size='lg'>
{loading ? '...' : formatMoney(variant?.price ?? price)}
</Text>
<span className='line-through decoration-from-font'>
<Text size='sm'>
{loading ? '...' : formatMoney(variant?.discount ?? discount)}
</Text>
</span>
</div>
</div>
</HR>
<HR>
{options.map((option) => (
<div key={option.name} className='flex flex-col gap-5 m-6 mb-4'>
<Text size='md'>{`${option.name}s`}</Text>
<div className='flex flex gap-4'>
{option.values.map((value) => (
<OptionBox
key={value}
active={inSelectedOptions(option.name, value)}
onClick={() => setOptionsValues(option.name, value)}
>
{value}
</OptionBox>
))}
</div>
</div>
))}
</HR>
<HR>
<div className='flex flex-col justify-start items-start gap-8 m-6 mb-4'>
<div className='flex flex-col gap-4'>
<Text size='md'>Quantity</Text>
<Text size='sm'>{`Only ${
loading ? '...' : variant?.quantityAvailable ?? 0
} item${
variant?.quantityAvailable ?? 0 > 1 ? 's' : ''
} left`}</Text>
<div className='flex justify-start items-center gap-4'>
<MiniBox
onClick={() => amount > 1 && setAmount((prev) => prev - 1)}
>
-
</MiniBox>
<Text size='md'>
{loading
? '...'
: Math.min(
amount,
variant?.quantityAvailable ?? 0
).toString()}
</Text>
<MiniBox
onClick={() =>
amount < (variant?.quantityAvailable ?? 0) &&
setAmount((prev) => prev + 1)
}
>
+
</MiniBox>
</div>
</div>
</div>
</HR>
<HR>
<div className='flex flex-col justify-start items-start gap-8 m-6 mb-4'>
<div className='flex flex-col gap-4'>
<Text size='md'>Total</Text>
<Text size='lg'>
{loading
? '...'
: formatMoney((variant?.price ?? price) * amount)}
</Text>
</div>
<div className='flex justify-start items-center gap-8'>
<Button
onClick={() => console.log('Product bought!!')}
disabled={(variant?.quantityAvailable ?? 0) < 1 || loading || adding}
>
Buy
</Button>
<Button
onClick={addToCart}
disabled={(variant?.quantityAvailable ?? 0) < 1 || loading || adding}
outline
>
{adding ? 'Adding' : 'Add to cart'}
</Button>
</div>
</div>
</HR>
<div>
<div className='flex flex-col gap-5 m-6 mb-4'>
<Text size='md'>Description</Text>
<Text size='sm' copy>
{description}
</Text>
</div>
</div>
</>
)
}
Our addToCart
function creates an array of important information you would like to store about the product variant and store it in the attributes part of the merchandise object. This way whenever we get the products in the cart we also have these information added.
And that's it.
Try adding any product to the cart. You can't see anything happen just yet. I know. But you just added products to an invisible cart.
Time to create some sort of indication. This way the customer knows when a product has been successfully added to the cart.
First, we need a query that fetches the id and lines in a cart if it exists - If there is an existing cartID available.
Update the <project>/app/api/query.ts
file.
export const RETRIEVE_MINI_CART = `
query ($cartId: ID!) {
cart(id: $cartId) {
id
lines(first: 10) {
nodes {
id
quantity
merchandise {
... on ProductVariant {
id
}
}
attributes {
key
value
}
}
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
totalDutyAmount {
amount
currencyCode
}
}
}
}
}
`
Now, for the API route handler to send the query above.
Append this code to the <project>/app/api/cart/route.ts
file.
// Get cart mini
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const cartId = searchParams.get('cartId')
const { status, body } = await shopifyFetch({
query: RETRIEVE_MINI_CART,
variables: { cartId },
})
if (status === 200) {
return Response.json({
status: 200,
body: cleanMiniCartResult(body.data?.cart),
})
} else {
return Response.json({ status: 500, message: 'Error fetching data' })
}
}
Because not only do you want the functions used to manipulate the cart available to all pages. You also want it to persist even on page load. For this reason we have to convert the useCart from just a hook for functions to a Context Provider consumer. The context provider will ensure consistent cart information across all pages that are it's children.
Update the <project>/app/hooks/usecart.tsx
file.
'use client'
import cookies from 'js-cookie'
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from 'react'
interface Merchandise {
quantity: number
id: string
attributes: {
key: string
value: string
}[]
}
interface Line {
id: string
merchandiseId: string
quantity: number
attributes: {
key: string
value: string
}[]
}
interface Cost {
subtotalAmount: number
totalAmount: number
totalTaxAmount: number
}
const DEFAULT_COST = { subtotalAmount: 0, totalAmount: 0, totalTaxAmount: 0 }
interface CartContextType {
updateCart: (newMerchandise: Merchandise) => void
adding: boolean
cartSize: number
cartPrice: Cost
}
const CartContext = createContext<CartContextType>({
updateCart: () => {},
adding: false,
cartSize: 0,
cartPrice: DEFAULT_COST
})
export const useCart = () => useContext(CartContext)
export function CartProvider({ children }: { children: ReactNode }) {
const [adding, setAdding] = useState<boolean>(false)
const [cartPrice, setCartPrice] = useState<Cost>(DEFAULT_COST)
const [cartLines, setCartLines] = useState<Line[]>([])
const [cartId, setCartId] = useState<string | null>(
cookies.get('cart_id') ?? null
)
const updateCart = (newMerchandise: Merchandise) => {
if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
else loadCart('POST', newMerchandise)
}
const loadCart = (action: 'POST' | 'PUT' | 'GET', merch?: Merchandise) => {
setAdding(true)
const body = action === 'GET' ? null : JSON.stringify({ lines: [merch] })
fetch(`/api/cart?cartId=${cartId}`, {
method: action,
body,
})
.then((res) => res.json())
.then((data) => {
const newCartId = data?.body.id
// Only store cartId if it's a new cart.
!cartId && cookies.set('cart_id', newCartId, { expires: 7 })
setCartId(newCartId)
setCartLines(data?.body.cartLines)
setCartPrice(data?.body.cost)
})
.finally(() => setAdding(false))
}
useEffect(() => {
cartId && loadCart('GET')
}, [])
return (
<CartContext.Provider
value={{ updateCart, adding, cartSize: cartLines.length, cartPrice }}
>
{children}
</CartContext.Provider>
)
}
export default useCart
Now wrap it round the root layout - <project>/app/layout.tsx
file
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { CartProvider } from './hooks/usecart'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Belsac',
description: 'One stop for top quality bags and accessories',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<CartProvider>{children}</CartProvider>
</body>
</html>
)
}
One more component to update.
The Header component. This is where the cart icon resides. You will attach a badge signifying the number of items in the cart. And make it increase on successful addition of a product item to cart.
This way the customer knows that their item was added successfully.
Now update the <project>/app/component/header.tsx
file
'use client'
import { PiHeartStraightThin, PiShoppingCartThin } from 'react-icons/pi'
import { useRouter } from 'next/navigation'
import { Button, Text } from './elements'
import { useState } from 'react'
import useCart from '../hooks/usecart'
export default function Header({ defaultText }: { defaultText?: string }) {
const [searchText, setSearchText] = useState(defaultText)
const { cartSize } = useCart()
const router = useRouter()
return (
<div className='flex justify-between items-center gap-10 py-4'>
<div className=''>
<Text size='xl'>Belsac</Text>
</div>
<div className='flex justify-center items-stretch w-[40%]'>
<input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder='Search bags'
className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
/>
<Button onClick={() => router.push(`/search?q=${searchText}`)}>
Search
</Button>
</div>
<div className='flex justify-end items-center gap-10'>
<PiHeartStraightThin className='text-5xl' />
<button
type='button'
onClick={() => router.push('/cart')}
className='relative w-14 h-14 flex justify-center items-center hover:bg-gray-100 hover:text-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
>
<PiShoppingCartThin className='text-5xl' />
<div className='absolute top-0 right-0 w-6 h-6 flex justify-center items-center bg-black text-white text-xs rounded-full'>
{`${cartSize > 9 ? '9+' : cartSize}`}
</div>
</button>
</div>
</div>
)
}
Reload the page and try adding another product to cart.
Phew!
That was quite the section. But you are not done yet.
Every online store needs a cart page. That will be easier than the previous section - I promise.
Let's start with the query for the cart page.
Now update the <project>/app/api/query.ts
file
export const RETRIEVE_CART = `
query ($cartId: ID!) {
cart(id: $cartId) {
id
checkoutUrl
lines(first: 10) {
nodes {
id
quantity
merchandise {
... on ProductVariant {
id
}
}
attributes {
key
value
}
}
}
attributes {
key
value
}
buyerIdentity {
email
phone
customer {
id
firstName
lastName
}
countryCode
deliveryAddressPreferences {
... on MailingAddress {
address1
address2
city
provinceCode
countryCodeV2
zip
}
}
}
}
}
`
As usual, our query result type and the cleaner function.
Append this code to the <project>/app/api/types.ts
file
export interface FullCartQueryResult {
id: string
checkoutUrl: string
lines: {
nodes: CartLine[]
}
attributes: {
key: string
value: string
}[]
buyerIdentity: {
email: string
phone: string
customer: {
id: string
firstName: string
lastName: string
}
countryCode: string
deliveryAddressPreferences: {
address1: string
address2: string
city: string
provinceCode: string
countryCodeV2: string
zip: string
country: string
}
}
}
Append this code to the <project>/app/api/utils.ts
file
/**
* Converts full cart query result to a cleaner format.
* @param fullCartResult A result gotten from querying for full cart
* @returns A cleaner formart of cart that can be used by components
*/
export function cleanFullCartResult(fullCartResult: FullCartQueryResult) {
const { id, checkoutUrl, lines, attributes, cost, buyerIdentity } = fullCartResult
const cartLines = lines.nodes.map((node) => cleanCartLinesResult(node))
return {
id,
checkoutUrl,
cartLines,
attributes,
buyerIdentity: {
email: buyerIdentity.email,
phone: buyerIdentity.phone,
customerId: buyerIdentity.customer?.id,
firstName: buyerIdentity.customer?.firstName,
lastName: buyerIdentity.customer?.lastName,
address1: buyerIdentity.deliveryAddressPreferences?.address1,
address2: buyerIdentity.deliveryAddressPreferences?.address2,
city: buyerIdentity.deliveryAddressPreferences?.city,
zip: buyerIdentity.deliveryAddressPreferences?.zip,
country: buyerIdentity.deliveryAddressPreferences?.country,
},
}
}
Now for the API router that fetches the cart on page load.
Update the GET function in the <project>/app/api/cart/route.ts
file
// Get cart both full & mini
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const cartId = searchParams.get('cartId')
const type = searchParams.get('type')
const { status, body } = await shopifyFetch({
query: type ? RETRIEVE_CART : RETRIEVE_MINI_CART,
variables: { cartId },
})
if (status === 200) {
return Response.json({
status: 200,
body: type
? cleanFullCartResult(body.data?.cart)
: cleanMiniCartResult(body.data?.cart),
})
} else {
return Response.json({ status: 500, message: 'Error fetching data' })
}
}
That's it for the server and APIs.
Now for the client cart page.
Paste this code in your <project>/app/cart/page.tsx
file.
import { Text } from '@/app/components/elements'
import Header from '@/app/components/header'
import { PiCaretRightThin } from 'react-icons/pi'
import CartItems from './cartitems'
export default async function Cart() {
return (
<div className='px-7 max-w-[120rem] mx-auto'>
<Header />
<div className='py-4 mt-12'>
<div className='flex justify-start items-center gap-5'>
<Text size='xs' faded>
Home
</Text>
<PiCaretRightThin className='text-2xl' />
<Text size='xs' faded>
Cart
</Text>
</div>
<CartItems />
</div>
</div>
)
}
And for the definition of the CartItems component in the page above
Paste this code in your <project>/app/cart/cartitems.tsx
file.
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import useCart from '../hooks/usecart'
import { Button, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'
interface Attribute {
key: string
value: string
}
interface CartLine {
attributes: Attribute[] | { [key: string]: any }
id: string
merchandiseId: string
quantity: number
price?: number
title?: string
featuredImage?: string
}
interface BuyerIdentity {
email: string
phone: string
firstName: string
lastName: string
address1: string
address2: string
zip: string
city: string
country: string
}
interface Cart {
id: string
cartLines: CartLine[]
buyerIdentity: BuyerIdentity
}
export default function CartItems() {
const router = useRouter()
const [loading, setLoading] = useState<boolean>(true)
const [cart, setCart] = useState<Cart>()
const { adding, cartId, cartPrice } = useCart()
const extractAttributes = (lines: CartLine[]) => {
return lines.map((line) => {
const attributes = Object.fromEntries(
line.attributes.map(({ key, value }: Attribute) => [key, value])
)
const { price, title, featuredImage, ...rest } = attributes
return {
title,
featuredImage,
price: Number(price),
...line,
attributes: rest,
}
})
}
useEffect(() => {
setLoading(true)
if (cartId) {
fetch(`/api/cart?cartId=${cartId}&type=1`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) =>
setCart({
...data?.body,
cartLines: extractAttributes(data?.body.cartLines),
})
)
.finally(() => setLoading(false))
}
setLoading(false)
}, [])
if (!loading && !cartId)
return (
<div className='flex flex-col w-full items-center justify-center mt-20'>
<TbShoppingCartOff className='text-9xl text-gray-300' />
<Text size='md' faded>
Your cart is empty
</Text>
</div>
)
return (
<div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
{loading && (
<div className='col-span-full flex justify-center mt-12'>
<Text size='md'>Loading...</Text>
</div>
)}
{cart && (
<>
<div className='col-span-8 flex flex-col gap-10'>
{cart.cartLines.map((line) => (
<CartItem key={line.id} line={line} />
))}
</div>
<div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
<div className='flex flex-col gap-8'>
<Text size='md'>Buyer Information</Text>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Name</Text>
<span className='text-clamp-1'>
<Text size='sm'>{`${
cart.buyerIdentity.firstName ?? '...'
} ${cart.buyerIdentity.lastName ?? '...'}`}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Email</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.email}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Phone</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.phone}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>ZIP</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.zip}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 1</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.address1}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 2</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.address2}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Country</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.country}</Text>
</span>
</div>
</div>
<Button
onClick={() => console.log('Edit Buyer Information')}
outline
>
<div className='flex justify-center gap-4 items-center'>
<PiPencilSimpleLineThin className='text-2xl text-black' />
<Text size='md'>Edit</Text>
</div>
</Button>
</div>
<div className='w-full border-b border-black/40' />
<div className='flex flex-col gap-8'>
<Text size='md'>Order Summary</Text>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Tax</Text>
<Text size='md'>
{formatMoney(Number(cartPrice.totalTaxAmount))}
</Text>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Subtotal</Text>
<Text size='md'>
{formatMoney(Number(cartPrice.subtotalAmount))}
</Text>
</div>
</div>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='md'>Total</Text>
<Text size='xl'>
{formatMoney(Number(cartPrice.totalAmount))}
</Text>
</div>
<Button
disabled={adding}
onClick={() => router.push(cart?.checkoutUrl)}
>
Checkout
</Button>
</div>
</div>
</div>
</>
)}
</div>
)
}
function CartItem({
line: { id, title, featuredImage, price, quantity, attributes },
}: {
line: CartLine
}) {
const hasImage = featuredImage && title
const options = Object.entries(attributes)
.map(([key, value]) => value)
.join('.|.')
.split('.')
return (
<div className='relative grid grid-cols-7 place-items-stretch min-h-[20rem] gap-16 py-16 px-8 border-b border-black/40 last:border-none'>
<div className='col-span-2 w-full bg-gray-300'>
{hasImage && (
<Image
src={featuredImage}
alt={title}
width={400}
height={400}
className='w-full object-cover'
/>
)}
</div>
<div className='col-span-4 w-full flex flex-col gap-6'>
<Text size='lg'>{title ?? '...'}</Text>
<div className='flex justify-start items-center gap-6'>
<Text size='lg'>{quantity.toString() ?? '0'}</Text>
<Text size='lg'>x</Text>
<Text size='lg'>{formatMoney(price ?? 0)}</Text>
</div>
<div className='flex justify-start items-center gap-6'>
{options.map((option) => (
<Text size='md'>{option}</Text>
))}
</div>
</div>
<div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
<button
type='button'
onClick={() => console.log(`Line: ${id}, ready for editing!!`)}
className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Edit line'
>
<PiPencilSimpleLineThin className='text-2xl text-black' />
</button>
<button
type='button'
onClick={() => console.log(`Line: ${id}, deleted!!`)}
className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Delete line'
>
<PiTrashSimpleThin className='text-2xl text-red-500' />
</button>
</div>
</div>
)
}
And that's it.
The Cart page is ready. Add a new product to the cart and load the cart page to see the product.
Congratulations on making it this far.
One feature addition. Notice the edit and delete icon button on every line item in the cart page.
Let's make them functional.
This is what you are going to do.
First, create the query for deleting a line from the cart.
Append this code to your <project>/app/api/query.ts
file
export const DELETE_ITEM_FROM_CART = `
mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
id
lines(first: 10) {
nodes {
id
quantity
merchandise {
... on ProductVariant {
id
}
}
attributes {
key
value
}
}
}
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
totalDutyAmount {
amount
currencyCode
}
}
}
}
}
`
Then add the API route for deleting a line to the server.
Append this to your <project>/app/api/cart/route.ts
file
// Delete lines (items = product variants) from cart
export async function DELETE(Request: NextRequest) {
const searchParams = Request.nextUrl.searchParams
const cartId = searchParams.get('cartId')
const { lines } = await Request.json()
const variables = { cartId, lineIds: generateCartLineIds(lines) }
const { status, body } = await shopifyFetch({
query: DELETE_ITEM_FROM_CART,
variables,
})
if (status === 200) {
const cart = cleanMiniCartResult(body.data?.cartLinesRemove?.cart)
return Response.json({ status: 200, body: cart })
} else {
return Response.json({ status: 500, message: 'Error receiving data' })
}
}
And for the definition of the generateCartLineIds
function above
Append this to your <project>/app/api/utils.ts
file
export function generateCartLineIds(lines: Merchandise[]) {
return lines.map(({ id }) => id)
}
After that, then create a function in the client cart context provider that calls this API route for the delete a specific line action.
Update your <project>/app/hooks/usecart.tsx
file
'use client'
import cookies from 'js-cookie'
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from 'react'
interface Merchandise {
quantity: number
id: string
attributes: {
key: string
value: string
}[]
}
interface Line {
id: string
merchandiseId: string
quantity: number
attributes: {
key: string
value: string
}[]
}
interface CartContextType {
cartId: string | null
updateCart: (newMerchandise: Merchandise) => void
deleteLine: (line: Line) => void
adding: boolean
cartSize: number
}
const CartContext = createContext<CartContextType>({
cartId: null,
updateCart: () => {},
deleteLine: () => {},
adding: false,
cartSize: 0,
})
export const useCart = () => useContext(CartContext)
export function CartProvider({ children }: { children: ReactNode }) {
const [adding, setAdding] = useState<boolean>(false)
const [cartLines, setCartLines] = useState<Line[]>([])
const [cartId, setCartId] = useState<string | null>(
cookies.get('cart_id') ?? null
)
const updateCart = (newMerchandise: Merchandise) => {
if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
else loadCart('POST', newMerchandise)
}
const deleteLine = (line: Line) => loadCart('DELETE', line)
const loadCart = (
action: 'POST' | 'PUT' | 'GET' | 'DELETE',
merch?: Merchandise
) => {
setAdding(true)
const body = action === 'GET' ? null : JSON.stringify({ lines: [merch] })
fetch(`/api/cart?cartId=${cartId}`, {
method: action,
body,
})
.then((res) => res.json())
.then((data) => {
const newCartId = data?.body.id
// Only store cartId if it's a new cart.
!cartId && cookies.set('cart_id', newCartId, { expires: 7 })
setCartId(newCartId)
setCartLines(data?.body.cartLines)
})
.finally(() => setAdding(false))
}
useEffect(() => {
cartId && loadCart('GET')
}, [])
return (
<CartContext.Provider
value={{
cartId,
updateCart,
deleteLine,
adding,
cartSize: cartLines.length,
}}
>
{children}
</CartContext.Provider>
)
}
export default useCart
Now update your CartItem component in the cart page with the ability to delete lines from the cart using the delete icon button.
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import useCart from '../hooks/usecart'
import { Button, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'
interface Attribute {
key: string
value: string
}
interface CartLine {
attributes: Attribute[] | { [key: string]: any }
id: string
merchandiseId: string
quantity: number
price?: number
title?: string
featuredImage?: string
}
interface BuyerIdentity {
email: string
phone: string
firstName: string
lastName: string
address1: string
address2: string
city: string
zip: string
country: string
}
interface Cost {
subtotalAmount: string
totalAmount: string
totalTaxAmount: string
}
interface Cart {
id: string
cost: Cost
cartLines: CartLine[]
buyerIdentity: BuyerIdentity
}
export default function CartItems() {
const router = useRouter()
const [loading, setLoading] = useState<boolean>(true)
const [cart, setCart] = useState<Cart>()
const { cartId, deleteLine } = useCart()
const extractAttributes = (lines: CartLine[]) => {
return lines.map((line) => {
const attributes = Object.fromEntries(
line.attributes.map(({ key, value }: Attribute) => [key, value])
)
const { price, title, featuredImage, ...rest } = attributes
return {
title,
featuredImage,
price: Number(price),
...line,
attributes: rest,
}
})
}
const deleteCartLine = (line: CartLine) => {
if (cart) {
// Rmove the line from the cart
const newCartLines = cart?.cartLines.filter(({ id }) => id !== line.id)
setCart({ ...cart, cartLines: newCartLines })
// Modify attributes field to match db lines
const newAttributes = Object.entries(line.attributes).map(
([key, value]) => ({ key, value })
)
// Remove the line from store db
deleteLine({ ...line, attributes: newAttributes })
}
}
useEffect(() => {
setLoading(true)
if (cartId) {
fetch(`/api/cart?cartId=${cartId}&type=1`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) =>
setCart({
...data?.body,
cartLines: extractAttributes(data?.body.cartLines),
})
)
.finally(() => setLoading(false))
}
setLoading(false)
}, [])
if (!loading && !cartId)
return (
<div className='flex flex-col w-full items-center justify-center mt-20'>
<TbShoppingCartOff className='text-9xl text-gray-300' />
<Text size='md' faded>
Your cart is empty
</Text>
</div>
)
return (
<div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
{loading && (
<div className='col-span-full flex justify-center mt-12'>
<Text size='md'>Loading...</Text>
</div>
)}
{cart && (
<>
<div className='col-span-8 flex flex-col gap-10'>
{cart.cartLines.map((line) => (
<CartItem key={line.id} line={line} deleteLine={deleteCartLine} />
))}
</div>
<div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
<div className='flex flex-col gap-8'>
<Text size='md'>Buyer Information</Text>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Name</Text>
<span className='text-clamp-1'>
<Text size='sm'>{`${
cart.buyerIdentity.firstName ?? '...'
} ${cart.buyerIdentity.lastName ?? '...'}`}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Email</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.email}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Phone</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.phone}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>ZIP</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.zip}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 1</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.address1}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 2</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.address2}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Country</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.country}</Text>
</span>
</div>
</div>
<Button
onClick={() => console.log('Edit Buyer Information')}
outline
>
<div className='flex justify-center gap-4 items-center'>
<PiPencilSimpleLineThin className='text-2xl text-black' />
<Text size='md'>Edit</Text>
</div>
</Button>
</div>
<div className='w-full border-b border-black/40' />
<div className='flex flex-col gap-8'>
<Text size='md'>Order Summary</Text>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Tax</Text>
<Text size='md'>
{formatMoney(Number(cart.cost.totalTaxAmount))}
</Text>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Subtotal</Text>
<Text size='md'>
{formatMoney(Number(cart.cost.subtotalAmount))}
</Text>
</div>
</div>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='md'>Total</Text>
<Text size='xl'>
{formatMoney(Number(cart.cost.totalAmount))}
</Text>
</div>
<Button
disabled={adding}
onClick={() => router.push(cart?.checkoutUrl)}
>
Checkout
</Button>
</div>
</div>
</div>
</>
)}
</div>
)
}
function CartItem({
line,
deleteLine,
}: {
line: CartLine
deleteLine: (line: CartLine) => void
}) {
const { id, title, featuredImage, price, quantity, attributes } = line
const hasImage = featuredImage && title
const options = Object.entries(attributes)
.map(([key, value]) => value)
.join('.|.')
.split('.')
return (
<div className='relative grid grid-cols-7 place-items-stretch min-h-[20rem] gap-16 py-16 px-8 border-b border-black/40 last:border-none'>
<div className='col-span-2 w-full bg-gray-300'>
{hasImage && (
<Image
src={featuredImage}
alt={title}
width={400}
height={400}
className='w-full object-cover'
/>
)}
</div>
<div className='col-span-4 w-full flex flex-col gap-6'>
<Text size='lg'>{title ?? '...'}</Text>
<div className='flex justify-start items-center gap-6'>
<Text size='lg'>{quantity.toString() ?? '0'}</Text>
<Text size='lg'>x</Text>
<Text size='lg'>{formatMoney(price ?? 0)}</Text>
</div>
<div className='flex justify-start items-center gap-6'>
{options.map((option) => (
<Text size='md'>{option}</Text>
))}
</div>
</div>
<div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
<button
type='button'
onClick={() => console.log(`Line: ${id}, ready for editing!!`)}
className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Edit line'
>
<PiPencilSimpleLineThin className='text-2xl text-black' />
</button>
<button
type='button'
onClick={() => deleteLine(line)}
className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Delete line'
>
<PiTrashSimpleThin className='text-2xl text-red-500' />
</button>
</div>
</div>
)
}
And that's it for deleting a line from a cart. Not just deleting a line but also reflecting the changes in price in the checkout section of the cart page.
For the next section. You are going to make editing a cart line possible by clicking the icon
Let's start with the query.
Append this code to <project>/app/api/query.ts
file
export const UPDATE_A_CART_ITEM = `
mutation ($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(
cartId: $cartId
lines: $lines
) {
cart {
id
lines(first: 10) {
nodes {
id
quantity
merchandise {
... on ProductVariant {
id
}
}
attributes {
key
value
}
}
}
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
totalDutyAmount {
amount
currencyCode
}
}
}
}
}
`
Next, update the API route for cart with this route function
Append this to <project>/app/api/cart/route.ts
file
// Update lines in a cart
export async function PATCH(Request: NextRequest) {
const searchParams = Request.nextUrl.searchParams
const cartId = searchParams.get('cartId')
const { lines } = await Request.json()
const variables = { cartId, lines }
const { status, body } = await shopifyFetch({
query: UPDATE_A_CART_ITEM,
variables,
})
if (status === 200) {
const cart = cleanMiniCartResult(body.data?.cartLinesUpdate?.cart)
return Response.json({ status: 200, body: cart })
} else {
return Response.json({ status: 500, message: 'Error receiving data' })
}
}
Next, update the usecart context provider to be able to edit a line. And pass the function to it's consumers.
'use client'
import cookies from 'js-cookie'
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from 'react'
interface Merchandise {
quantity: number
id: string
attributes: {
key: string
value: string
}[]
}
interface Line {
id: string
merchandiseId: string
quantity: number
attributes: {
key: string
value: string
}[]
}
interface Cost {
subtotalAmount: number
totalAmount: number
totalTaxAmount: number
}
const DEFAULT_COST = { subtotalAmount: 0, totalAmount: 0, totalTaxAmount: 0 }
interface CartContextType {
cartId: string | null
updateCart: (newMerchandise: Merchandise) => void
deleteLine: (line: Line) => void
editLine: (line: Line) => void
adding: boolean
cartSize: number
cartPrice: Cost
}
const CartContext = createContext<CartContextType>({
cartId: null,
updateCart: () => {},
deleteLine: () => {},
editLine: () => {},
adding: false,
cartSize: 0,
cartPrice: DEFAULT_COST,
})
export const useCart = () => useContext(CartContext)
export function CartProvider({ children }: { children: ReactNode }) {
const [adding, setAdding] = useState<boolean>(false)
const [cartPrice, setCartPrice] = useState<Cost>(DEFAULT_COST)
const [cartLines, setCartLines] = useState<Line[]>([])
const [cartId, setCartId] = useState<string | null>(
cookies.get('cart_id') ?? null
)
const updateCart = (newMerchandise: Merchandise) => {
if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
else loadCart('POST', newMerchandise)
}
const deleteLine = (line: Line) => loadCart('DELETE', line)
const editLine = (line: Line) => loadCart('PATCH', line)
const loadCart = (
action: 'POST' | 'PUT' | 'GET' | 'DELETE' | 'PATCH',
merch?: Merchandise
) => {
setAdding(true)
const body = action === 'GET' ? null : JSON.stringify({ lines: [merch] })
fetch(`/api/cart?cartId=${cartId}`, {
method: action,
body,
})
.then((res) => res.json())
.then((data) => {
const newCartId = data?.body.id
// Only store cartId if it's a new cart.
!cartId && cookies.set('cart_id', newCartId, { expires: 7 })
setCartId(newCartId)
setCartLines(data?.body.cartLines)
setCartPrice(data?.body.cost)
})
.finally(() => setAdding(false))
}
useEffect(() => {
cartId && loadCart('GET')
}, [])
return (
<CartContext.Provider
value={{
cartId,
updateCart,
deleteLine,
editLine,
adding,
cartSize: cartLines.length,
cartPrice,
}}
>
{children}
</CartContext.Provider>
)
}
export default useCart
Lastly, you need to update the cart page and the CartItem
component to be able to trigger cart line editing on edit icon click.
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import { TfiClose, TfiCheck } from 'react-icons/tfi'
import useCart from '../hooks/usecart'
import { Button, MiniBox, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'
interface Attribute {
key: string
value: string
}
interface CartLine {
attributes: Attribute[]
optionAttributes: { [key: string]: any }
id: string
merchandiseId: string
quantity: number
price?: number
title?: string
featuredImage?: string
maxQuantity?: number
}
interface BuyerIdentity {
email: string
phone: string
firstName: string
lastName: string
address1: string
address2: string
city: string
zip: string
country: string
}
interface Cart {
id: string
cartLines: CartLine[]
buyerIdentity: BuyerIdentity
}
export default function CartItems() {
const router = useRouter()
const [loading, setLoading] = useState<boolean>(true)
const [cart, setCart] = useState<Cart>()
const { cartId, cartPrice, deleteLine, editLine } = useCart()
const extractAttributes = (lines: CartLine[]) => {
return lines.map((line) => {
const attributes = Object.fromEntries(
line.attributes.map(({ key, value }: Attribute) => [key, value])
)
const { price, title, featuredImage, maxQuantity, ...rest } = attributes
return {
title,
featuredImage,
price: Number(price),
maxQuantity: Number(maxQuantity),
...line,
optionAttributes: rest,
}
})
}
const deleteCartLine = (line: CartLine) => {
if (cart) {
// Rmove the line from the cart
const newCartLines = cart?.cartLines.filter(({ id }) => id !== line.id)
setCart({ ...cart, cartLines: newCartLines })
// Remove the line from store db
deleteLine(line)
}
}
const editCartLine = (oldLine: CartLine, newAmount: number) => {
if (cart) {
const newLine = { ...oldLine, quantity: newAmount }
// Edit the line in the cart
const updatedCartLines = cart?.cartLines.map((line) => {
if (line.id === newLine.id) {
return newLine
}
return line
})
setCart({ ...cart, cartLines: updatedCartLines })
// CartLine here should match the object structure of db cartline
const editedLine = {
id: newLine.id,
merchandiseId: newLine.merchandiseId,
quantity: newLine.quantity,
attributes: newLine.attributes,
}
editLine(editedLine)
}
}
useEffect(() => {
setLoading(true)
if (cartId) {
fetch(`/api/cart?cartId=${cartId}&type=1`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) =>
setCart({
...data?.body,
cartLines: extractAttributes(data?.body.cartLines),
})
)
.finally(() => setLoading(false))
}
setLoading(false)
}, [])
if (!loading && !cartId)
return (
<div className='flex flex-col w-full items-center justify-center mt-20'>
<TbShoppingCartOff className='text-9xl text-gray-300' />
<Text size='md' faded>
Your cart is empty
</Text>
</div>
)
return (
<div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
{loading && (
<div className='col-span-full flex justify-center mt-12'>
<Text size='md'>Loading...</Text>
</div>
)}
{cart && (
<>
<div className='col-span-8 flex flex-col gap-10'>
{cart.cartLines.map((line) => (
<CartItem
key={line.id}
line={line}
deleteLine={deleteCartLine}
editLine={editCartLine}
/>
))}
</div>
<div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
<div className='flex flex-col gap-8 w-full overflow-hidden'>
<Text size='md'>Buyer Information</Text>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Name</Text>
<span className='text-clamp-1'>
<Text size='sm'>{`${
cart.buyerIdentity.firstName ?? '...'
} ${cart.buyerIdentity.lastName ?? '...'}`}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Email</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.email}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Phone</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.phone}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>ZIP</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.zip}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 1</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.address1}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 2</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.address2}</Text>
</span>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Country</Text>
<span className='text-clamp-1'>
<Text size='sm'>{cart.buyerIdentity.country}</Text>
</span>
</div>
</div>
<Button
onClick={() => console.log('Edit Buyer Information')}
outline
>
<div className='flex justify-center gap-4 items-center'>
<PiPencilSimpleLineThin className='text-2xl text-black' />
<Text size='md'>Edit</Text>
</div>
</Button>
</div>
<div className='w-full border-b border-black/40' />
<div className='flex flex-col gap-8'>
<Text size='md'>Order Summary</Text>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Tax</Text>
<Text size='md'>
{formatMoney(Number(cartPrice.totalTaxAmount))}
</Text>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Subtotal</Text>
<Text size='md'>
{formatMoney(Number(cartPrice.subtotalAmount))}
</Text>
</div>
</div>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='md'>Total</Text>
<Text size='xl'>
{formatMoney(Number(cartPrice.totalAmount))}
</Text>
</div>
<Button
disabled={adding}
onClick={() => router.push(cart?.checkoutUrl)}
>
Checkout
</Button>
</div>
</div>
</div>
</>
)}
</div>
)
}
function CartItem({
line,
deleteLine,
editLine,
}: {
line: CartLine
deleteLine: (line: CartLine) => void
editLine: (line: CartLine, newAmount: number) => void
}) {
const [inEditMode, setInEditMode] = useState(false)
const {
title,
featuredImage,
price,
quantity,
maxQuantity,
optionAttributes,
} = line
const [newAmount, setNewAmount] = useState(quantity)
const hasImage = featuredImage && title
const options = Object.entries(optionAttributes)
.map(([key, value]) => value)
.join('.|.')
.split('.')
const EditModeControl = () => (
<div className='flex justify-start items-center gap-4'>
<button
type='button'
onClick={() => {
editLine(line, newAmount)
setInEditMode(false)
}}
className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Save line edit'
>
<TfiCheck className='text-2xl text-black' />
</button>
<button
type='button'
onClick={() => {
setNewAmount(quantity)
setInEditMode(false)
}}
className='w-14 h-14 flex justify-center items-center border border-black bg-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Cancel line edit'
>
<TfiClose className='text-2xl text-white' />
</button>
</div>
)
const EditAmountPanel = () => (
<div className='flex justify-start items-center gap-4'>
<MiniBox
onClick={() => newAmount > 1 && setNewAmount((prev) => prev - 1)}
>
-
</MiniBox>
<Text size='md'>{newAmount.toString()}</Text>
<MiniBox
onClick={() =>
newAmount < (maxQuantity ?? 0) && setNewAmount((prev) => prev + 1)
}
>
+
</MiniBox>
</div>
)
return (
<div className='relative grid grid-cols-9 place-items-stretch min-h-[20rem] xl:gap-16 gap-8 py-16 px-8 border-b border-black/40 last:border-none'>
<div className={`col-span-2 w-full ${!hasImage && 'bg-gray-300'}`}>
{hasImage && (
<Image
src={featuredImage}
alt={title}
width={400}
height={400}
className='w-full object-cover'
/>
)}
</div>
<div className='col-span-7 w-full flex flex-col gap-6'>
<Text size='lg'>{title ?? '...'}</Text>
<div className='flex justify-start items-center gap-6'>
{inEditMode ? (
<EditAmountPanel />
) : (
<Text size='lg'>{quantity.toString()}</Text>
)}
<Text size='lg'>x</Text>
<Text size='lg'>{formatMoney(price ?? 0)}</Text>
</div>
<div className='flex justify-start items-center gap-6'>
{options.map((option, id) => (
<Text key={option + id} size='md'>
{option}
</Text>
))}
</div>
</div>
<div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
{inEditMode ? (
<EditModeControl />
) : (
<button
type='button'
onClick={() => setInEditMode(true)}
className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Edit line'
>
<PiPencilSimpleLineThin className='text-2xl text-black' />
</button>
)}
<button
type='button'
onClick={() => deleteLine(line)}
className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Delete line'
>
<PiTrashSimpleThin className='text-2xl text-red-500' />
</button>
</div>
</div>
)
}
Now we are already done with the major part of this section.
But remember we still have one more query left: The query to edit the customer/buyer identity or information.
Paste this code in your <project>/app/api/query.ts
file.
export const UPDATE_CUSTOMER_INFO = `
mutation cartBuyerIdentityUpdate($buyerIdentity: CartBuyerIdentityInput!, $cartId: ID!) {
cartBuyerIdentityUpdate(buyerIdentity: $buyerIdentity, cartId: $cartId) {
cart {
id
buyerIdentity {
email
phone
customer {
id
firstName
lastName
}
countryCode
deliveryAddressPreferences {
... on MailingAddress {
address1
address2
city
provinceCode
countryCodeV2
zip
country
}
}
}
}
}
}
`
As you already suspected, you need a cleaner function. And to do this you need to give the query result a type.
export interface CustomerInfoQueryResult {
id: string
buyerIdentity: {
email: string
phone: string
customer: {
id: string
firstName: string
lastName: string
}
countryCode: string
deliveryAddressPreferences: {
address1: string
address2: string
city: string
provinceCode: string
countryCodeV2: string
zip: string
country: string
}
}
}
Next, the cleaner function
Append this code to your <project>/app/api/utils.ts
file.
/**
* Converts customer info query result to a cleaner format.
* @param result A result gotten from querying for customer info
* @returns A cleaner formart of customer info that can be used by components
*/
export function cleanCustomerInfoResult(result: CustomerInfoQueryResult) {
const { id, buyerIdentity } = result
const {
email,
phone,
customer: { firstName, lastName },
deliveryAddressPreferences: { address1, address2, city, zip, country },
} = buyerIdentity
return {
id,
firstName,
lastName,
email,
phone,
address1,
address2,
city,
country,
}
}
Done?
Next, create the API router.
Paste this in the <project>/app/api/cart/customer/route.ts
file
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
import { UPDATE_CUSTOMER_INFO } from '../../query'
import { cleanCustomerInfoResult } from '../../utils'
export async function POST(Request: NextRequest) {
const searchParams = Request.nextUrl.searchParams
const cartId = searchParams.get('cartId')
const { customerInfo } = await Request.json()
const { status, body } = await shopifyFetch({
query: UPDATE_CUSTOMER_INFO,
variables: { cartId, buyerIdentity: generateBuyerId(customerInfo) },
})
if (status === 200) {
const cart = cleanCustomerInfoResult(
body.data?.cartBuyerIdentityUpdate?.cart
)
return Response.json({ status: 200, body: cart })
} else {
return Response.json({ status: 500, message: 'Error receiving data' })
}
}
Here is the definition for the generateBuyerId function above.
Append this to your <project>/app/api/utils.ts
file
export function generateBuyerId(customerInfo: any) {
const {
firstName,
lastName,
email,
phone,
address1,
address2,
city,
province,
country,
zip,
} = customerInfo
return {
email,
phone,
deliveryAddressPreferences: {
deliveryAddress: {
firstName,
lastName,
address1,
address2,
city,
province,
country,
zip,
},
},
}
}
Now for the client. You have to create a new component for the checkout in the cart page folder. This will keep your codes clean.
Paste this in the <project/app/cart/cartinfo.tsx
file
'use client'
import { useRouter } from 'next/navigation'
import { formatMoney } from '@/lib/product'
import { Button, Text } from '../components/elements'
import useCart from '../hooks/usecart'
import { PiPencilSimpleLineThin } from 'react-icons/pi'
import { useMemo, useState } from 'react'
export interface BuyerIdentity {
email: string
phone: string
firstName: string
lastName: string
address1: string
address2: string
city: string
zip: string
country: string
}
export default function CartInfo({
cartId,
defaultBuyerIdentity,
checkoutUrl,
}: {
cartId: string
defaultBuyerIdentity: BuyerIdentity
checkoutUrl: string
}) {
const { adding, cartPrice } = useCart()
const router = useRouter()
const [inEditMode, setInEditMode] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [buyerIdentity, setBuyerIdentity] =
useState<BuyerIdentity>(defaultBuyerIdentity)
const hasCompleteDetails = useMemo(
() => Object.values(buyerIdentity).every((value) => value !== ''),
[buyerIdentity]
)
// api fetch
const updateInfo = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.currentTarget)
const newBuyerIdentity: BuyerIdentity = {
email: formData.get('email') as string,
phone: formData.get('phone') as string,
firstName: formData.get('first name') as string,
lastName: formData.get('last name') as string,
address1: formData.get('Address 1') as string,
address2: formData.get('Address 2') as string,
city: '',
zip: formData.get('zip') as string,
country: formData.get('Country') as string,
}
fetch(`/api/cart/customer?cartId=${cartId}`, {
method: 'POST',
body: JSON.stringify({ customerInfo: newBuyerIdentity }),
})
.then((res) => res.json())
.then((data) => {
if (data?.status === '200') {
setInEditMode(false)
setBuyerIdentity(data?.body)
}
})
.catch((e) => console.log('An error occurred.\n', e))
.finally(() => setLoading(false))
}
return (
<>
<form
onSubmit={updateInfo}
className='flex flex-col gap-8 w-full overflow-hidden'
>
<Text size='md'>Buyer Information</Text>
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-2 items-stretch'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Name</Text>
{!inEditMode && (
<span className='text-clamp-1'>
<Text size='sm'>{`${buyerIdentity.firstName ?? '...'} ${
buyerIdentity.lastName ?? '...'
}`}</Text>
</span>
)}
</div>
{(inEditMode || loading) && (
<div className='flex items-stretch justify-start gap-4'>
<input
name='first name'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='First Name'
/>
<input
name='last name'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='Last Name'
/>
</div>
)}
</div>
<div className='flex flex-col gap-2 items-stretch'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Email</Text>
{!inEditMode && (
<span className='text-clamp-1'>
<Text size='sm'>{buyerIdentity.email}</Text>
</span>
)}
</div>
{(inEditMode || loading) && (
<input
name='email'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='Email'
/>
)}
</div>
<div className='flex flex-col gap-2 items-stretch'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Phone</Text>
{!inEditMode && (
<span className='text-clamp-1'>
<Text size='sm'>{buyerIdentity.phone}</Text>
</span>
)}
</div>
{(inEditMode || loading) && (
<input
name='phone'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='Phone'
/>
)}
</div>
<div className='flex flex-col gap-2 items-stretch'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>ZIP</Text>
{!inEditMode && (
<span className='text-clamp-1'>
<Text size='sm'>{buyerIdentity.zip}</Text>
</span>
)}
</div>
{(inEditMode || loading) && (
<input
name='zip'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='Zip'
/>
)}
</div>
<div className='flex flex-col gap-2 items-stretch'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 1</Text>
{!inEditMode && (
<span className='text-clamp-1'>
<Text size='sm'>{buyerIdentity.address1}</Text>
</span>
)}
</div>
{(inEditMode || loading) && (
<input
name='Address 1'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='Address 1'
/>
)}
</div>
<div className='flex flex-col gap-2 items-stretch'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Address 2</Text>
{!inEditMode && (
<span className='text-clamp-1'>
<Text size='sm'>{buyerIdentity.address2}</Text>
</span>
)}
</div>
{(inEditMode || loading) && (
<input
name='Address 2'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='Address 2'
/>
)}
</div>
<div className='flex flex-col gap-2 items-stretch'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Country</Text>
{!inEditMode && (
<span className='text-clamp-1'>
<Text size='sm'>{buyerIdentity.country}</Text>
</span>
)}
</div>
{(inEditMode || loading) && (
<input
name='Country'
className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
placeholder='Country'
/>
)}
</div>
</div>
{inEditMode ? (
<div className='flex items-center justify-center gap-4'>
<button
type='submit'
disabled={!hasCompleteDetails}
className='py-4 px-10 shadow-md border border-black/90 leading-none text-2xl font-light text-black/90 bg-transparent hover:bg-gray-300 disabled:border-black/30'
>
<Text size='md'>{loading ? 'Saving' : 'Save'}</Text>
</button>
<Button onClick={() => setInEditMode(false)}>
<Text size='md' white>
Cancel
</Text>
</Button>
</div>
) : (
<Button onClick={() => setInEditMode(true)} outline>
<div className='flex justify-center gap-4 items-center'>
<PiPencilSimpleLineThin className='text-2xl text-black' />
<Text size='md'>Edit</Text>
</div>
</Button>
)}
</form>
<div className='w-full border-b border-black/40' />
<div className='flex flex-col gap-8'>
<Text size='md'>Order Summary</Text>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Tax</Text>
<Text size='md'>
{formatMoney(Number(cartPrice.totalTaxAmount))}
</Text>
</div>
<div className='flex items-center justify-between gap-4'>
<Text size='xs'>Subtotal</Text>
<Text size='md'>
{formatMoney(Number(cartPrice.subtotalAmount))}
</Text>
</div>
</div>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-4'>
<Text size='md'>Total</Text>
<Text size='xl'>{formatMoney(Number(cartPrice.totalAmount))}</Text>
</div>
<div className='flex flex-col items-stretch gap-3'>
<Button
disabled={adding || !hasCompleteDetails || loading}
onClick={() => router.push(checkoutUrl)}
>
Checkout
</Button>
{!hasCompleteDetails && (
<Text size='xs'>
* Please fill up your information before checkout.
</Text>
)}
</div>
</div>
</div>
</>
)
}
Here is an update of the CartItem
component accomodating the component above.
Append this to your <project>/app/cart/cartitem.tsx
file
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import { TfiClose, TfiCheck } from 'react-icons/tfi'
import useCart from '../hooks/usecart'
import { Button, MiniBox, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'
import CartInfo, { BuyerIdentity } from './cartinfo'
interface Attribute {
key: string
value: string
}
interface CartLine {
attributes: Attribute[]
optionAttributes: { [key: string]: any }
id: string
merchandiseId: string
quantity: number
price?: number
title?: string
featuredImage?: string
maxQuantity?: number
}
interface Cart {
id: string
checkoutUrl: string
cartLines: CartLine[]
buyerIdentity: BuyerIdentity
}
export default function CartItems() {
const router = useRouter()
const [loading, setLoading] = useState<boolean>(true)
const [cart, setCart] = useState<Cart>()
const { adding, cartId, cartPrice, deleteLine, editLine } = useCart()
const extractAttributes = (lines: CartLine[]) => {
return lines.map((line) => {
const attributes = Object.fromEntries(
line.attributes.map(({ key, value }: Attribute) => [key, value])
)
const { price, title, featuredImage, maxQuantity, ...rest } = attributes
return {
title,
featuredImage,
price: Number(price),
maxQuantity: Number(maxQuantity),
...line,
optionAttributes: rest,
}
})
}
const deleteCartLine = (line: CartLine) => {
if (cart) {
// Rmove the line from the cart
const newCartLines = cart?.cartLines.filter(({ id }) => id !== line.id)
setCart({ ...cart, cartLines: newCartLines })
// Remove the line from store db
deleteLine(line)
}
}
const editCartLine = (oldLine: CartLine, newAmount: number) => {
if (cart) {
const newLine = { ...oldLine, quantity: newAmount }
// Edit the line in the cart
const updatedCartLines = cart?.cartLines.map((line) => {
if (line.id === newLine.id) {
return newLine
}
return line
})
setCart({ ...cart, cartLines: updatedCartLines })
// CartLine here should match the object structure of db cartline
const editedLine = {
id: newLine.id,
merchandiseId: newLine.merchandiseId,
quantity: newLine.quantity,
attributes: newLine.attributes,
}
editLine(editedLine)
}
}
useEffect(() => {
setLoading(true)
if (cartId) {
fetch(`/api/cart?cartId=${cartId}&type=1`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) =>
setCart({
...data?.body,
cartLines: extractAttributes(data?.body.cartLines),
})
)
.finally(() => setLoading(false))
}
setLoading(false)
}, [])
if (!loading && !cartId)
return (
<div className='flex flex-col w-full items-center justify-center mt-20'>
<TbShoppingCartOff className='text-9xl text-gray-300' />
<Text size='md' faded>
Your cart is empty
</Text>
</div>
)
return (
<div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
{loading && (
<div className='col-span-full flex justify-center mt-12'>
<Text size='md'>Loading...</Text>
</div>
)}
{cart && (
<>
<div className='col-span-8 flex flex-col gap-10'>
{cart.cartLines.map((line) => (
<CartItem
key={line.id}
line={line}
deleteLine={deleteCartLine}
editLine={editCartLine}
/>
))}
</div>
<div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
<CartInfo
cartId={cart.id}
defaultBuyerIdentity={cart.buyerIdentity}
checkoutUrl={cart.checkoutUrl}
/>
</div>
</>
)}
</div>
)
}
function CartItem({
line,
deleteLine,
editLine,
}: {
line: CartLine
deleteLine: (line: CartLine) => void
editLine: (line: CartLine, newAmount: number) => void
}) {
const [inEditMode, setInEditMode] = useState(false)
const {
title,
featuredImage,
price,
quantity,
maxQuantity,
optionAttributes,
} = line
const [newAmount, setNewAmount] = useState(quantity)
const hasImage = featuredImage && title
const options = Object.entries(optionAttributes)
.map(([key, value]) => value)
.join('.|.')
.split('.')
const EditModeControl = () => (
<div className='flex justify-start items-center gap-4'>
<button
type='button'
onClick={() => {
editLine(line, newAmount)
setInEditMode(false)
}}
className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Save line edit'
>
<TfiCheck className='text-2xl text-black' />
</button>
<button
type='button'
onClick={() => {
setNewAmount(quantity)
setInEditMode(false)
}}
className='w-14 h-14 flex justify-center items-center border border-black bg-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Cancel line edit'
>
<TfiClose className='text-2xl text-white' />
</button>
</div>
)
const EditAmountPanel = () => (
<div className='flex justify-start items-center gap-4'>
<MiniBox
onClick={() => newAmount > 1 && setNewAmount((prev) => prev - 1)}
>
-
</MiniBox>
<Text size='md'>{newAmount.toString()}</Text>
<MiniBox
onClick={() =>
newAmount < (maxQuantity ?? 0) && setNewAmount((prev) => prev + 1)
}
>
+
</MiniBox>
</div>
)
return (
<div className='relative grid grid-cols-9 place-items-stretch min-h-[20rem] xl:gap-16 gap-8 py-16 px-8 border-b border-black/40 last:border-none'>
<div className={`col-span-2 w-full ${!hasImage && 'bg-gray-300'}`}>
{hasImage && (
<Image
src={featuredImage}
alt={title}
width={400}
height={400}
className='w-full object-cover'
/>
)}
</div>
<div className='col-span-7 w-full flex flex-col gap-6'>
<Text size='lg'>{title ?? '...'}</Text>
<div className='flex justify-start items-center gap-6'>
{inEditMode ? (
<EditAmountPanel />
) : (
<Text size='lg'>{quantity.toString()}</Text>
)}
<Text size='lg'>x</Text>
<Text size='lg'>{formatMoney(price ?? 0)}</Text>
</div>
<div className='flex justify-start items-center gap-6'>
{options.map((option, id) => (
<Text key={option + id} size='md'>
{option}
</Text>
))}
</div>
</div>
<div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
{inEditMode ? (
<EditModeControl />
) : (
<button
type='button'
onClick={() => setInEditMode(true)}
className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Edit line'
>
<PiPencilSimpleLineThin className='text-2xl text-black' />
</button>
)}
<button
type='button'
onClick={() => deleteLine(line)}
className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
title='Delete line'
>
<PiTrashSimpleThin className='text-2xl text-red-500' />
</button>
</div>
</div>
)
}
Without the customer complete information the checkout button is disabled to avoid customers moving to the checkout URL with incomplete information on delivery.
If you haven't granted an unauthorised_user
access to your customer in the headless app of in your Shopify admin store. It might prevent you from getting results. So just go to headless
and edit the access of your created storefront.
This will make sure the checkout process is smooth.
One more thing.
Remember our product page. The BUY
button doesn't work as it should. You need to fix that. When the buy button is clicked, the product should be added to the cart and the the customer should be taken immediately to the cart page.
Update your button in the <project>/app/collection/[cid]/product/[pid]/details.tsx
file:
...
<Button
onClick={() => {
addToCart()
router.push('/cart')
}}
disabled={
(variant?.quantityAvailable ?? 0) < 1 || loading || adding
}
>
Buy
</Button>
...
And we are done. Yes, I meant it.
Now everything works.
Congratulations on staying till the end.
It was a rough road but we are here at last. Now you can build online stores with Shopify and Next.js together with ease.
You will find the full code for this tutorial in this GitHub repo.
If you are an enthusiast like me, you should make the store responsive. I know I will.
Practice and have fun.