Make a simple Shopping Cart App using NextJS 13 + Redux

Nay mình sẽ làm một ví dụ về Giỏ hàng(cart) trong Nextjs 13. Ở đây mình có kết hợp với Redux, Redux-Thunk , để xử lý thêm một sản phẩm vào giỏ hàng
Nếu bài viết này , bạn cảm thấy hay thì hãy chia sẻ nó , đó là ủng hộ tôi
Bạn nào chưa xem về Redux thì xem lại các bài viết trước của mình tại đây:

+ Build A Shopping Cart With React + Redux
+ Create A Simple React With Redux

Trong bài viết này mình cũng vận dụng kiến thức ở các bài viết về Redux mà mình đã thực hiện thôi, nên cũng không có giải thích gì nhiều, vì đa phần React nó giống với NextJS rồi. Chỉ có điều là React nó chạy render ở client, còn Nextjs nó render trên server, chính vì thế nó hổ trợ cho việc chúng ta có thể SEO các từ khoá,....

Bố cục của nội dung hôm nay sau:

+ app/_assets/images : Dùng để lưu hình ảnh

+ app/_components/Header.tsx : Cấu hình giao diện cho header , hiển thị số lượng chỉ số sản phẩm có trong giỏ hàng

+ app/_libs/index.ts : Các thư viện cần cài đặt

+ app/_redux/actions/index.js : Cấu hình các action cho Redux, khi ta dispatch một action nó sẽ đi đên Reducers xử lý, nói chúng reducers nó đảm nhận sự thay đổi dữ liệu, sao đó nó mới cập nhật đến Stores, nên ta cần cấu hình các action cho nó dễ hiểu và maintain hơn

+ app/_redux/reducers/index.js : Nơi nhận các action gửi đến. Reducers nó sẽ nhận dạng các action để mà xử lý. Xử lý xong , nó sẽ cập nhật đến Stores

+ app/_redux/stores/index.js : Nơi lưu trữ các trạng thái (state) của hệ thống. Nó là nơi quản lý các State, giúp ta dễ dàng lấy ra dùng bắt cứ nơi nào trong Component

+ app/_redux/redux-provider.js : Nó sử dụng thành phần Provider từ thư viện react-redux để cung cấp store cho các thành phần con thông qua prop store. Các thành phần con được truyền vào thông qua prop children.

+ app/_redux/provider.tsx : Thành phần này được sử dụng để cung cấp chức năng của Redux store cho các thành phần con của nó. Kiểu PropsWithChildren<any> được sử dụng để định nghĩa prop children

+ app/_types/index.ts : Cấu hình các interface trong typescript, dùng nó để định dạng các kiểu dữ liệu , giúp ta dễ kiểm tra lỗi hơn

+ app/(route)/api/router.ts : Thiết lập đường dẫn route api, để gửi yêu cầu lấy tất cả sản phẩm , VD:/api/products

+ app/(route)/api/[id]/route.ts : Thiếp lập API, Gửi kèm theo ID của sản phẩm, để lấy thông tin của sản phẩm đó. VD: /api/products/12

+ app/(route)/cart/page.tsx : Thiếp lập giao diện hiển thị các sản phẩm mà người dùng đã mua có trong giỏ hàng(carts). Chúng ta lấy sản phẩm có trong giỏ hàng tại Stores của Redux

+ app/(route)/product/page.tsx : Trong component này ta cần hiện thị tất cả sản phẩm lấy được, từ hành động request /api/products/route.ts. VD: /api/products

+ app/(route)/product/[id]/page.tsx : Tại component này ta hiện thị của sản phẩm theo ID . Từ hành động request api/products/[id]/route.ts . VD: /api/products/12

+ app/page.tsx : Cấu hình component hiện thị

+ app/layout.tsx : Cấu hình bố cục hệ thống , đồng thời cấu hình Providers trong React-Redux vào để có thể sử dụng Redux trong các Component 

+ .env : Tạo file .env ở thư mục tổng của hệ thống, thiếp lập các biến môi trường cho nó. VD: PATH_URL_BACKEND=https://dummyjson.com

Okay, vậy là xong, giờ ta đi sơ lượt qua các file bên trên thôi, mọi người có thể xem tại : Github

