😍Step by Step Study (part 1)👆
Last updated
Was this helpful?
Last updated
Was this helpful?
src\pages\index.tsx
import Layout from '@/components/Layout/Layout.component';
import LoadingSpinner from '@/components/LoadingSpinner/LoadingSpinner.component';
import client from '@/utils/apollo/ApolloClient';
import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES';
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import { useEffect, useState } from 'react';
export default function Home({ products}: InferGetStaticPropsType<typeof getStaticProps>) {
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
setTimeout(()=> {
setIsLoading(false);
},1500);
}, []);
return (
<Layout title="Lionel">
{
isLoading ? <LoadingSpinner /> : "Home1"
}
</Layout>
);
}
export const getStaticProps: GetStaticProps = async () => {
const { data, loading, networkStatus } = await client.query({query: FETCH_ALL_PRODUCTS_QUERY});
return {
props: {
products: data.products.nodes,
networkStatus,
},
revalidate: 60
}
}
src\utils\gql\GQL_QUERIES.ts
import { gql } from '@apollo/client';
export const FETCH_ALL_PRODUCTS_QUERY = gql`
query MyQuery {
products(last: 8) {
nodes {
__typename
databaseId
description(format: RENDERED)
image {
__typename
sourceUrl
}
onSale
name
slug
... on SimpleProduct {
price
salePrice
regularPrice
}
... on VariableProduct {
price
salePrice
regularPrice
productTypes {
nodes {
databaseId
description
name
products {
__typename
nodes {
description
onSale
image {
__typename
mediaItemUrl
}
... on SimpleProduct {
price
salePrice
regularPrice
}
... on VariableProduct {
price
salePrice
regularPrice
}
}
}
}
}
}
}
}
}
`;
src\pages\index.tsx
import Layout from '@/components/Layout/Layout.component';
import LoadingSpinner from '@/components/LoadingSpinner/LoadingSpinner.component';
import DisplayProducts from '@/components/Product/DisplayProducts.component';
import client from '@/utils/apollo/ApolloClient';
import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES';
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import { useEffect, useState } from 'react';
export default function Home({ products}: InferGetStaticPropsType<typeof getStaticProps>) {
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
setTimeout(()=> {
setIsLoading(false);
},1500);
}, []);
return (
<Layout title="Lionel">
{
isLoading ? <LoadingSpinner /> : <DisplayProducts products={products} />
}
</Layout>
);
}
export const getStaticProps: GetStaticProps = async () => {
const { data } = await client.query({query: FETCH_ALL_PRODUCTS_QUERY});
return {
props: {
products: data.products.nodes
},
revalidate: 60
}
}
src\components\Product\DisplayProducts.component.tsx
import Link from 'next/link';
import { v4 as uuidv4 } from 'uuid';
import { filteredVariantPrice, paddedPrice } from '@/utils/functions/functions';
interface Image {
__typename: string;
sourceUrl: string;
}
interface Node {
__typename: string;
price: string;
regularPrice: string;
salePrice?: string;
}
interface Variations {
__typename: string;
nodes: Node[];
}
interface RootObject {
__typename: string;
databaseId: number;
name: string;
slug: string;
image: Image;
price: string;
regularPrice: string;
salePrice?: string;
onSale: boolean;
variations: Variations;
}
interface IDisplayProductsProps {
products: RootObject[];
}
const DisplayProducts = ({ products }: IDisplayProductsProps) => {
return (
<section className="container mx-auto bg-white py-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{
products ? (products.map(({ databaseId, name, slug, image,price,regularPrice,salePrice,onSale,variations})=>{
if (price) {
price = paddedPrice(price, 'kr');
}
if (regularPrice) {
regularPrice = paddedPrice(regularPrice, 'kr');
}
if (salePrice) {
salePrice = paddedPrice(salePrice, 'kr');
}
return (
<div key={uuidv4()} className="group">
<Link
href={`/product/${encodeURIComponent(slug)}?id=${encodeURIComponent(databaseId)}`}
>
<div className="aspect-[3/4] relative overflow-hidden bg-gray-100">
{image ? (
<img
id="product-image"
className="w-full h-full object-cover object-center transition duration-300 group-hover:scale-105"
alt={name}
src={image.sourceUrl}
/>
) : (
<img
id="product-image"
className="w-full h-full object-cover object-center transition duration-300 group-hover:scale-105"
alt={name}
src={process.env.NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL}
/>
)}
</div>
</Link>
</div>
)
})) : (<div className="mx-auto text-xl font-bold text-center text-gray-800 no-underline uppercase"> Ingen produkter funnet </div>)
}
</div>
</section>
)
}
export default DisplayProducts;
src\pages\product[slug].tsx
import { withRouter } from 'next/router';
import type { NextPage, GetServerSideProps, InferGetServerSidePropsType } from 'next';
import Layout from '@/components/Layout/Layout.component';
import { GET_SINGLE_PRODUCT } from '@/utils/gql/GQL_QUERIES';
import client from '@/utils/apollo/ApolloClient';
import SingleProduct from '@/components/Product/SingleProduct.component';
const Product: NextPage = ({product,networkStatus}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
const hasError = networkStatus === '8';
return (
<Layout title={`${product.name ? product.name : ''}`}>
{product ? (
<SingleProduct product={product} />
) : (
<div className="mt-8 text-2xl text-center">Loading product ...</div>
)}
{hasError && (
<div className="mt-8 text-2xl text-center">
Error loading product ...
</div>
)}
</Layout>
)
}
export default withRouter(Product);
export const getServerSideProps: GetServerSideProps = async ({ query: { id }}) => {
const { data, networkStatus } = await client.query({
query: GET_SINGLE_PRODUCT,
variables: {id}
});
return {
props: { product: data.product, networkStatus },
};
}
src\components\Product\SingleProduct.component.tsx
import { useState, useEffect } from 'react';
import { filteredVariantPrice, paddedPrice } from '@/utils/functions/functions';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component';
interface IImage {
__typename: string;
uri: string;
title: string;
srcSet: string;
sourceUrl: string;
}
interface IVariationNode {
__typename: string;
name: string;
}
interface IAllPaColors {
__typename: string;
nodes: IVariationNode[];
}
interface IAllPaSizes {
__typename: string;
nodes: IVariationNode[];
}
export interface IVariationNodes {
__typename: string;
id: string;
databaseId: number;
name: string;
image?: string;
stockStatus: string;
stockQuantity: number;
purchasable: boolean;
onSale: boolean;
salePrice?: string;
regularPrice: string;
}
interface IVariations {
__typename: string;
nodes: IVariationNodes[];
}
export interface IProduct {
__typename: string;
databaseId: number;
name: string;
description: string;
slug: string;
averageRating: number;
onSale: boolean;
image: IImage;
price: string;
regularPrice: string;
salePrice?: string;
allPaColors?: IAllPaColors;
allPaSizes?: IAllPaSizes;
variations?: IVariations;
stockQuantity: number;
}
export interface IProductRootObject {
product: IProduct;
variationId?: number;
fullWidth?: boolean;
}
const _ = require("lodash");
const SingleProduct = ({ product }: IProductRootObject) => {
let { description, image, name, onSale, price, regularPrice, salePrice } = product;
const [isLoading, setIsLoading] = useState<boolean>(true);
const [selectedVariation, setSelectedVariation] = useState<number>();
const [imagedVariation, setImagedVariation] = useState<string>('');
let DESCRIPTION_WITHOUT_HTML;
if (typeof window !== 'undefined') {
DESCRIPTION_WITHOUT_HTML = new DOMParser().parseFromString(
description,
'text/html',
).body.textContent;
}
if (price) {
price = paddedPrice(price, ' ');
}
if (regularPrice) {
regularPrice = paddedPrice(regularPrice, ' ');
}
if (salePrice) {
salePrice = paddedPrice(salePrice, ' ');
}
useEffect(() => {
setTimeout(() => {
setIsLoading(false);
}, 300);
if (selectedVariation) {
let indexF = _.findIndex(product.variations?.nodes, function (o: any) { return o.databaseId === selectedVariation; });
var { sourceUrl }:any = product.variations?.nodes[indexF].image;
setImagedVariation(sourceUrl);
}
}, [selectedVariation]);
return (
<section className="bg-white mb-[8rem] md:mb-12">
{
isLoading ? <LoadingSpinner /> : (
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col md:grid md:grid-cols-2 md:gap-8">
<div className="mb-6 md:mb-0 group">
<div className="max-w-xl mx-auto aspect-[3/4] relative overflow-hidden bg-gray-100">
<img
src={
imagedVariation !== '' ? imagedVariation : (image?.sourceUrl || process.env.NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL)
}
alt={name}
className="w-full h-full object-cover object-center transition duration-300 group-hover:scale-105"
/>
</div>
</div>
<div className="flex flex-col">
<h1 className="text-2xl font-bold text-center md:text-left mb-4">
{name}
</h1>
<div className="text-center md:text-left mb-6">
{
onSale ? (
<div className="flex flex-col md:flex-row items-center md:items-start gap-2">
<p className="text-2xl font-bold text-gray-900">
{product.variations ? filteredVariantPrice(price, 'right') : salePrice}
</p>
<p className="text-xl text-gray-500 line-through">
{product.variations ? filteredVariantPrice(price, 'right') : regularPrice}
</p>
</div>
) :
(<p className="text-2xl font-bold">{price}</p>)
}
</div>
<p className="text-lg mb-6 text-center md:text-left">
{DESCRIPTION_WITHOUT_HTML}
</p>
{Boolean(product.stockQuantity) && (
<div className="mb-6 mx-auto md:mx-0">
<div className="p-2 bg-green-100 border border-green-400 rounded-lg max-w-[14.375rem]">
<p className="text-lg text-green-700 font-semibold text-center md:text-left">
{product.stockQuantity} in stock
</p>
</div>
</div>
)}
{product.variations && (
<div className="mb-6 mx-auto md:mx-0 w-full max-w-[14.375rem]">
<label htmlFor="variant" className="block text-lg font-medium mb-2 text-center md:text-left">
Variants
</label>
<select
id="variant"
name="variant"
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) =>
setSelectedVariation(Number(e.target.value))
}
>
{product.variations.nodes.map(
({ id, name, databaseId, stockQuantity }) => {
return (
<option key={id} value={databaseId}>
{name.split('- ').pop()} - ({stockQuantity} in stock)
</option>
)
},
)}
</select>
</div>
)}
</div>
</div>
</div>
)
}
</section>
);
}
export default SingleProduct;
src\utils\gql\GQL_QUERIES.ts
import { gql } from '@apollo/client';
export const FETCH_ALL_PRODUCTS_QUERY = gql`
query MyQuery {
products(last: 8) {
nodes {
__typename
databaseId
description(format: RENDERED)
image {
__typename
sourceUrl
}
onSale
name
slug
... on SimpleProduct {
price
salePrice
regularPrice
}
... on VariableProduct {
price
salePrice
regularPrice
productTypes {
nodes {
databaseId
description
name
products {
__typename
nodes {
description
onSale
image {
__typename
mediaItemUrl
}
... on SimpleProduct {
price
salePrice
regularPrice
}
... on VariableProduct {
price
salePrice
regularPrice
}
}
}
}
}
}
}
}
}
`;
export const GET_SINGLE_PRODUCT = gql`
query Product($id: ID!) {
product(id: $id, idType: DATABASE_ID) {
databaseId
name
description
slug
averageRating
image {
uri
title
srcSet
sourceUrl
}
... on SimpleProduct {
stockQuantity
price
regularPrice
salePrice
}
... on VariableProduct {
price
regularPrice
salePrice
allPaColors {
nodes {
name
}
}
allPaSizes {
nodes {
name
}
}
variations {
nodes {
databaseId
name
image {
sourceUrl
}
stockStatus
stockQuantity
price
salePrice
regularPrice
onSale
}
}
}
... on ExternalProduct {
name
price
externalUrl
}
... on GroupProduct {
products {
nodes {
... on SimpleProduct {
price
regularPrice
salePrice
}
}
}
}
}
}
`;
mutation {
addToCart(input: {productId: 1786}) {
cartItem {
key
product {
node {
id
name
description
slug
type
onSale
averageRating
reviewCount
image {
altText
sourceUrl
}
}
}
}
}
}
{
"data": {
"addToCart": {
"cartItem": {
"key": "6449f44a102fde848669bdd9eb6b76fa",
"product": {
"node": {
"id": "cHJvZHVjdDoxNzg2",
"name": "Single",
"description": "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sagittis orci ac odio dictum tincidunt. Donec ut metus leo. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed luctus, dui eu sagittis sodales, nulla nibh sagittis augue, vel porttitor diam enim non metus. Vestibulum aliquam augue neque. Phasellus tincidunt odio eget ullamcorper efficitur. Cras placerat ut turpis pellentesque vulputate. Nam sed consequat tortor. Curabitur finibus sapien dolor. Ut eleifend tellus nec erat pulvinar dignissim. Nam non arcu purus. Vivamus et massa massa.</p>\n",
"slug": "single",
"type": "SIMPLE",
"onSale": true,
"averageRating": 0,
"reviewCount": 0,
"image": {
"altText": "",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/single-1.jpg"
}
}
}
}
}
}
}
Hoặc chỉ cần id là đủ mẫu như sau
mutation {
addToCart(input: {productId: 1780}) {
__typename
}
}
https://wpclidemo.dev/wp-admin/post.php?post=1774&action=edit
https://wpclidemo.dev/product/hoodie/
mutation {
addToCart(input: {productId: 1774 }) {
cartItem {
key
product {
node {
id
name
description
slug
type
onSale
averageRating
reviewCount
image {
altText
sourceUrl
}
galleryImages { // thêm để lấy ảnh gallery
nodes {
sourceUrl
altText
}
}
}
}
}
}
}
{
"data": {
"addToCart": {
"cartItem": {
"key": "f0bda020d2470f2e74990a07a607ebd9",
"product": {
"node": {
"id": "cHJvZHVjdDoxNzc0",
"name": "Hoodie",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"slug": "hoodie",
"type": "VARIABLE",
"onSale": true,
"averageRating": 0,
"reviewCount": 0,
"image": {
"altText": "",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/hoodie-2.jpg"
},
"galleryImages": {
"nodes": [
{
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/hoodie-blue-1.jpg",
"altText": ""
},
{
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/hoodie-green-1.jpg",
"altText": ""
},
{
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/hoodie-with-logo-2.jpg",
"altText": ""
}
]
}
}
}
}
}
}
}
Một trường hợp
mutation {
addToCart(input: {productId:7}) {
cartItem {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
databaseId
name
description
type
onSale
price
regularPrice
salePrice
image {
id
sourceUrl
altText
}
attributes {
nodes {
id
attributeId
name
value
}
}
}
}
quantity
total
subtotal
subtotalTax
}
}
}
{
"data": {
"addToCart": {
"cartItem": {
"key": "8f14e45fceea167a5a36dedd4bea2543",
"product": {
"node": {
"id": "cHJvZHVjdDo3",
"databaseId": 7,
"name": "Hoodie",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"type": "VARIABLE",
"onSale": true,
"slug": "hoodie-2",
"averageRating": 0,
"reviewCount": 0,
"image": {
"id": "cG9zdDozNA==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg",
"altText": ""
},
"galleryImages": {
"nodes": [
{
"id": "cG9zdDozNQ==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1.jpg",
"altText": ""
},
{
"id": "cG9zdDozNg==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1.jpg",
"altText": ""
},
{
"id": "cG9zdDozNw==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg",
"altText": ""
}
]
}
}
},
"variation": null,
"quantity": 1,
"total": "42 ₫",
"subtotal": "42 ₫",
"subtotalTax": "0 ₫"
}
}
}
}
mutation {
addToCart(input: {productId: 7, variationId: 30}) {
cartItem {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
}
}
quantity
total
subtotal
subtotalTax
}
}
}
{
"data": {
"addToCart": {
"cartItem": {
"key": "5a35fb08d551fb9cff28454e4208d9ac",
"product": {
"node": {
"id": "cHJvZHVjdDo3",
"databaseId": 7,
"name": "Hoodie",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"type": "VARIABLE",
"onSale": false,
"slug": "hoodie-2",
"averageRating": 0,
"reviewCount": 0,
"image": {
"id": "cG9zdDozNA==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg",
"altText": ""
},
"galleryImages": {
"nodes": [
{
"id": "cG9zdDozNQ==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1.jpg",
"altText": ""
},
{
"id": "cG9zdDozNg==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1.jpg",
"altText": ""
},
{
"id": "cG9zdDozNw==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg",
"altText": ""
}
]
}
}
},
"variation": {
"node": {
"id": "cHJvZHVjdF92YXJpYXRpb246MzA="
}
},
"quantity": 1,
"total": "45 ₫",
"subtotal": "45 ₫",
"subtotalTax": "0 ₫"
}
}
}
}
Kết quả:
Mua kèm theo số lượng
mutation {
addToCart(input: {productId: 7, variationId: 25, quantity: 5}) {
cartItem {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
}
}
quantity
total
subtotal
subtotalTax
}
}
}
{
"data": {
"addToCart": {
"cartItem": {
"key": "257eacf4555ef0dc335708114ce2d06e",
"product": {
"node": {
"id": "cHJvZHVjdDo3",
"databaseId": 7,
"name": "Hoodie",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"type": "VARIABLE",
"onSale": false,
"slug": "hoodie-2",
"averageRating": 0,
"reviewCount": 0,
"image": {
"id": "cG9zdDozNA==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg",
"altText": ""
},
"galleryImages": {
"nodes": [
{
"id": "cG9zdDozNQ==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1.jpg",
"altText": ""
},
{
"id": "cG9zdDozNg==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1.jpg",
"altText": ""
},
{
"id": "cG9zdDozNw==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg",
"altText": ""
}
]
}
}
},
"variation": {
"node": {
"id": "cHJvZHVjdF92YXJpYXRpb246MjU="
}
},
"quantity": 5,
"total": "225 ₫",
"subtotal": "225 ₫",
"subtotalTax": "0 ₫"
}
}
}
}
query GET_CART {
cart {
contents {
nodes {
product {
node {
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
srcSet
altText
title
}
galleryImages {
nodes {
id
sourceUrl
srcSet
altText
title
}
}
}
}
}
}
}
}
{
"data": {
"cart": {
"contents": {
"nodes": [
{
"product": {
"node": {
"databaseId": 7,
"name": "Hoodie",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"type": "VARIABLE",
"onSale": false,
"slug": "hoodie-2",
"averageRating": 0,
"reviewCount": 0,
"image": {
"id": "cG9zdDozNA==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg 801w",
"altText": "",
"title": "hoodie-2.jpg"
},
"galleryImages": {
"nodes": [
{
"id": "cG9zdDozNQ==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1.jpg 800w",
"altText": "",
"title": "hoodie-blue-1.jpg"
},
{
"id": "cG9zdDozNg==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1.jpg 800w",
"altText": "",
"title": "hoodie-green-1.jpg"
},
{
"id": "cG9zdDozNw==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg 801w",
"altText": "",
"title": "hoodie-with-logo-2.jpg"
}
]
}
}
}
},
{
"product": {
"node": {
"databaseId": 7,
"name": "Hoodie",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"type": "VARIABLE",
"onSale": false,
"slug": "hoodie-2",
"averageRating": 0,
"reviewCount": 0,
"image": {
"id": "cG9zdDozNA==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg 801w",
"altText": "",
"title": "hoodie-2.jpg"
},
"galleryImages": {
"nodes": [
{
"id": "cG9zdDozNQ==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-blue-1.jpg 800w",
"altText": "",
"title": "hoodie-blue-1.jpg"
},
{
"id": "cG9zdDozNg==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-green-1.jpg 800w",
"altText": "",
"title": "hoodie-green-1.jpg"
},
{
"id": "cG9zdDozNw==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg",
"srcSet": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-300x300.jpg 300w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-150x150.jpg 150w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-768x768.jpg 768w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-324x324.jpg 324w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-416x416.jpg 416w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2-100x100.jpg 100w, https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg 801w",
"altText": "",
"title": "hoodie-with-logo-2.jpg"
}
]
}
}
}
}
]
}
}
}
}
Step 4.3 giải thích cách tạo cart không cần đăng nhập
const [addToCart, { loading: addToCartLoading }] = useMutation(ADD_TO_CART, {
variables: {
input: productQueryInput,
},
onCompleted: () => {
refetch();
},
onError: (err) => {
console.log(err)
setRequestError(true);
}
});
const handleAddToCart = () => {
addToCart();
refetch();
};
return(
<>
<Button
handleButtonClick={() => handleAddToCart()}
buttonDisabled={addToCartLoading || requestError || isCartLoading}
fullWidth={fullWidth}
>
{isCartLoading ? 'Loading...' : 'BUY'}
</Button>
</>
)
Chú ý: Mặc định hàm useQuery nó sẽ hoạt động mỗi lần render
const {data,refetch} = useQuery(GET_CART,{
notifyOnNetworkStatusChange: true,
onCompleted: () => {
console.log(data);
const updatedCart = getFormattedCart(data);
if (!updatedCart) {
return;
}
console.log(updatedCart);
localStorage.setItem('woocommerce-cart', JSON.stringify(updatedCart));
// Update cart data in React Context.
setCart(updatedCart);
},
onError: () => {
console.log("onError1");
}
});
— prop buttonDisabled này rất quan trọng nó giúp hoạt động thêm được vào giỏ hàng khi đã được sẵn sàng không sảy ra lỗi
return(
<>
<Button
handleButtonClick={() => handleAddToCart()}
buttonDisabled={addToCartLoading || requestError || isCartLoading}
fullWidth={fullWidth}
>
{isCartLoading ? 'Loading...' : 'BUY'}
</Button>
</>
)
Toàn bộ code src\components\Product\AddToCart.component.tsx
import { useContext, useState } from 'react';
import { useQuery, useMutation } from '@apollo/client';
import { v4 as uuidv4 } from 'uuid';
import { ADD_TO_CART } from '@/utils/gql/GQL_MUTATIONS';
import Button from '@/components/UI/Button.component';
import { GET_CART } from '@/utils/gql/GQL_QUERIES';
import { getFormattedCart } from '@/utils/functions/functions';
import { CartContext } from '@/stores/CartProvider';
const AddToCart = ({ product, variationId, fullWidth = false}: IProductRootObject) => {
const { setCart, isLoading: isCartLoading } = useContext(CartContext);
const [requestError, setRequestError] = useState<boolean>(false);
const productId = product?.databaseId ? product?.databaseId : variationId;
const productQueryInput = {
clientMutationId: uuidv4(),
productId,
};
const {data,refetch} = useQuery(GET_CART,{
notifyOnNetworkStatusChange: true,
onCompleted: () => {
console.log(data);
const updatedCart = getFormattedCart(data);
if (!updatedCart) {
return;
}
localStorage.setItem('woocommerce-cart', JSON.stringify(updatedCart));
// Update cart data in React Context.
setCart(updatedCart);
},
onError: (err) => {
console.log(err);
}
});
const [addToCart, { loading: addToCartLoading }] = useMutation(ADD_TO_CART, {
variables: {
input: productQueryInput,
},
onCompleted: () => {
refetch();
},
onError: (err) => {
console.log(err)
setRequestError(true);
}
});
const handleAddToCart = () => {
addToCart();
refetch();
};
return(
<>
<Button
handleButtonClick={() => handleAddToCart()}
buttonDisabled={addToCartLoading || requestError || isCartLoading}
fullWidth={fullWidth}
>
{isCartLoading ? 'Loading...' : 'BUY'}
</Button>
</>
)
}
export default AddToCart;
--— Và một điều nữa khi client hoạt động nó sẽ tạo ra một key : woocommerce-session để làm việc với session
Toàn bộ code src\utils\apollo\ApolloClient.js
import { ApolloLink, ApolloClient,createHttpLink,InMemoryCache} from '@apollo/client';
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
export const middleware = new ApolloLink((operation, forward) => {
const sessionData = typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('woo-session')) : null;
if (sessionData && sessionData.token && sessionData.createdTime) {
const { token, createdTime } = sessionData;
if (Date.now() - createdTime > SEVEN_DAYS) {
localStorage.removeItem('woo-session');
localStorage.setItem('woocommerce-cart', JSON.stringify({}));
}else {
operation.setContext(() => ({
headers: {
'woocommerce-session': `Session ${token}`,
},
}))
}
}
return forward(operation);
});
export const afterware = new ApolloLink((operation, forward) =>
forward(operation).map((response) => {
const context = operation.getContext();
const {response: { headers }} = context;
const session = headers.get('woocommerce-session');
if (session && typeof window !== 'undefined') {
if ('false' === session) {
localStorage.removeItem('woo-session');
} else if (!localStorage.getItem('woo-session')) {
localStorage.setItem('woo-session',JSON.stringify({ token: session, createdTime: Date.now() }));
}
}
return response;
})
);
const clientSide = typeof window === 'undefined';
const client = new ApolloClient({
ssrMode: clientSide,
link: middleware.concat(
afterware.concat(
createHttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
fetch,
})
)
),
cache: new InMemoryCache(),
});
export default client;
mutation {
addToCart(input: {productId: 11}) {
cartItem {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
databaseId
name
description
type
onSale
price
regularPrice
salePrice
image {
id
sourceUrl
altText
}
attributes {
nodes {
id
attributeId
name
value
}
}
}
}
quantity
total
subtotal
subtotalTax
}
}
}
{
"data": {
"addToCart": {
"cartItem": {
"key": "6512bd43d9caa6e02c990b0a82652dca",
"product": {
"node": {
"id": "cHJvZHVjdDoxMQ==",
"databaseId": 11,
"name": "Belt",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"type": "SIMPLE",
"onSale": true,
"slug": "belt-2",
"averageRating": 0,
"reviewCount": 0,
"image": {
"id": "cG9zdDo0MA==",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/belt-2.jpg",
"altText": ""
},
"galleryImages": {
"nodes": []
}
}
},
"variation": null,
"quantity": 1,
"total": "55 ₫",
"subtotal": "55 ₫",
"subtotalTax": "0 ₫"
}
}
}
}
mutation {
updateItemQuantities(
input: {items: {key: "6512bd43d9caa6e02c990b0a82652dca", quantity: 10}}
) {
cart {
contents {
nodes {
product {
node {
content
}
}
quantity
}
}
}
}
}
{
"data": {
"updateItemQuantities": {
"cart": {
"contents": {
"nodes": [
{
"product": {
"node": {
"content": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n"
}
},
"quantity": 10
}
]
}
}
}
}
}
src\pages\shopping-cart.tsx
import Layout from '@/components/Layout/Layout.component';
import CartContents from '@/components/Cart/CartContents.component';
import { NextPage } from 'next';
const ShopCart: NextPage = () => (
<Layout title="Shop Cart">
<CartContents />
</Layout>
);
export default ShopCart;
src\components\Cart\CartContents.component.tsx
import { useContext, useEffect } from 'react';
import { useQuery, useMutation } from '@apollo/client';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { v4 as uuidv4 } from 'uuid';
import { CartContext } from '@/stores/CartProvider';
import Button from '@/components/UI/Button.component';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component';
import { GET_CART } from '@/utils/gql/GQL_QUERIES';
import { getFormattedCart, getUpdatedItems, handleQuantityChange } from '@/utils/functions/functions';
import { UPDATE_CART } from '@/utils/gql/GQL_MUTATIONS';
const CartContents = () => {
const router = useRouter();
const { setCart } = useContext(CartContext);
const isCheckoutPage = router.pathname === '/checkout';
const { data, refetch } = useQuery(GET_CART, {
notifyOnNetworkStatusChange: true,
onCompleted: () => {
const updatedCart = getFormattedCart(data);
if (!updatedCart && !data.cart.contents.nodes.length) {
localStorage.removeItem('woocommerce-cart');
setCart(null);
return;
}
localStorage.setItem('woocommerce-cart', JSON.stringify(updatedCart));
setCart(updatedCart);
},
});
const [updateCart, { loading: updateCartProcessing }] = useMutation(
UPDATE_CART,
{
onCompleted: () => {
refetch();
setTimeout(() => {
refetch();
}, 3000);
},
},
);
const handleRemoveProductClick = (
cartKey: string,
products: IProductUpdateRootObject[],
) => {
if (products?.length) {
const updatedItems = getUpdatedItems(products, 0, cartKey);
updateCart({
variables: {
input: {
clientMutationId: uuidv4(),
items: updatedItems,
},
},
});
}
refetch();
setTimeout(() => {
refetch();
}, 3000);
};
useEffect(() => {
refetch();
}, [refetch]);
const cartTotal = data?.cart?.total || '0';
const getUnitPrice = (subtotal: string, quantity: number) => {
const numericSubtotal = parseFloat(subtotal.replace(/[^0-9.-]+/g, ''));
return isNaN(numericSubtotal) ? 'N/A' : (numericSubtotal / quantity).toFixed(2);
};
return (
<div className="container mx-auto px-4 py-8">
{
data?.cart?.contents?.nodes?.length ? (
<>
<div className=" rounded-lg p-6 mb-8 md:w-full">
{
data.cart.contents.nodes.map((item: IProductCartRootObject,index:number) => {
return (
<div key={index} className="flex items-center border-b border-gray-200 py-4">
<div className="flex-shrink-0 w-24 h-24 relative hidden md:block">
<Image
src={item.product.node.image?.sourceUrl}
alt={item.product.node.name}
layout="fill"
objectFit="cover"
className="rounded"
/>
</div>
<div className="flex-grow ml-4">
<h2 className="text-lg font-semibold">
{item.product.node.name}
</h2>
<p className="text-gray-600">
{getUnitPrice(item.subtotal, item.quantity)} đ
</p>
</div>
<div className="flex items-center">
<input
type="number"
min="1"
value={item.quantity}
onChange={(event) => {
handleQuantityChange(event,item.key,data.cart.contents.nodes,updateCart,updateCartProcessing);
}}
className="w-16 px-2 py-1 text-center border border-gray-300 rounded mr-2"
/>
<Button
handleButtonClick={() =>
handleRemoveProductClick(item.key,data.cart.contents.nodes)
}
variant="secondary"
buttonDisabled={updateCartProcessing}
>
Delete
</Button>
</div>
</div>
)
})
}
</div>
<div className="rounded-lg p-6 md:w-full">
A2
</div>
</>
) : (
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">
No products in the cart.
</h2>
<Link href="/shop" passHref>
<Button variant="primary">Continue shopping</Button>
</Link>
</div >
)
}
</div >
);
}
export default CartContents;
ty
src\utils\functions\functions.ts
import { ChangeEvent } from "react";
import { v4 as uuidv4 } from 'uuid';
export const paddedPrice = (price: string, symbol: string) => price.split(symbol).join(` `);
export const filteredVariantPrice = (price: string, side: string) => {
if ('right' === side) {
return price.substring(price.length, price.indexOf('-')).replace('-', '');
}
return price.substring(0, price.indexOf('-')).replace('-', '');
};
export const getFormattedCart = (data: any) => {
const formattedCart: any = {
products: [],
totalProductsCount: 0,
totalProductsPrice: 0,
};
if (!data) {
return;
}
const givenProducts = data.cart.contents.nodes;
// Create an empty object.
formattedCart.products = [];
const product: Product = {
productId: 0,
cartKey: '',
name: '',
qty: 0,
price: 0,
totalPrice: '0',
image: { sourceUrl: '', srcSet: '', title: '' },
};
let totalProductsCount = 0;
let i = 0;
if (!givenProducts.length) {
return;
}
givenProducts.forEach(() => {
const givenProduct = givenProducts[Number(i)].product.node;
// Convert price to a float value
const convertedCurrency = givenProducts[Number(i)].total.replace(/[^0-9.-]+/g,'');
product.productId = givenProduct.productId;
product.cartKey = givenProducts[Number(i)].key;
product.name = givenProduct.name;
product.qty = givenProducts[Number(i)].quantity;
product.price = Number(convertedCurrency) / product.qty;
product.totalPrice = givenProducts[Number(i)].total;
// Ensure we can add products without images to the cart
product.image = givenProduct.image.sourceUrl
? { sourceUrl: givenProduct.image.sourceUrl, srcSet: givenProduct.image.srcSet, title: givenProduct.image.title, }
: { sourceUrl: process.env.NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL, srcSet: process.env.NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL, title: givenProduct.name };
totalProductsCount += givenProducts[Number(i)].quantity;
// Push each item into the products array.
formattedCart.products.push(product);
i++;
});
formattedCart.totalProductsCount = totalProductsCount;
formattedCart.totalProductsPrice = data.cart.total;
return formattedCart;
};
export const getUpdatedItems = (products: IProductUpdateRootObject[], newQty: number, cartKey: string,) => {
// Create an empty array.
const updatedItems: TUpdatedItems = [];
// Loop through the product array.
products.forEach((cartItem) => {
// If you find the cart key of the product user is trying to update, push the key and new qty.
if (cartItem.key === cartKey) {
updatedItems.push({
key: cartItem.key,
quantity: newQty,
});
// Otherwise just push the existing qty without updating.
} else {
updatedItems.push({
key: cartItem.key,
quantity: cartItem.quantity,
});
}
});
// Return the updatedItems array with new Qtys.
return updatedItems;
};
export const handleQuantityChange = (
event: ChangeEvent<HTMLInputElement>,
cartKey: string,
cart: IProductUpdateRootObject[],
updateCart: (variables: IUpdateCartRootObject) => void,
updateCartProcessing: boolean,
) => {
if (typeof window !== 'undefined') {
event.stopPropagation();
// Return if the previous update cart mutation request is still processing
if (updateCartProcessing || !cart) {
return;
}
// If the user tries to delete the count of product, set that to 1 by default ( This will not allow him to reduce it less than zero )
const newQty = event.target.value ? parseInt(event.target.value, 10) : 1;
if (cart.length) {
const updatedItems = getUpdatedItems(cart, newQty, cartKey);
updateCart({
variables: {
input: {
clientMutationId: uuidv4(),
items: updatedItems,
},
},
});
}
}
};
src\utils\gql\GQL_MUTATIONS.ts
import { gql } from "@apollo/client";
export const ADD_TO_CART = gql`
mutation ($input: AddToCartInput!) {
addToCart(input: $input) {
cartItem {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
databaseId
name
description
type
onSale
price
regularPrice
salePrice
image {
id
sourceUrl
altText
}
attributes {
nodes {
id
attributeId
name
value
}
}
}
}
quantity
total
subtotal
subtotalTax
}
}
}
`;
export const UPDATE_CART = gql`
mutation ($input: UpdateItemQuantitiesInput!) {
updateItemQuantities(input: $input) {
items {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
databaseId
name
description
type
onSale
price
regularPrice
salePrice
image {
id
sourceUrl
altText
}
attributes {
nodes {
id
attributeId
name
value
}
}
}
}
quantity
total
subtotal
subtotalTax
}
updated {
key
product {
node {
id
databaseId
}
}
variation {
node {
id
databaseId
}
}
}
}
}
`;
Step 5.2 Subtotal && Link Checkout
src\components\Cart\CartContents.component.tsx
import { useContext, useEffect } from 'react';
import { useQuery, useMutation } from '@apollo/client';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { v4 as uuidv4 } from 'uuid';
import { CartContext } from '@/stores/CartProvider';
import Button from '@/components/UI/Button.component';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component';
import { GET_CART } from '@/utils/gql/GQL_QUERIES';
import { getFormattedCart, getUpdatedItems, handleQuantityChange, paddedPrice } from '@/utils/functions/functions';
import { UPDATE_CART } from '@/utils/gql/GQL_MUTATIONS';
const CartContents = () => {
const router = useRouter();
const { setCart } = useContext(CartContext);
const isCheckoutPage = router.pathname === '/checkout';
const { data, refetch } = useQuery(GET_CART, {
notifyOnNetworkStatusChange: true,
onCompleted: () => {
const updatedCart = getFormattedCart(data);
if (!updatedCart && !data.cart.contents.nodes.length) {
localStorage.removeItem('woocommerce-cart');
setCart(null);
return;
}
localStorage.setItem('woocommerce-cart', JSON.stringify(updatedCart));
setCart(updatedCart);
},
});
const [updateCart, { loading: updateCartProcessing }] = useMutation(
UPDATE_CART,
{
onCompleted: () => {
refetch();
setTimeout(() => {
refetch();
}, 3000);
},
},
);
const handleRemoveProductClick = (
cartKey: string,
products: IProductUpdateRootObject[],
) => {
if (products?.length) {
const updatedItems = getUpdatedItems(products, 0, cartKey);
updateCart({
variables: {
input: {
clientMutationId: uuidv4(),
items: updatedItems,
},
},
});
}
refetch();
setTimeout(() => {
refetch();
}, 3000);
};
useEffect(() => {
refetch();
}, [refetch]);
let cartTotal = data?.cart?.total || '0';
if (cartTotal) {
cartTotal = paddedPrice(cartTotal, ' ');
}
const getUnitPrice = (subtotal: string, quantity: number) => {
const numericSubtotal = parseFloat(subtotal.replace(/[^0-9.-]+/g, ''));
return isNaN(numericSubtotal) ? 'N/A' : (numericSubtotal / quantity).toFixed(2);
};
return (
<div className="container mx-auto px-4 py-8">
{
data?.cart?.contents?.nodes?.length ? (
<>
<div className=" rounded-lg p-6 mb-8 md:w-full">
{
data.cart.contents.nodes.map((item: IProductCartRootObject,index:number) => {
return (
<div key={index} className="flex items-center border-b border-gray-200 py-4">
<div className="flex-shrink-0 w-24 h-24 relative hidden md:block">
<Image
src={item.product.node.image?.sourceUrl}
alt={item.product.node.name}
layout="fill"
objectFit="cover"
className="rounded"
/>
</div>
<div className="flex-grow ml-4">
<h2 className="text-lg font-semibold">
{item.product.node.name}
</h2>
<p className="text-gray-600">
{getUnitPrice(item.subtotal, item.quantity)} đ
</p>
</div>
<div className="flex items-center">
<input
type="number"
min="1"
value={item.quantity}
onChange={(event) => {
handleQuantityChange(event,item.key,data.cart.contents.nodes,updateCart,updateCartProcessing);
}}
className="w-16 px-2 py-1 text-center border border-gray-300 rounded mr-2"
/>
<Button
handleButtonClick={() =>
handleRemoveProductClick(item.key,data.cart.contents.nodes)
}
variant="secondary"
buttonDisabled={updateCartProcessing}
>
Delete
</Button>
</div>
</div>
)
})
}
</div>
<div className="rounded-lg p-6 md:w-full">
<div className="flex justify-end mb-4">
<span className="font-semibold pr-2">Subtotal:</span>
<span>{cartTotal}</span>
</div>
</div>
{
!isCheckoutPage && (
<div className="flex justify-end mb-4">
<Link href="/checkout" passHref>
<Button variant="primary" fullWidth>CHECKOUT</Button>
</Link>
</div>
)
}
</>
) : (
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">
No products in the cart.
</h2>
<Link href="/shop" passHref>
<Button variant="primary">Continue shopping</Button>
</Link>
</div >
)
}
</div >
);
}
export default CartContents;
Đọc bài viết này https://learnreact.gitbook.io/learnreact/advanced-reactjs/cach-khai-bao-cac-kieu-chung-global-cho-cac-du-an-react-typescript-ok
globals.d.ts
import { FieldValues, UseFormRegister } from 'react-hook-form';
import {SubmitHandler} from 'react-hook-form';
declare global {
interface ILayoutProps {
children?: ReactNode;
title: string;
}
interface IImage {
__typename: string;
uri: string;
title: string;
srcSet: string;
sourceUrl: string;
}
interface IVariationNode {
__typename: string;
name: string;
}
interface IAllPaColors {
__typename: string;
nodes: IVariationNode[];
}
interface IAllPaSizes {
__typename: string;
nodes: IVariationNode[];
}
interface IVariationNodes {
__typename: string;
id: string;
databaseId: number;
name: string;
image?: string;
stockStatus: string;
stockQuantity: number;
purchasable: boolean;
onSale: boolean;
salePrice?: string;
regularPrice: string;
}
interface IVariations {
__typename: string;
nodes: IVariationNodes[];
}
interface IProduct {
__typename: string;
databaseId: number;
name: string;
description: string;
slug: string;
averageRating: number;
onSale: boolean;
image: IImage;
price: string;
regularPrice: string;
salePrice?: string;
allPaColors?: IAllPaColors;
allPaSizes?: IAllPaSizes;
variations?: IVariations;
stockQuantity: number;
}
interface IProductRootObject {
product: IProduct;
variationId?: number;
fullWidth?: boolean;
}
interface IProductCart {
__typename: string;
node: IProductNode;
}
interface IProductCartRootObject {
__typename: string;
key: string;
product: IProductCart;
variationId?: number;
fullWidth?: boolean;
subtotal: string;
quantity: number;
}
interface IButtonProps {
handleButtonClick?: () => void;
buttonDisabled?: boolean;
variant?: TButtonVariant;
children?: ReactNode;
fullWidth?: boolean;
href?: string;
title?: string;
selected?: boolean;
}
type TButtonVariant = 'primary' | 'secondary' | 'hero' | 'filter' | 'reset';
interface ICartProviderProps {
children: React.ReactNode;
}
interface Image {
sourceUrl?: string;
srcSet?: string;
title: string;
}
interface Product {
cartKey: string;
name: string;
qty: number;
price: number;
totalPrice: string;
image: Image;
productId: number;
}
interface RootObject {
products: Product[];
totalProductsCount: number;
totalProductsPrice: number;
}
type TRootObject = RootObject | string | null | undefined;
type TRootObjectNull = RootObject | null | undefined;
interface ICartContext {
cart: RootObject | null | undefined;
setCart: React.Dispatch<React.SetStateAction<TRootObjectNull>>;
updateCart: (newCart: RootObject) => void;
isLoading: boolean;
}
interface ICartProps {
stickyNav?: boolean;
}
interface IImage {
__typename: string;
id: string;
sourceUrl?: string;
srcSet?: string;
altText: string;
title: string;
}
interface IGalleryImages {
__typename: string;
nodes: IImage[];
}
interface IProductNode {
__typename: string;
id: string;
databaseId: number;
name: string;
description: string;
type: string;
onSale: boolean;
slug: string;
averageRating: number;
reviewCount: number;
image: IImage;
galleryImages: IGalleryImages;
productId: number;
}
interface IProduct {
__typename: string;
node: IProductNode;
}
interface IProductUpdateRootObject {
__typename: string;
key: string;
product: IProduct;
variation?: IVariationNodes;
quantity: number;
total: string;
subtotal: string;
subtotalTax: string;
}
type TUpdatedItems = { key: string; quantity: number }[];
interface IUpdateCartItem {
key: string;
quantity: number;
}
interface IUpdateCartInput {
clientMutationId: string;
items: IUpdateCartItem[];
}
interface IUpdateCartVariables {
input: IUpdateCartInput;
}
interface IUpdateCartRootObject {
variables: IUpdateCartVariables;
}
interface IFormattedCartProps {
cart: {
contents: {
nodes: IProductUpdateRootObject[]
};
total: number
};
}
interface ICheckoutDataProps {
firstName: string;
lastName: string;
address1: string;
address2: string;
city: string;
country: string;
state: string;
postcode: string;
email: string;
phone: string;
company: string;
paymentMethod: string;
}
interface IBilling {
firstName: string;
lastName: string;
address1: string;
city: string;
postcode: string;
email: string;
phone: string;
}
interface IShipping {
firstName: string;
lastName: string;
address1: string;
city: string;
postcode: string;
email: string;
phone: string;
}
interface ICheckoutData {
clientMutationId: string;
billing: IBilling;
shipping: IShipping;
shipToDifferentAddress: boolean;
paymentMethod: string;
isPaid: boolean;
transactionId: string;
}
interface ICustomValidation {
required?: boolean;
minlength?: number;
}
interface IErrors { }
interface IInputRootObject {
inputLabel: string;
inputName: string;
customValidation: ICustomValidation;
errors?: IErrors;
register?: UseFormRegister<FieldValues>;
type?: string;
}
interface IBillingProps {
handleFormSubmit: SubmitHandler<ICheckoutDataProps>;
}
interface ImageDP {
__typename: string;
sourceUrl: string;
}
interface NodeDP {
__typename: string;
price: string;
regularPrice: string;
salePrice?: string;
}
interface VariationsDP {
__typename: string;
nodes: NodeDP[];
}
interface RootObjectDP {
__typename: string;
databaseId: number;
name: string;
slug: string;
image: ImageDP;
price: string;
regularPrice: string;
salePrice?: string;
onSale: boolean;
variations: VariationsDP;
}
interface IDisplayProductsProps {
products: RootObjectDP[];
}
}
export {};
Một mẫu query checkout
mutation-order.js
mutation {
checkout(
input:
{
billing: {
address1: "Tòa S3, Skylake Vinhomes Phạm Hùng, Nam Từ Liêm",
address2: "Phung Hung Ha Dong",
city: "Ha Noi",
company: "Ha Noi",
country: VN,
email: "phamngoctuong1805@gmail.com",
firstName: "Lionel", phone: "0914748166",
postcode: "10000",
lastName: "Tưởng", state: ""
},
paymentMethod: "cod"
}
)
{
clientMutationId
redirect
result
}
}
// ==
{
"data": {
"checkout": {
"clientMutationId": null,
"redirect": "https://wpclidemo.dev/checkout/order-received/1876/?key=wc_order_Egg0qI4BJiKzJ",
"result": "success"
}
}
}
Còn đây là kết quả thanh toán ở website
src\utils\functions\functions.ts
export const createCheckoutData = (order: ICheckoutDataProps) => ({
clientMutationId: uuidv4(),
billing: {
firstName: order.firstName,
lastName: order.lastName,
address1: order.address1,
address2: order.address2,
city: order.city,
country: order.country,
state: order.state,
postcode: order.postcode,
email: order.email,
phone: order.phone,
company: order.company,
},
shipping: {
firstName: order.firstName,
lastName: order.lastName,
address1: order.address1,
address2: order.address2,
city: order.city,
country: order.country,
state: order.state,
postcode: order.postcode,
email: order.email,
phone: order.phone,
company: order.company,
},
shipToDifferentAddress: false,
paymentMethod: order.paymentMethod,
isPaid: false,
transactionId: '123456',
})
datatest-graphql.txt
mutation {
checkout(
input:
{
billing: {
address1: "Tòa S3, Skylake Vinhomes Phạm Hùng, Nam Từ Liêm",
address2: "Phung Hung Ha Dong",
city: "Ha Noi",
company: "Ha Noi",
country: VN,
email: "phamngoctuong1805@gmail.com",
firstName: "Lionel", phone: "0914748166",
postcode: "10000",
lastName: "Tưởng", state: ""
},
paymentMethod: "cod"
}
)
{
clientMutationId
redirect
result
}
}
{
"data": {
"checkout": {
"clientMutationId": null,
"redirect": "https://wpclidemo.dev/checkout/order-received/1876/?key=wc_order_Egg0qI4BJiKzJ",
"result": "success"
}
}
}
src\utils\gql\GQL_MUTATIONS.ts
import { gql } from "@apollo/client";
export const ADD_TO_CART = gql`
mutation ($input: AddToCartInput!) {
addToCart(input: $input) {
cartItem {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
databaseId
name
description
type
onSale
price
regularPrice
salePrice
image {
id
sourceUrl
altText
}
attributes {
nodes {
id
attributeId
name
value
}
}
}
}
quantity
total
subtotal
subtotalTax
}
}
}
`;
export const UPDATE_CART = gql`
mutation ($input: UpdateItemQuantitiesInput!) {
updateItemQuantities(input: $input) {
items {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl
altText
}
}
}
}
variation {
node {
id
databaseId
name
description
type
onSale
price
regularPrice
salePrice
image {
id
sourceUrl
altText
}
attributes {
nodes {
id
attributeId
name
value
}
}
}
}
quantity
total
subtotal
subtotalTax
}
updated {
key
product {
node {
id
databaseId
}
}
variation {
node {
id
databaseId
}
}
}
}
}
`;
export const CHECKOUT_MUTATION = gql`
mutation($input: CheckoutInput!) {
checkout(input: $input) {
result
redirect
}
}
`;
src\utils\constants\INPUT_FIELDS.ts
export const INPUT_FIELDS = [
{
id: 1,
label: 'First name',
name: 'firstName',
customValidation: { required: true, minLength: 4 },
},
{
id: 2,
label: 'Last Name',
name: 'lastName',
customValidation: { required: true, minLength: 4 },
},
{
id: 3,
label: 'Address 1',
name: 'address1',
customValidation: { required: true, minLength: 4 },
},
{
id: 4,
label: 'Address 2',
name: 'address2',
customValidation: { required: true, minLength: 4 },
},
{
id: 5,
label: 'City',
name: 'city',
customValidation: { required: true, minLength: 2 },
},
{
id: 6,
label: 'Country',
name: 'country',
customValidation: { required: true, minLength: 2 },
},
{
id: 7,
label: 'State',
name: 'state',
customValidation: { required: true, minLength: 2 },
},
{
id: 8,
label: 'Postnummer',
name: 'postcode',
customValidation: { required: true, minLength: 4, pattern: '[+0-9]{4,6}' },
},
{
id: 9,
label: 'Email',
name: 'email',
customValidation: { required: true, type: 'email' },
},
{
id: 10,
label: 'Phone',
name: 'phone',
customValidation: { required: true, minLength: 8, pattern: '[+0-9]{8,12}' },
},
{
id: 11,
label: 'Company',
name: 'company',
customValidation: { required: true, minLength: 2 },
}
];
console.log(orderData); 👇
{
"clientMutationId": "addd838e-07a6-4137-834b-a63ce6dd2580",
"billing": {
"firstName": "Phạm",
"lastName": "Tưởng",
"address1": "Phung Hung Ha Dong",
"address2": "Phung Hung Ha Dong",
"city": "Ha Noi",
"country": "VN",
"state": "Ga",
"postcode": "10000",
"email": "phamngoctuong1805@gmail.com",
"phone": "0914748166",
"company": "Ha Noi"
},
"shipping": {
"firstName": "Phạm",
"lastName": "Tưởng",
"address1": "Phung Hung Ha Dong",
"address2": "Phung Hung Ha Dong",
"city": "Ha Noi",
"country": "VN",
"state": "Ga",
"postcode": "10000",
"email": "phamngoctuong1805@gmail.com",
"phone": "0914748166",
"company": "Ha Noi"
},
"shipToDifferentAddress": false,
"paymentMethod": "cod",
"isPaid": false,
"transactionId": "123456"
}
src\components\Layout\Layout.component.tsx
import { ReactNode, useContext, useEffect } from 'react';
import { useQuery } from '@apollo/client';
import Header from '../Header/Header.component';
import PageTitle from './PageTitle.component';
import { CartContext } from '@/stores/CartProvider';
import { GET_CART } from '@/utils/gql/GQL_QUERIES';
import { getFormattedCart } from '@/utils/functions/functions';
const Layout = ({ children, title }: ILayoutProps) => {
const { setCart } = useContext(CartContext);
const { data, refetch } = useQuery(GET_CART, {
notifyOnNetworkStatusChange: true,
onCompleted: () => {
const updatedCart = getFormattedCart(data);
if (!updatedCart && !data?.cart?.contents?.nodes.length) {
// Should we clear the localStorage if we have no remote cart?
return;
}
localStorage.setItem('woocommerce-cart', JSON.stringify(updatedCart));
setCart(updatedCart);
}
});
useEffect(() => {
refetch();
}, [refetch]);
return (
<div className="flex flex-col min-h-screen w-full mx-auto">
<Header title={title} />
<div className="container mx-auto px-6 flex-1">
<PageTitle title={title} />
<main>{children}</main>
</div>
</div>
)
};
export default Layout;
src\pages\shop.tsx
import Head from 'next/head';
import Layout from '@/components/Layout/Layout.component';
import ProductList from '@/components/Product/ProductList.component';
import client from '@/utils/apollo/ApolloClient';
import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES';
import type { NextPage, GetStaticProps, InferGetStaticPropsType } from 'next';
const Shop: NextPage = ({
products,
loading,
}: InferGetStaticPropsType<typeof getStaticProps>) => {
if (loading)
return (
<Layout title="Products">
<div className="flex justify-center items-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-gray-900"></div>
</div>
</Layout>
);
if (!products)
return (
<Layout title="Products">
<div className="flex justify-center items-center min-h-screen">
<p className="text-red-500">No products found</p>
</div>
</Layout>
);
return (
<Layout title="Products">
<Head>
<title>Products | WooCommerce Next.js</title>
</Head>
<div className="container mx-auto px-4 py-8">
<ProductList products={products} title="Men's clothing" />
</div>
</Layout>
);
};
export default Shop;
export const getStaticProps: GetStaticProps = async () => {
const { data, loading, networkStatus } = await client.query({
query: FETCH_ALL_PRODUCTS_QUERY,
});
return {
props: {
products: data.products.nodes,
loading,
networkStatus,
},
revalidate: 60,
};
};
src\components\Product\ProductList.component.tsx
const ProductList = ({ products, title }: ProductListProps) => {
return (
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8">
<h1 className="text-xl sm:text-2xl font-medium text-center sm:text-left">
{title} <span className="text-gray-500"></span>
</h1>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-2 sm:gap-4">
<label htmlFor="sort-select" className="text-sm font-medium">Sortering:</label>
<select
id="sort-select"
value={"desc"}
className="min-w-[140px] border rounded-md px-3 py-1.5 text-sm"
>
<option value="popular">Popular</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="newest">Newest</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
ProductCard
</div>
</div>
</div>
)
}
export default ProductList;
src\components\Product\ProductList.component.tsx
import { useProductFilters } from "@/hooks/useProductFilters";
import ProductCard from "./ProductCard.component";
const ProductList = ({ products, title }: ProductListProps) => {
const {filterProducts} = useProductFilters(products);
const filteredProducts = filterProducts(products);
return (
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8">
<h1 className="text-xl sm:text-2xl font-medium text-center sm:text-left">
{title} <span className="text-gray-500">({filteredProducts.length})</span>
</h1>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-2 sm:gap-4">
<label htmlFor="sort-select" className="text-sm font-medium">Sortering:</label>
<select
id="sort-select"
value={"desc"}
className="min-w-[140px] border rounded-md px-3 py-1.5 text-sm"
>
<option value="popular">Popular</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="newest">Newest</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product: Product) => (
<ProductCard
key={product.databaseId}
databaseId={product.databaseId}
name={product.name}
price={product.price}
slug={product.slug}
image={product.image}
/>
))}
</div>
</div>
</div>
)
}
export default ProductList;
src\components\Product\ProductCard.component.tsx
import Link from 'next/link';
import Image from 'next/image';
const ProductCard = ({ databaseId, name, price, slug, image, }: ProductCardProps) => {
return (
<div className="group">
ProductCard
</div>
)
}
export default ProductCard;
src\hooks\useProductFilters.ts
import { useState } from 'react';
export const useProductFilters = (products: Product[]) => {
const filterProducts = (products: Product[]) => {
const filtered = products?.filter((product: Product) => {
return true;
});
return [...(filtered || [])].sort((a, b) => {
return 0;
})
};
return {
filterProducts
}
}