Apply Promo

Build a Shopping Cart using Server Actions that allows the user to apply a promo code.


Environment

Use the environment you are most comfortable with. Though previous articles have recommended using create-react-app to create a local version of the project, going forward, since the React docs site suggests using a full-stack React framework, challenges will use the most popular of these frameworks, Next.js. You can run

npx create-next-app@latest

from your terminal to set up a new Next.js project, then use npm run dev to develop locally and inspect the DOM from your browser when debugging. I opt to use App Router, made stable in Next.js 13.4 over the Pages Router. I also use tailwindcss for simple inline styling.

Specification

The application should display a list of items in the shopping cart. An initial shopping cart with a few items is provided in the starter code, and you do not need to support adding items. Each item should show its name, price per unit, and quantity. Users should also be able to adjust the quantity of each item.

  • Total Cost Calculation
    • The application should calculate and display the total cost of the items in the shopping cart.
    • If a promotional code is applied, the total cost should reflect the discount.
  • Promotional Code
    • Users should be able to enter a promotional code.
    • Upon submission, the application should check the code's validity using a server action (an async function that is guaranteed to run on the server). End user's can access any client code, so storing or fetching correct codes for comparison would effectively leak them.
    • If the code is valid, apply a discount to every second item in the cart (BOGO - Buy One, Get One for free).
  • Promo Code Feedback
    • Display a message to the user indicating whether the promotional code was successfully applied or if it was incorrect.
    • The message should be displayed in a modal and disappear after a short delay.

Here are a few images of the finished application:

The component's starting state
The component's starting state
The component after the user submits the correct code
The component after the user submits the correct code

Starter Code

Below is a small skeleton of the application to get you started. You will still need a file to define your Server Action (e.g. app/actions.ts). The Next.js docs explain how to use Server Actions here.

app/page.tsx

import ShoppingCart from './ShoppingCart'

export default function Home() {
  return (
    <div className='flex items-center justify-center h-screen w-screen space-y-10'>
      <ShoppingCart/>
    </div>
  )
}

app/ShoppingCart.tsx

'use client'

const INITIAL_CART = [
    { id: 1, name: 'Banana', price: 0.50, quantity: 1 },
    { id: 2, name: 'Avocado', price: 3.00, quantity: 2 },
  ]

export default function ShoppingCart(){
  const [cartItems, setCartItems] = useState(INITIAL_CART);


  // YOUR CODE HERE
}

Solution

The following blocks contain the entire solution. They are broken down and explained in detail below.

app/actions.ts

'use server'

export async function checkCode(prevState: any, formData: FormData) {
    const rawFormData = {
        promoCode: formData.get('promocode')
      }

    
    // In a real-world example, you might access 
    // a database of codes to check if the entered 
    // code is legitimate.
    const CODE = 'BOGOCODE'

    const applied = rawFormData['promoCode'] == CODE
    const msg = applied 
        ? 'Your promocode was successfully applied.' 
        : 'The entered code is incorrect.'

    return ({bool: applied, msg: msg})
    

  }

app/ShoppingCart.tsx

'use client'

import { useFormState } from "react-dom"
import { checkCode } from "./actions"
import { useEffect, useState } from "react"

const INITIAL_CART = [
    { id: 1, name: 'Banana', price: 0.50, quantity: 1 },
    { id: 2, name: 'Avocado', price: 3.00, quantity: 2 },
  ]