Demo: Thấy hay, hãy đăng ký ủng hộ kênh  Youtube Hòa Nguyễn Coder  

Link Demo: Click Tại Đây

Ở đây mình có sử dụng API : https://dummyjson.com, mình thấy nó rất hay, các bạn có thể sử dụng nó

+ app/(route)/api/router.ts : Xây dụng phương thức GET . Giúp chúng ta request http://localhost:3000/api/products , nó sẽ gọi lấy tất cả sản phẩm từ api: https://dummyjson.com/products

import { NextRequest, NextResponse } from 'next/server'

export async function GET() {
  const res = await fetch(process.env.PATH_URL_BACKEND+'/products', {
    headers: {
      'Content-Type': 'application/json',
    },
  })
  const result = await res.json()
  return NextResponse.json({ result })
}

+ app/(route)/api/[id]/route.ts : Lấy sản phẩm theo ID mà ta chèn vào link , request http://localhost:3000/api/product/[id]  , nó sẽ lấy sản phẩm tại api : https://dummyjson.com/products/12

import { NextRequest, NextResponse } from "next/server"
export async function GET(request : NextRequest,{ params }: { params: { id: number } }) {
    const res = await fetch(process.env.PATH_URL_BACKEND+`/products/${params.id}`, {
      next: { revalidate: 10 } ,
      headers: {
        'Content-Type': 'application/json',
      },
    })
    const result = await res.json()
    return NextResponse.json(result)
  }  

Okay, tiếp ta sẽ thiết lập Redux thôi
+ app/_redux/actions/index.js : Thiếp lập cài đặt các action , giúp ta dễ dạng gọi và distpath một action

export const INCREASE_QUANTITY = "INCREASE_QUANTITY";
export const DECREASE_QUANTITY = "DECREASE_QUANTITY";
export const GET_NUMBER_CART = "GET_NUMBER_CART";
export const ADD_CART = "ADD_CART";
export const UPDATE_CART = "UPDATE_CART";
export const DELETE_CART = "DELETE_CART";

/*GET NUMBER CART*/
export function GetNumberCart() {
  return {
    type: "GET_NUMBER_CART",
  };
}

export function AddCart(payload) {
  return {
    type: "ADD_CART",
    payload,
  };
}
export function UpdateCart(payload) {
  return {
    type: "UPDATE_CART",
    payload,
  };
}
export function DeleteCart(payload) {
  return {
    type: "DELETE_CART",
    payload,
  };
}

export function IncreaseQuantity(payload) {
  return {
    type: "INCREASE_QUANTITY",
    payload,
  };
}
export function DecreaseQuantity(payload) {
  return {
    type: "DECREASE_QUANTITY",
    payload,
  };
}

+ app/_redux/reducers/index.js : Tại file này, ta cần nhận dạng các action để xử ý chúng, và cập nhật dữ liệu đến Stores 

import { combineReducers } from "redux";
import {
  GET_NUMBER_CART,
  ADD_CART,
  DECREASE_QUANTITY,
  INCREASE_QUANTITY,
  DELETE_CART,
} from "../actions";
const initProduct = {
  numberCart: 0,
  Carts: [],
};

function todoProduct(state = initProduct, action) {
  switch (action.type) {
    case GET_NUMBER_CART:
      return {
        ...state,
      };
    case ADD_CART:
      if (state.numberCart == 0) {
        let cart = {
          id: action.payload.id,
          quantity: 1,
          name: action.payload.title,
          image: action.payload.thumbnail,
          price: action.payload.price,
        };
        state.Carts.push(cart);
      } else {
        let check = false;
        state.Carts.map((item, key) => {
          if (item.id == action.payload.id) {
            state.Carts[key].quantity++;
            check = true;
          }
        });
        if (!check) {
          let _cart = {
            id: action.payload.id,
            quantity: 1,
            name: action.payload.title,
            image: action.payload.thumbnail,
            price: action.payload.price,
          };
          state.Carts.push(_cart);
        }
      }
      return {
        ...state,
        numberCart: state.numberCart + 1,
      };
    case INCREASE_QUANTITY:
      state.numberCart++;
      state.Carts[action.payload].quantity++;

      return {
        ...state,
      };
    case DECREASE_QUANTITY:
      let quantity = state.Carts[action.payload].quantity;
      if (quantity > 1) {
        state.numberCart--;
        state.Carts[action.payload].quantity--;
      }

      return {
        ...state,
      };
    case DELETE_CART:
      let quantity_ = state.Carts[action.payload].quantity;
      console.log(quantity_);
      return {
        ...state,
        numberCart: state.numberCart - quantity_,
        Carts: state.Carts.filter((item) => {
          return item.id != state.Carts[action.payload].id;
        }),
      };
    default:
      return state;
  }
}
const ShopApp = combineReducers({
  _todoProduct: todoProduct,
});
export default ShopApp;

Đoạn code trên mình có giải thích ở các bài viết về Redux rồi, các bạn có thể xem lại ở mục Redux trong website nhé

+ app/_redux/stores/index.js : gọi reducers vào store

import { createStore, applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import ShopApp from '../reducers/index'
const store =  createStore(ShopApp,applyMiddleware(thunkMiddleware));
export default store;

​+ app/_reudx/redux-provider.js : Gọi Provider trong react-redux

"use client";
import store from "./stores";
import { Provider } from "react-redux";
export default function ReduxProvider({ children }) {
  return (
      <Provider store={store}>
          {children}
      </Provider>
  );
}

+ app/_redux/Provider.tsx : 

"use client";
import { PropsWithChildren } from "react";
import ReduxProvider from "./redux-provider";
export default function Providers({ children }: PropsWithChildren<any>) {
    return (
        <ReduxProvider>
            {children}
        </ReduxProvider>
    );
}

Thiếp lập Redux đã xong, như để sử dụng được ta cần phải cấu hình lại layout bố cục của ta
+ app/layout.tsx :

import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Providers from './_redux/provider'
import Header from './_components/Header'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
  title: 'Create a simple example Cart in NextJS 13',
  description: 'Create a simple example Cart in NextJS 13',
}
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
        <html lang="en">
          <body className={inter.className}>
               <Providers>
                    <Header />
                    {children}
                </Providers>
          </body>
        </html>
  )
}

+ app/page.tsx :

import ProductPage from './(routes)/product/page'
export default function Home() {
  return (
   <div className='w-full max-w-6xl m-auto'>
       <ProductPage />
   </div>
  )
}

Vậy là ta có thể sử dụng được rồi đó, giờ chỉ cần vào component và gọi và sử dụng thôi 
Tiếp theo bạn nào siêng thì thiết lập các kiểu dữ liệu trong typescript , để giúp ta dễ dàng kiểm tra lỗi và ràng buộc kiểu dữ liệu cho hợp lý
+ app/_types/index.ts :

export interface IProduct {
    id: number
    title: string
    description: string,
    brand:string,
    price: number,
    thumbnail: string,
    images: string[],
}

+ app/_libs/index.ts : export một function fetch để gọi trong các component 

export const fetcher = (url: string) => fetch(url).then((res) => res.json());

Okay, giờ đây là bước mà ta gọi và sử dụng chúng thôi
+ app/_components/Header.tsx

'use client'
import Link from 'next/link'
import React from 'react'
import { useSelector } from 'react-redux'
import icon_cart from "@/app/_assets/images/icons8-cart-80.png";
import Image from 'next/image';
export default function Header() {
  const  numberCart  = useSelector((state: any) => state._todoProduct.numberCart);
  return (
    <div className='w-full p-5 bg-gray-300'>
       <div className='w-full max-w-6xl m-auto px-4 flex flex-row items-center justify-between'>
          <Link href={'/'} className='font-bold text-xl'>Hoa Dev <br/> <span className='text-red-500 text-sm'>https://hoanguyenit.com</span></Link>
            <Link href='/cart' className='bg-white p-2 block rounded-md'>
                <div className='flex flex-row gap-2'><Image src={icon_cart} alt="cart" width={25} height={25} /> Cart : <span className='font-bold text-red-500 inline-block'>
                      {numberCart}
                  </span></div>
            </Link>
       </div>
    </div>
  )
}