export default function ShoppingCart(){
  const [applied, formAction] = useFormState(checkCode, {bool: false, msg: 'n/a'})
  const [cartItems, setCartItems] = useState(INITIAL_CART);
  const [modalOpen, setModalOpen] = useState(false)

  const updateQuantity = (itemId: number, newQuantity: number) => {
    setCartItems((prevItems) =>
      prevItems.map((item) =>
        item.id === itemId ? { ...item, quantity: newQuantity } : item
      ).filter((item) => item.quantity > 0)
    );
  };

  const calculateTotal = () => {
    return cartItems.reduce((total, item) => {
      const addition = applied.bool
        ? (Math.floor(item.quantity/2) + item.quantity%2) * item.price
        : item.price * item.quantity
      return total + addition
      
    }, 0);
  };

  useEffect(() => {
    if (applied.msg == 'n/a') return
    setModalOpen(true)
    setTimeout(() => setModalOpen(false), 1500)
  }, [applied])
  
  return (
    <div className='flex flex-col space-y-3'>
      <div className="text-xl">Shopping Cart</div>
        {cartItems.map((item) => (
          <div key={item.id} className="flex">
            <p className="w-64">{item.name} - ${item.price}</p>
            <input
              type="number"
              value={item.quantity}
              onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
              className="w-10 mx-4"
            />
          </div>
        ))}
      <p>Total: ${calculateTotal()}</p>
      <div className="pt-10">
        {applied.bool ? 
          <p>Promo applied</p> 
          :
          (<form action={formAction}>
            <input type="text" name="promocode" />
            <button className='bg-blue-500 hover:bg-blue-700 text-white px-2 rounded mx-2'type="submit">Submit</button>
          </form>)
        }
      </div>
      {modalOpen && 
        <div className="absolute flex justify-center items-center z-1 w-64 rounded outline outline-3 bg-blue-300 self-center text-center">
          <p>{applied.msg}</p>
        </div>
      }
    </div>
  )
}

Solution Breakdown

1. app/actions.ts

This file contains the checkCode() function responsible for checking the validity of a promotional code. The function uses the signature required by React in order to work with its useFormState() hook. It uses the entered code and compares it with a predefined code (BOGOCODE). If the entered code matches, it sets a boolean flag (applied) to true and provides a success message. Otherwise, it sets the flag to false and provides an error message. As the Next.js docs advise, client components can only use Server Actions that use the module-level 'use server' directive. Server components, though, can define nested server actions that place the directive inside the async function.

'use server'

export async function checkCode(prevState: any, formData: FormData) {
    const rawFormData = {
        promoCode: formData.get('promocode')
    }

    // In a real-world example, you might access 
    // a database of codes to check if the entered 
    // code is legitimate.
    const CODE = 'BOGOCODE'

    const applied = rawFormData['promoCode'] == CODE
    const msg = applied 
        ? 'Your promocode was successfully applied.' 
        : 'The entered code is incorrect.'

    return ({bool: applied, msg: msg})
}

2. app/ShoppingCart.tsx

This file contains the main implementation of the shopping cart application.

State Initialization:

const [applied, formAction] = useFormState(checkCode, {bool: false, msg: 'n/a'});
const [cartItems, setCartItems] = useState([
  { id: 1, name: 'Banana', price: 0.50, quantity: 1 },
  { id: 2, name: 'Avocado', price: 3.00, quantity: 2 },
]);
const [modalOpen, setModalOpen] = useState(false);

useFormState() is React's new hook for accessing the value returned by the server action after it handles a submission.

Update Quantity Function:

const updateQuantity = (itemId: number, newQuantity: number) => {
  setCartItems((prevItems) =>
    prevItems.map((item) =>
      item.id === itemId ? { ...item, quantity: newQuantity } : item
    ).filter((item) => item.quantity > 0)
  );
};

Calculate Total Cost Function:

const calculateTotal = () => {
  return cartItems.reduce((total, item) => {
    const addition = applied.bool
      ? (Math.floor(item.quantity/2) + item.quantity%2) * item.price
      : item.price * item.quantity
    return total + addition;
  }, 0);
};

UseEffect for Modal Feedback:

useEffect(() => {
  if (applied.msg == 'n/a') return;
  setModalOpen(true);
  setTimeout(() => setModalOpen(false), 1500);
}, [applied]);

The function passed to useEffect() runs only after applied changes (i.e. when the server action updates the state variable created by useFormState() by returning a new object). To avoid the modal appearing when the component first renders, we return if the message is still in its initial state ('n/a').


useFormState
Server Actions
Next.js
useEffect
useState