Đoạn code trên mình có thêm useSelector , còn muốn gửi một action thì dùng thêm useDispatch
* useSelector : giúp ta lấy dữ liệu trong lưu trữ trong stores, code dưới đây nó sẽ lấy tổng số sản phẩm có trong giỏ hàng

const  numberCart  = useSelector((state: any) => state._todoProduct.numberCart);

* useDispatch : giúp ta dispatch một action(hành động), Ví dụ như sau:

dispatch(AddCart(product))
//or
dispatch({type:'ADD_CART',payload:product});

+ app/(route)/product/page.tsx : Đoạn có dưới đây, ta chỉ việc request api để lấy dữ liệu và hiển thị ra thôi

"use client"
import Image from 'next/image'
import Link from 'next/link'
import useSWR from 'swr';
import { fetcher } from '@/app/_libs';
import { IProduct } from '@/app/_types';
export default function ProductPage() {
    const { data , error, isLoading } = useSWR<any>(
        `/api/products`,
        fetcher
      );
    if (error) return <div>Failed to load</div>;
    if (isLoading) return <div>Loading...</div>;
    if (!data) return null;
  return (
    <div className='w-full'>
        <ul className='flex flex-wrap mt-4'>
            {
                data && data.result.products.map((product: IProduct) => {
                    return (
                        <li key={product.id} className="w-full sm:w-1/2 md:w-1/3 xl:w-1/4 p-4">
                            <div className="my-2 bg-white rounded-[20px] overflow-hidden relative sm:h-auto md:h-[380px] hover:shadow-md  border-gray-500/20 border-[1px]">
                                    <Link href={`/product/${product.id}`}><Image className="w-full block h-[230px] sm:h-auo border-[1px] border-gray-300" src={product.thumbnail} alt=""  width={200} height={120} /></Link>
                                    <div className="p-4">
                                        <h2 className="capitalize text-xl sm:text-[14px] md:text-[16px] font-bold"><Link href={`/product/${product.id}`}>{product.title}</Link></h2>
                                    </div>
                                    <div className="w-full sm:relative md:absolute bottom-0 flex justify-between items-center border-t-[1px] border-gray-200 py-2">
                                        <ul className="pl-4">
                                            <li className="inline-block px-1"><Link href="react"><span className="inline-block text-[12px]">#{product.brand}</span></Link></li>
                                        </ul>
                                        <div className="pr-4">
                                            <span className="text-[14px] font-bold"><i className="fas fa-eye pr-2"></i>Price: {product.price}</span>
                                        </div>
                                    </div>
                            </div>
                        </li>
                    )
                })
               
            }
        </ul>
    </div>
  )
}

+ app/(route)/product/[id]/page.tsx: request api kèm theo ID, để lấy thông tin sản phẩm theo ID đó

"use client"
import Image from 'next/image'
import { useDispatch } from 'react-redux'
import useSWR from 'swr';
import { fetcher } from '@/app/_libs';
import { AddCart } from '@/app/_redux/actions'
export default function ProductDetailPage({ params }: { params: { id: number } }) {
    const dispatch = useDispatch();
    const { data : product , error, isLoading } = useSWR<any>(
        `/api/products/${params.id}`,fetcher
    );
    if (error) return <div>Failed to load</div>;
    if (isLoading) return <div>Loading...</div>;
    if (!product) return null;
  return (
    <div className='w-full max-w-[400px] m-auto flex flex-col justify-center'>
       <div className="w-full mt-4">
            <Image src={product?.thumbnail} alt={product?.title} width={400} height={400}/>
            <div className='w-full mt-2'>
                <h1 className='font-bold text-2xl text-red-500'>{product?.title}</h1>
                <p className='text-gray-500'>{product?.description}</p>
                <p className='text-gray-500'>Price: ${product?.price}</p>
                <button className='bg-yellow-400 px-4 py-2 text-white mt-1' onClick={() => dispatch(AddCart(product))}>Add to Cart</button>
            </div>
       </div>
    </div>
  )
}


Đoạn code trên mình có dùng một dispatch(AddCart(product)) , giúp gửi một action đi đến Reducers xử lý , xử lý xong, cập nhật carts trong Stores

+ app/(route)/cart/page.tsx : Liệt kê các sản phẩm có trong giỏ hàng(carts) ra cho người dùng xem

"use client"
import { DecreaseQuantity, DeleteCart, IncreaseQuantity } from "@/app/_redux/actions";
import { IProduct } from "@/app/_types";
import Image from "next/image";
import React from "react";

import { useSelector, useDispatch } from 'react-redux';
export default function CartPage() {
  const dispatch = useDispatch();
  const items = useSelector((state: any) => state._todoProduct);
  //  console.log(items)
  const ListCart: any[] = [];
  let TotalCart=0;
  Object.keys(items.Carts).forEach(function(item){
      TotalCart+=items.Carts[item].quantity * items.Carts[item].price;
      ListCart.push(items.Carts[item]);
  });
  return (
    <div className="w-full max-w-4xl m-auto">
      <table className="w-full table-auto">
        <caption className="caption-top text-left font-bold py-5">
             Carts
        </caption>
        <thead>
          <tr>
            <td className="border border-slate-300 p-2"></td>
            <th className="border border-slate-300 p-2">Image</th>
            <th className="border border-slate-300 p-2">Title</th>
            <th className="border border-slate-300 p-2">Price</th>
            <th className="border border-slate-300 p-2">Quantity</th>
            <th className="border border-slate-300 p-2">Total Price</th>
          </tr>
        </thead>
        <tbody>
          {
            ListCart && ListCart.map((cart: any,key : number) => {
              return (
                <tr key={cart.id}>
                  <td className="border border-slate-300 p-2"><button className="bg-red-500 w-10 text-center text-xl px-2 py-1 text-white ml-5"  onClick={()=>dispatch(DeleteCart(key))}>X</button></td>
                  <td className="border border-slate-300 p-2">
                    <Image src={cart.image} alt={cart.name} width={150} height={150} />
                    </td>
                  <td className="border border-slate-300 p-2">{cart.name}</td>
                  <td className="border border-slate-300 p-2">{cart.price}</td>
                  <td className="border border-slate-300 p-2">
                    <div className="flex flex-row gap-2 justify-center">
                          <span className="text-xl px-2 py-1 text-black font-bold cursor-pointer" onClick={() => dispatch(DecreaseQuantity(key))}>-</span>
                          <span className="bg-gray-400 w-10 text-center text-xl px-1 py-1 text-white font-bold">{cart.quantity}</span>
                          <span className="text-xl px-2 py-1 text-black cursor-pointer" onClick={() => dispatch(IncreaseQuantity(key))} >+</span>
                         
                    </div>
                  </td>
                  <td className="border border-slate-300 p-2">{(cart.quantity * cart.price).toLocaleString('en-US')} $</td>

                </tr>
              )
            })
          }
        
        </tbody>
      </table>
      <div className="w-full">
        <div className="w-full mt-4">
            <h1 className="font-bold text-2xl text-red-500">Total : {Number(TotalCart).toLocaleString('en-US')} $</h1>
        </div>
      </div>
    </div>
  );
}

Đoạn code trên lấy sản phẩm có trong giỏ hàng , tính toán giá tiền, lưu vào mảng ListCart , sau đó chạy vòng lặp để hiện thị ra

const items = useSelector((state: any) => state._todoProduct);
  const ListCart: any[] = [];
  let TotalCart=0;
  Object.keys(items.Carts).forEach(function(item){
      TotalCart+=items.Carts[item].quantity * items.Carts[item].price;
      ListCart.push(items.Carts[item]);
  });


Thiếp lập các dispatch action sau:

* dispatch(DecreaseQuantity(key)) : gửi action xử lý việc giảm số lượng sản phẩm hiện tại theo key
* dispatch(IncreaseQuantity(key)) : gửi action xử lý tăng một sản phẩm hiện tại theo key

Okay vậy là xong, Nếu bạn thấy hay,  thì hãy chia sẻ bài viết này! Đến với mọi người!

Hình Demo:

Bài Viết Liên Quan

Messsage

Ủng hộ tôi bằng cách click vào quảng cáo. Để tôi có kinh phí tiếp tục phát triển Website!(Support me by clicking on the ad. Let me have the money to continue developing the Website!)