🫢Sử dụng Nextjs kết nối store woocomerce, category product, graphql Example 1 (ok)

Khai báo ... on SimpleProduct, ... on VariableProduct, ... on ExternalProduct, ... on VariableProduct xem ở đây nhé!

Khai báo mutation addToCart

Source wpclidemo-dev-graphql-woocomerce

Document

Step 1.1: Create Layout && ApolloClient

next.config.ts

import type { NextConfig } from "next";
const nextConfig: NextConfig = {
  /* config options here */
  reactStrictMode: true,
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'swewoocommerce.dfweb.no',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'via.placeholder.com',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'funtapgames.com',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'wpclidemo.dev',
        pathname: '**',
      },
    ],
  },
};
export default nextConfig;

.env

NEXT_PUBLIC_GRAPHQL_URL="https://wpclidemo.dev/graphql"
NEXT_PUBLIC_ALGOLIA_INDEX_NAME= "algolia"
NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL="https://res.cloudinary.com/placeholder-337_utsb7h.jpg"
NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL="https://via.placeholder.com/600"
NEXT_PUBLIC_ALGOLIA_APP_ID = "changeme"
NEXT_PUBLIC_ALGOLIA_PUBLIC_API_KEY = "changeme"
NODE_TLS_REJECT_UNAUTHORIZED=0
NODE_ENV="development"

src\utils\gql\GQL_QUERIES.ts

import { gql } from '@apollo/client';
export const FETCH_ALL_PRODUCTS_QUERY = gql`
  query MyQuery {
    products(first: 50) {
      nodes {
        databaseId
        name
        onSale
        slug
        image {
          sourceUrl
        }
        productCategories {
          nodes {
            name
            slug
          }
        }
        ... on SimpleProduct {
          databaseId
          price
          regularPrice
          salePrice
        }
        ... on VariableProduct {
          databaseId
          price
          regularPrice
          salePrice
          allPaColors {
            nodes {
              name
              slug
            }
          }
          allPaSizes {
            nodes {
              name
            }
          }
          variations {
            nodes {
              price
              regularPrice
              salePrice
              attributes {
                nodes {
                  name
                  value
                }
              }
            }
          }
        }
      }
    }
  }
`;

src\utils\apollo\ApolloClient.js

/*eslint complexity: ["error", 6]*/
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink, } from '@apollo/client';
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
/**
 * Middleware operation
 * If we have a session token in localStorage, add it to the GraphQL request as a Session header.
 */
export const middleware = new ApolloLink((operation, forward) => {
  /**
   * If session data exist in local storage, set value as session header.
   * Here we also delete the session if it is older than 7 days
   */
  const sessionData = process.browser
    ? JSON.parse(localStorage.getItem('woo-session'))
    : null;
  if (sessionData && sessionData.token && sessionData.createdTime) {
    const { token, createdTime } = sessionData;
    // Check if the token is older than 7 days
    if (Date.now() - createdTime > SEVEN_DAYS) {
      // If it is, delete it
      localStorage.removeItem('woo-session');
      localStorage.setItem('woocommerce-cart', JSON.stringify({}));
    } else {
      // If it's not, use the token
      operation.setContext(() => ({
        headers: {
          'woocommerce-session': `Session ${token}`,
        },
      }));
    }
  }
  return forward(operation);
});
/**
 * Afterware operation.
 *
 * This catches the incoming session token and stores it in localStorage, for future GraphQL requests.
 */
export const afterware = new ApolloLink((operation, forward) =>
  forward(operation).map((response) => {
    /**
     * Check for session header and update session in local storage accordingly.
     */
    const context = operation.getContext();
    const {
      response: { headers },
    } = context;
    const session = headers.get('woocommerce-session');
    if (session && process.browser) {
      if ('false' === session) {
        // Remove session data if session destroyed.
        localStorage.removeItem('woo-session');
        // Update session new data if changed.
      } else if (!localStorage.getItem('woo-session')) {
        localStorage.setItem(
          'woo-session',
          JSON.stringify({ token: session, createdTime: Date.now() }),
        );
      }
    }
    return response;
  }),
);
const clientSide = typeof window === 'undefined';
// Apollo GraphQL client.
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;

src\pages\index.tsx

import client from '@/utils/apollo/ApolloClient';
import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES';
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import Layout from '@/components/Layout/Layout.component';
export default function Home({ products }: InferGetStaticPropsType<typeof getStaticProps>) {
  console.log(products);
  return (
    <Layout title="Lionel">
      a
    </Layout>
  );
}
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\Layout\Layout.component.tsx

import React, { ReactNode } from 'react';
import Header from '../Header/Header.component';
import PageTitle from './PageTitle.component';
interface ILayoutProps {
  children?: ReactNode;
  title: string;
}
const Layout = ({ children, title }: ILayoutProps) => {
  return(
    <div className="flex flex-col min-h-screen w-full mx-auto">
      <Header title={title} />
      {title === 'Lionel' ? (
        <main className="flex-1 px-4 md:px-0">{children}</main>
      ) : (
        <div className="container mx-auto px-6 flex-1">
          <PageTitle title={title} />
          <main>{children}</main>
        </div>
      )}
    </div>
  )
}
export default Layout;

src\components\Layout\PageTitle.component.tsx

interface IPageTitleProps {
  title: string;
}
const PageTitle = ({ title }: IPageTitleProps) => (
  <section className="w-full bg-white border-b border-gray-200">
    <div className="container mx-auto py-12 px-6">
      <h1 className="text-2xl text-center tracking-wider text-gray-900 uppercase">
        {title}
      </h1>
    </div>
  </section>
);
export default PageTitle;

src\components\Header\Header.component.tsx

import Head from 'next/head';
import Navbar from './Navbar.component';
interface IHeaderProps {
  title: string;
}
const Header = ({ title }: IHeaderProps) => (
  <>
    <Head>
      <title>{`Next.js webshop with WooCommerce ${title}`}</title>
      <meta name="description" content="WooCommerce webshop" />
      <meta name="keywords" content="Ecommerce, WooCommerce" />
      <meta property="og:title" content="Nextjs Ecommerce with Woocommerce" key="pagetitle" />
    </Head>
    <div className="container mx-auto px-6">
      <Navbar />
    </div>
  </>
)
export default Header;

src\components\Header\Navbar.component.tsx

import Link from "next/link";
const Navbar = () => {
  return (
    <header className="border-b border-gray-200">
      <nav id="header" className="top-0 z-50 w-full bg-white">
        <div className="container mx-auto px-4 sm:px-6 py-4">
          <div className="flex flex-col space-y-4">
            <div className="text-center">
              <Link href="/">
                <span className="text-lg font-bold tracking-widest text-gray-900">
                  Home
                </span>
              </Link>
            </div>
            <div className="flex items-center justify-between">
              <div className="flex items-center space-x-8">
                <Link href="/produkter">
                  <span className="text-base uppercase tracking-wider group relative">
                    <span className="relative inline-block">
                      <span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
                      Produkter
                    </span>
                  </span>
                </Link>
                <Link href="/kategorier">
                  <span className="text-base uppercase tracking-wider group relative">
                    <span className="relative inline-block">
                      <span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
                      Kategorier
                    </span>
                  </span>
                </Link>
              </div>
            </div>
          </div>
        </div>
      </nav>
    </header>
  )
}
export default Navbar;

Step 1.2 hoàn thiện giao diện Home Page

src\components\Header\Navbar.component.tsx

import Link from "next/link";
const Navbar = () => {
  return (
    <header className="border-b border-gray-200">
      <nav id="header" className="top-0 z-50 w-full bg-white">
        <div className="container mx-auto px-4 sm:px-6 py-4">
          <div className="flex flex-col space-y-4">
            <div className="text-center">
              <Link href="/">
                <span className="text-lg font-bold tracking-widest text-gray-900">
                  Home
                </span>
              </Link>
            </div>
            <div className="flex items-center justify-between">
              <div className="flex items-center space-x-8">
                <Link href="/product">
                  <span className="text-base uppercase tracking-wider group relative">
                    <span className="relative inline-block">
                      <span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
                      Product
                    </span>
                  </span>
                </Link>
                <Link href="/category">
                  <span className="text-base uppercase tracking-wider group relative">
                    <span className="relative inline-block">
                      <span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
                      Category
                    </span>
                  </span>
                </Link>
              </div>
            </div>
          </div>
        </div>
      </nav>
    </header>
  )
}
export default Navbar;

src\components\Header\Header.component.tsx

import Head from 'next/head';
import Navbar from './Navbar.component';
interface IHeaderProps {
  title: string;
}
const Header = ({ title }: IHeaderProps) => (
  <>
    <Head>
      <title>{`Next.js webshop with WooCommerce ${title}`}</title>
      <meta name="description" content="WooCommerce webshop" />
      <meta name="keywords" content="Ecommerce, WooCommerce" />
      <meta property="og:title" content="Nextjs Ecommerce with Woocommerce" key="pagetitle" />
    </Head>
    <div className="container mx-auto px-6">
      <Navbar />
    </div>
  </>
)
export default Header;

src\components\Layout\Layout.component.tsx

import React, { ReactNode } from 'react';
import Header from '../Header/Header.component';
import PageTitle from './PageTitle.component';
interface ILayoutProps {
  children?: ReactNode;
  title: string;
}
const Layout = ({ children, title }: ILayoutProps) => {
  return(
    <div className="flex flex-col min-h-screen w-full mx-auto">
      <Header title={title} />
      {title === 'Lionel' ? (
        <main className="flex-1 px-4 md:px-0">{children}</main>
      ) : (
        <div className="container mx-auto px-6 flex-1">
          <PageTitle title={title} />
          <main>{children}</main>
        </div>
      )}
    </div>
  )
}
export default Layout;

src\components\Layout\PageTitle.component.tsx

interface IPageTitleProps {
  title: string;
}
const PageTitle = ({ title }: IPageTitleProps) => (
  <section className="w-full bg-white border-b border-gray-200">
    <div className="container mx-auto py-12 px-6">
      <h1 className="text-2xl text-center tracking-wider text-gray-900 uppercase">
        {title}
      </h1>
    </div>
  </section>
);
export default PageTitle;

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;
  onSale: boolean;
  slug: string;
  image: Image;
  price: string;
  regularPrice: string;
  salePrice?: string;
  variations: Variations;
}
interface IDisplayProductsProps {
  products: RootObject[];
}
const DisplayProducts = ({ products }: IDisplayProductsProps) => (
  <section className="container mx-auto bg-white py-12">
    <div id="product-container" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
      {
        products ? (products.map(({ databaseId, name, price, regularPrice, salePrice, onSale, slug, image, variations }) => {
          console.log(price);
          if (price) {
            price = paddedPrice(price, '&nbsp;');
          }
          if (regularPrice) {
            regularPrice = paddedPrice(regularPrice, '&nbsp;');
          }
          if (salePrice) {
            salePrice = paddedPrice(salePrice, '&nbsp;');
          }
          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>
              <Link href={`/produkt/${encodeURIComponent(slug,)}?id=${encodeURIComponent(databaseId)}`}>
                <span>
                  <div className="mt-4">
                    <p className="text-base font-bold text-center cursor-pointer hover:text-gray-600 transition-colors">
                      {name}
                    </p>
                  </div>
                </span>
              </Link>
              <div className="mt-2 text-center">
                {
                  onSale ? (
                    <div className="flex justify-center items-center space-x-2">
                      <span className="text-red-600">
                        {variations && filteredVariantPrice(price, '')}
                        {!variations && salePrice}
                      </span>
                      <span className="text-gray-500 text-sm line-through">
                        {variations && filteredVariantPrice(price, 'right')}
                        {!variations && regularPrice}
                      </span>
                    </div>
                  ) : (<span className="text-gray-900"> {price} </span>)
                }
              </div>
            </div>
          );
        })) : (<div className="mx-auto text-xl font-bold text-center text-gray-800 no-underline uppercase">No products found</div>)
      }
    </div>
  </section>
);
export default DisplayProducts;

src\pages\index.tsx

import client from '@/utils/apollo/ApolloClient';
import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES';
import DisplayProducts from '@/components/Product/DisplayProducts.component';
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import Layout from '@/components/Layout/Layout.component';
export default function Home({ products }: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <Layout title="Lionel">
      {products && <DisplayProducts products={products} />}
    </Layout>
  );
}
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\utils\functions\functions.tsx

/* Interface for products*/
export interface IImage {
  __typename: string;
  id: string;
  sourceUrl?: string;
  srcSet?: string;
  altText: string;
  title: string;
}
export 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;
}
type TUpdatedItems = { key: string; quantity: number }[];
export interface IUpdateCartItem {
  key: string;
  quantity: number;
}
export interface IUpdateCartInput {
  clientMutationId: string;
  items: IUpdateCartItem[];
}
export interface IUpdateCartVariables {
  input: IUpdateCartInput;
}
export interface IUpdateCartRootObject {
  variables: IUpdateCartVariables;
}
/**
 * Add empty character after currency symbol
 * @param {string} price The price string that we input
 * @param {string} symbol Currency symbol to add empty character/padding after
 */
export const paddedPrice = (price: string, symbol: string) =>
  price.split(symbol).join(" ");
/**
 * Shorten inputted string (usually product description) to a maximum of length
 * @param {string} input The string that we input
 * @param {number} length The length that we want to shorten the text to
 */
export const trimmedStringToLength = (input: string, length: number) => {
  if (input.length > length) {
    const subStr = input.substring(0, length);
    return `${subStr}...`;
  }
  return input;
};
/**
 * Filter variant price. Changes "kr198.00 - kr299.00" to kr299.00 or kr198 depending on the side variable
 * @param {String} side Which side of the string to return (which side of the "-" symbol)
 * @param {String} price The inputted price that we need to convert
 */
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('-', '');
};

src\utils\apollo\ApolloClient.js

/*eslint complexity: ["error", 6]*/
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink, } from '@apollo/client';
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
/**
 * Middleware operation
 * If we have a session token in localStorage, add it to the GraphQL request as a Session header.
 */
export const middleware = new ApolloLink((operation, forward) => {
  /**
   * If session data exist in local storage, set value as session header.
   * Here we also delete the session if it is older than 7 days
   */
  const sessionData = process.browser
    ? JSON.parse(localStorage.getItem('woo-session'))
    : null;
  if (sessionData && sessionData.token && sessionData.createdTime) {
    const { token, createdTime } = sessionData;
    // Check if the token is older than 7 days
    if (Date.now() - createdTime > SEVEN_DAYS) {
      // If it is, delete it
      localStorage.removeItem('woo-session');
      localStorage.setItem('woocommerce-cart', JSON.stringify({}));
    } else {
      // If it's not, use the token
      operation.setContext(() => ({
        headers: {
          'woocommerce-session': `Session ${token}`,
        },
      }));
    }
  }
  return forward(operation);
});
/**
 * Afterware operation.
 *
 * This catches the incoming session token and stores it in localStorage, for future GraphQL requests.
 */
export const afterware = new ApolloLink((operation, forward) =>
  forward(operation).map((response) => {
    /**
     * Check for session header and update session in local storage accordingly.
     */
    const context = operation.getContext();
    const {
      response: { headers },
    } = context;
    const session = headers.get('woocommerce-session');
    if (session && process.browser) {
      if ('false' === session) {
        // Remove session data if session destroyed.
        localStorage.removeItem('woo-session');
        // Update session new data if changed.
      } else if (!localStorage.getItem('woo-session')) {
        localStorage.setItem(
          'woo-session',
          JSON.stringify({ token: session, createdTime: Date.now() }),
        );
      }
    }
    return response;
  }),
);
const clientSide = typeof window === 'undefined';
// Apollo GraphQL client.
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;

src\utils\gql\GQL_QUERIES.ts

import { gql } from '@apollo/client';
export const FETCH_ALL_PRODUCTS_QUERY = gql`
  query MyQuery {
    products(first: 50) {
      nodes {
        databaseId
        name
        onSale
        slug
        image {
          sourceUrl
        }
        productCategories {
          nodes {
            name
            slug
          }
        }
        ... on SimpleProduct {
          databaseId
          price
          regularPrice
          salePrice
        }
        ... on VariableProduct {
          databaseId
          price
          regularPrice
          salePrice
          allPaColors {
            nodes {
              name
              slug
            }
          }
          allPaSizes {
            nodes {
              name
            }
          }
          variations {
            nodes {
              price
              regularPrice
              salePrice
              attributes {
                nodes {
                  name
                  value
                }
              }
            }
          }
        }
      }
    }
  }
`;

Step 1.3 Single Product

src\components\Product\SingleProduct.component.tsx

// Imports
import { useState, useEffect } from 'react';
// Utils
import { filteredVariantPrice, paddedPrice } from '@/utils/functions/functions';
import LoadingSpinner from '@/components/LoadingSpinner/LoadingSpinner.component';
const SingleProduct = ({ product }: any) => {
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const placeholderFallBack = 'https://placeholder.pics/svg/600';
  let DESCRIPTION_WITHOUT_HTML;
  useEffect(() => {
    setIsLoading(false);
  }, []);
  let { description, image, name, onSale, price, regularPrice, salePrice } = product;
  console.log(product);
  if (price) {
    price = paddedPrice(price, '&nbsp;');
  }
  if (regularPrice) {
    regularPrice = paddedPrice(regularPrice, '&nbsp;');
  }
  if (salePrice) {
    salePrice = paddedPrice(salePrice, '&nbsp;');
  }
  if (typeof window !== 'undefined') {
    DESCRIPTION_WITHOUT_HTML = new DOMParser().parseFromString(
      description,
      'text/html',
    ).body.textContent;
  }
  return (
    <section className="bg-white mb-[8rem] md:mb-12">
      {
        isLoading ? (
          <div className="h-56 mt-20">
            <p className="text-2xl font-bold text-center">Loading product ...</p>
            <br />
            <LoadingSpinner />
          </div>
        ) : (
          <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
                    id="product-image"
                    src={
                      image?.sourceUrl ||
                      process.env.NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL ||
                      placeholderFallBack
                    }
                    alt={name}
                    className="w-full h-full object-cover object-center transition duration-300 group-hover:scale-105"
                  />
                </div>
              </div>
            </div>
            <div className="flex flex-col">
              <h1 className="text-2xl font-bold text-center md:text-left mb-4">
                {name}
              </h1>
              {/* Price Display */}
              <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, '')
                        : 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>
              {/* Description */}
              <p className="text-lg mb-6 text-center md:text-left">
                {DESCRIPTION_WITHOUT_HTML}
              </p>
              {/* Stock Status */}
              {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>
              )}
            </div>
          </div>
        )
      }
    </section>
  );
}
export default SingleProduct;

src\pages\product\[slug].tsx

// Imports
import { withRouter } from 'next/router';
// Components
import SingleProduct from '@/components/Product/SingleProduct.component';
import Layout from '@/components/Layout/Layout.component';
// Utilities
import client from '@/utils/apollo/ApolloClient';
// Types
import type {
  NextPage,
  GetServerSideProps,
  InferGetServerSidePropsType,
} from 'next';
// GraphQL
import { GET_SINGLE_PRODUCT } from '@/utils/gql/GQL_QUERIES';
/**
 * Display a single product with dynamic pretty urls
 * @function Produkt
 * @param {InferGetServerSidePropsType<typeof getServerSideProps>} products
 * @returns {JSX.Element} - Rendered 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, loading, networkStatus } = await client.query({
    query: GET_SINGLE_PRODUCT,
    variables: { id },
  });
  return {
    props: { product: data.product, loading, networkStatus },
  };
};

src\utils\gql\GQL_QUERIES.ts

Step 1.4 Single Product and Get Cart and Button Cart

src\utils\gql\GQL_QUERIES.ts

import { gql } from '@apollo/client';
export const FETCH_ALL_PRODUCTS_QUERY = gql`
  query MyQuery {
    products(first: 50) {
      nodes {
        databaseId
        name
        onSale
        slug
        image {
          sourceUrl
        }
        productCategories {
          nodes {
            name
            slug
          }
        }
        ... on SimpleProduct {
          databaseId
          price
          regularPrice
          salePrice
        }
        ... on VariableProduct {
          databaseId
          price
          regularPrice
          salePrice
          allPaColors {
            nodes {
              name
              slug
            }
          }
          allPaSizes {
            nodes {
              name
            }
          }
          variations {
            nodes {
              price
              regularPrice
              salePrice
              attributes {
                nodes {
                  name
                  value
                }
              }
            }
          }
        }
      }
    }
  }
`;
export const GET_SINGLE_PRODUCT = gql`
  query Product($id: ID!) {
    product(id: $id, idType: DATABASE_ID) {
      id
      databaseId
      averageRating
      slug
      description
      onSale
      image {
        id
        uri
        title
        srcSet
        sourceUrl
      }
      name
      ... on SimpleProduct {
        salePrice
        regularPrice
        price
        id
        stockQuantity
      }
      ... on VariableProduct {
        salePrice
        regularPrice
        price
        id
        allPaColors {
          nodes {
            name
          }
        }
        allPaSizes {
          nodes {
            name
          }
        }
        variations {
          nodes {
            id
            databaseId
            name
            stockStatus
            stockQuantity
            purchasable
            onSale
            salePrice
            regularPrice
          }
        }
      }
      ... on ExternalProduct {
        price
        id
        externalUrl
      }
      ... on GroupProduct {
        products {
          nodes {
            ... on SimpleProduct {
              id
              price
            }
          }
        }
        id
      }
    }
  }
`;
export const GET_CART = gql`
  query GET_CART {
    cart {
      contents {
        nodes {
          key
          product {
            node {
              id
              databaseId
              name
              description
              type
              onSale
              slug
              averageRating
              reviewCount
              image {
                id
                sourceUrl
                srcSet
                altText
                title
              }
              galleryImages {
                nodes {
                  id
                  sourceUrl
                  srcSet
                  altText
                  title
                }
              }
            }
          }
          variation {
            node {
              id
              databaseId
              name
              description
              type
              onSale
              price
              regularPrice
              salePrice
              image {
                id
                sourceUrl
                srcSet
                altText
                title
              }
              attributes {
                nodes {
                  id
                  name
                  value
                }
              }
            }
          }
          quantity
          total
          subtotal
          subtotalTax
        }
      }
      subtotal
      subtotalTax
      shippingTax
      shippingTotal
      total
      totalTax
      feeTax
      feeTotal
      discountTax
      discountTotal
    }
  }
`;

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
      }
    }
  }
`;

src\utils\functions\functions.tsx

import { IVariationNodes } from '@/components/Product/AddToCart.component';
import { RootObject, Product } from '@/stores/CartProvider';
/* Interface for products*/
export interface IImage {
  __typename: string;
  id: string;
  sourceUrl?: string;
  srcSet?: string;
  altText: string;
  title: string;
}
export 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;
}
type TUpdatedItems = { key: string; quantity: number }[];
export interface IProductRootObject {
  __typename: string;
  key: string;
  product: IProduct;
  variation?: IVariationNodes;
  quantity: number;
  total: string;
  subtotal: string;
  subtotalTax: string;
}
interface IFormattedCartProps {
  cart: { contents: { nodes: IProductRootObject[] }; total: number };
}
export interface IUpdateCartItem {
  key: string;
  quantity: number;
}
export interface IUpdateCartInput {
  clientMutationId: string;
  items: IUpdateCartItem[];
}
export interface IUpdateCartVariables {
  input: IUpdateCartInput;
}
export interface IUpdateCartRootObject {
  variables: IUpdateCartVariables;
}
/**
 * Add empty character after currency symbol
 * @param {string} price The price string that we input
 * @param {string} symbol Currency symbol to add empty character/padding after
 */
export const paddedPrice = (price: string, symbol: string) =>
  price.split(symbol).join(" ");
/**
 * Shorten inputted string (usually product description) to a maximum of length
 * @param {string} input The string that we input
 * @param {number} length The length that we want to shorten the text to
 */
export const trimmedStringToLength = (input: string, length: number) => {
  if (input.length > length) {
    const subStr = input.substring(0, length);
    return `${subStr}...`;
  }
  return input;
};
/**
 * Filter variant price. Changes "kr198.00 - kr299.00" to kr299.00 or kr198 depending on the side variable
 * @param {String} side Which side of the string to return (which side of the "-" symbol)
 * @param {String} price The inputted price that we need to convert
 */
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('-', '');
};
/**
 * Returns cart data in the required format.
 * @param {String} data Cart data
 */
export const getFormattedCart = (data: IFormattedCartProps) => {
  const formattedCart: RootObject = {
    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;
};

src\stores\CartProvider.tsx

import React, { useState, useEffect, createContext, useMemo, } from 'react';
interface ICartProviderProps {
  children: React.ReactNode;
}
interface Image {
  sourceUrl?: string;
  srcSet?: string;
  title: string;
}
export interface Product {
  cartKey: string;
  name: string;
  qty: number;
  price: number;
  totalPrice: string;
  image: Image;
  productId: number;
}
export interface RootObject {
  products: Product[];
  totalProductsCount: number;
  totalProductsPrice: number;
}
export type TRootObject = RootObject | string | null | undefined;
export type TRootObjectNull = RootObject | null | undefined;
interface ICartContext {
  cart: RootObject | null | undefined;
  setCart: React.Dispatch<React.SetStateAction<TRootObjectNull>>;
  updateCart: (newCart: RootObject) => void;
  isLoading: boolean;
}
const CartState: ICartContext = {
  cart: null,
  setCart: () => { },
  updateCart: () => { },
  isLoading: true,
};
export const CartContext = createContext<ICartContext>(CartState);
export const CartProvider = ({ children }: ICartProviderProps) => {
  const [cart, setCart] = useState<RootObject | null>();
  const [isLoading, setIsLoading] = useState(true);
  useEffect(() => {
    // Check if we are client-side before we access the localStorage
    if (typeof window !== 'undefined') {
      const localCartData = localStorage.getItem('woocommerce-cart');
      if (localCartData) {
        const cartData: RootObject = JSON.parse(localCartData);
        setCart(cartData);
      }
      setIsLoading(false);
    }
  }, []);
  const updateCart = (newCart: RootObject) => {
    setCart(newCart);
    if (typeof window !== 'undefined') {
      localStorage.setItem('woocommerce-cart', JSON.stringify(newCart));
    }
  };
  const contextValue = useMemo(() => {
    return { cart, setCart, updateCart, isLoading };
  }, [cart, isLoading]);
  return (
    <CartContext.Provider value={contextValue}>
      {children}
    </CartContext.Provider>
  );
}

src\pages\_app.tsx

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { ApolloProvider } from '@apollo/client';
import client from '@/utils/apollo/ApolloClient';
import { CartProvider } from '@/stores/CartProvider';
export default function App({ Component, pageProps }: AppProps) {
  return <ApolloProvider client={client}><CartProvider><Component {...pageProps} /></CartProvider></ApolloProvider>;
}

src\pages\product\[slug].tsx

// Imports
import { withRouter } from 'next/router';
// Components
import SingleProduct from '@/components/Product/SingleProduct.component';
import Layout from '@/components/Layout/Layout.component';
// Utilities
import client from '@/utils/apollo/ApolloClient';
// Types
import type {
  NextPage,
  GetServerSideProps,
  InferGetServerSidePropsType,
} from 'next';
// GraphQL
import { GET_SINGLE_PRODUCT } from '@/utils/gql/GQL_QUERIES';
/**
 * Display a single product with dynamic pretty urls
 * @function Produkt
 * @param {InferGetServerSidePropsType<typeof getServerSideProps>} products
 * @returns {JSX.Element} - Rendered 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, loading, networkStatus } = await client.query({
    query: GET_SINGLE_PRODUCT,
    variables: { id },
  });
  return {
    props: { product: data.product, loading, networkStatus },
  };
};

src\components\Header\Cart.component.tsx

import { useContext, useState, useEffect } from 'react';
import Link from 'next/link';
import { CartContext } from '@/stores/CartProvider';
interface ICartProps {
  stickyNav?: boolean;
}
const Cart = ({ stickyNav }: ICartProps) => {
  const { cart } = useContext(CartContext);
  const [productCount, setProductCount] = useState<number | null | undefined>();
  useEffect(() => {
    if (cart) {
      setProductCount(cart.totalProductsCount);
    } else {
      setProductCount(null);
    }
  }, [cart]);
  return (
    <>
      <Link href="/shopping-cart">
        <span
          className="pl-4 mt-4 no-underline inline-block"
          aria-label="Shopping Cart"
        >
          <svg
            className={`${stickyNav ? 'fill-white' : 'fill-current'}`}
            xmlns="https://www.w3.org/2000/svg"
            width="55"
            height="55"
            viewBox="0 0 30 30"
            aria-label="Handlekurv"
          >
            <path
              d="M21,7H7.462L5.91,3.586C5.748,3.229,5.392,3,5,3H2v2h2.356L9.09,15.414C9.252,15.771,9.608,16,10,16h8 c0.4,0,0.762-0.238,0.919-0.606l3-7c0.133-0.309,0.101-0.663-0.084-0.944C21.649,7.169,21.336,7,21,7z M17.341,14h-6.697L8.371,9 h11.112L17.341,14z"
              aria-label="Handlekurv"
            />
            <circle cx="10.5" cy="18.5" r="1.5" aria-label="Handlekurv" />
            <circle cx="17.5" cy="18.5" r="1.5" aria-label="Handlekurv" />
          </svg>
        </span>
      </Link>
      {productCount && (
        <span
          className={`w-6 h-6 pb-2 -mt-5 !-ml-2 text-center rounded-full
          ${stickyNav ? 'text-black bg-white' : 'text-white bg-black'}`}
        >
          {productCount}
        </span>
      )}
    </>
  );
}
export default Cart;

src\components\Header\Header.component.tsx

import Head from 'next/head';
import Navbar from './Navbar.component';
interface IHeaderProps {
  title: string;
}
const Header = ({ title }: IHeaderProps) => (
  <>
    <Head>
      <title>{`Next.js webshop with WooCommerce ${title}`}</title>
      <meta name="description" content="WooCommerce webshop" />
      <meta name="keywords" content="Ecommerce, WooCommerce" />
      <meta property="og:title" content="Nextjs Ecommerce with Woocommerce" key="pagetitle" />
    </Head>
    <div className="container mx-auto px-6">
      <Navbar />
    </div>
  </>
)
export default Header;

src\components\Header\Navbar.component.tsx

import Link from "next/link";
import Cart from "./Cart.component";
const Navbar = () => {
  return (
    <header className="border-b border-gray-200">
      <nav id="header" className="top-0 z-50 w-full bg-white">
        <div className="container mx-auto px-4 sm:px-6 py-4">
          <div className="flex flex-col space-y-4">
            <div className="text-center">
              <Link href="/">
                <span className="text-lg font-bold tracking-widest text-gray-900">
                  Home
                </span>
              </Link>
            </div>
            <div className="flex items-center justify-between">
              <div className="flex items-center space-x-8">
                <Link href="/product">
                  <span className="text-base uppercase tracking-wider group relative">
                    <span className="relative inline-block">
                      <span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
                      Product
                    </span>
                  </span>
                </Link>
                <Link href="/category">
                  <span className="text-base uppercase tracking-wider group relative">
                    <span className="relative inline-block">
                      <span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
                      Category
                    </span>
                  </span>
                </Link>
              </div>
              <Link href="/" className="hidden lg:block">
                <span className="text-xl font-bold tracking-widest text-gray-900 hover:text-gray-700 transition-colors">
                  ONLINE STORE
                </span>
              </Link>
              <div className="flex items-center space-x-3">
                <Cart />
              </div>
            </div>
          </div>
        </div>
      </nav>
    </header>
  )
}
export default Navbar;

Step 2.1 Categories

export const FETCH_ALL_CATEGORIES_QUERY = gql`
  query Categories {
    productCategories(first: 20) {
      nodes {
        id
        name
        slug
      }
    }
  }
`;

src\pages\categories.tsx

import { NextPage, InferGetStaticPropsType, GetStaticProps } from 'next';
import Categories from '@/components/Category/Categories.component';
import Layout from '@/components/Layout/Layout.component';
import client from '@/utils/apollo/ApolloClient';
import { FETCH_ALL_CATEGORIES_QUERY } from '@/utils/gql/GQL_QUERIES';
const Category: NextPage = ({
  categories,
}: InferGetStaticPropsType<typeof getStaticProps>) => (
  <Layout title="Kategorier">
    {categories && <Categories categories={categories} />}
  </Layout>
);
export default Category;
export const getStaticProps: GetStaticProps = async () => {
  const result = await client.query({
    query: FETCH_ALL_CATEGORIES_QUERY,
  });
  return {
    props: {
      categories: result.data.productCategories.nodes,
    },
    revalidate: 10,
  };
};

Step 2.2 Category Deatail

src\utils\gql\GQL_QUERIES.ts

import { gql } from '@apollo/client';
export const GET_SINGLE_PRODUCT = gql`
  query Product($id: ID!) {
    product(id: $id, idType: DATABASE_ID) {
      id
      databaseId
      averageRating
      slug
      description
      onSale
      image {
        id
        uri
        title
        srcSet
        sourceUrl
      }
      name
      ... on SimpleProduct {
        salePrice
        regularPrice
        price
        id
        stockQuantity
      }
      ... on VariableProduct {
        salePrice
        regularPrice
        price
        id
        allPaColors {
          nodes {
            name
          }
        }
        allPaSizes {
          nodes {
            name
          }
        }
        variations {
          nodes {
            id
            databaseId
            name
            stockStatus
            stockQuantity
            purchasable
            onSale
            salePrice
            regularPrice
          }
        }
      }
      ... on ExternalProduct {
        price
        id
        externalUrl
      }
      ... on GroupProduct {
        products {
          nodes {
            ... on SimpleProduct {
              id
              price
            }
          }
        }
        id
      }
    }
  }
`;
/**
 * Fetch first 4 products from a specific category
 */
export const FETCH_FIRST_PRODUCTS_FROM_HOODIES_QUERY = `
 query MyQuery {
  products(first: 4, where: {category: "Hoodies"}) {
    nodes {
      productId
      name
      onSale
      slug
      image {
        sourceUrl
      }
      ... on SimpleProduct {
        price
        regularPrice
        salePrice
      }
      ... on VariableProduct {
        price
        regularPrice
        salePrice
      }
    }
  }
}
 `;
/**
 * Fetch first 200 Woocommerce products from GraphQL
 */
export const FETCH_ALL_PRODUCTS_QUERY = gql`
  query MyQuery {
    products(first: 50) {
      nodes {
        databaseId
        name
        onSale
        slug
        image {
          sourceUrl
        }
        productCategories {
          nodes {
            name
            slug
          }
        }
        ... on SimpleProduct {
          databaseId
          price
          regularPrice
          salePrice
        }
        ... on VariableProduct {
          databaseId
          price
          regularPrice
          salePrice
          allPaColors {
            nodes {
              name
              slug
            }
          }
          allPaSizes {
            nodes {
              name
            }
          }
          variations {
            nodes {
              price
              regularPrice
              salePrice
              attributes {
                nodes {
                  name
                  value
                }
              }
            }
          }
        }
      }
    }
  }
`;
/**
 * Fetch first 20 categories from GraphQL
 */
export const FETCH_ALL_CATEGORIES_QUERY = gql`
  query Categories {
    productCategories(first: 20) {
      nodes {
        id
        name
        slug
      }
    }
  }
`;
export const GET_PRODUCTS_FROM_CATEGORY = gql`
  query ProductsFromCategory($id: ID!) {
    productCategory(id: $id) {
      id
      name
      products(first: 50) {
        nodes {
          id
          databaseId
          onSale
          averageRating
          slug
          description
          image {
            id
            uri
            title
            srcSet
            sourceUrl
          }
          name
          ... on SimpleProduct {
            salePrice
            regularPrice
            onSale
            price
            id
          }
          ... on VariableProduct {
            salePrice
            regularPrice
            onSale
            price
            id
          }
          ... on ExternalProduct {
            price
            id
            externalUrl
          }
          ... on GroupProduct {
            products {
              nodes {
                ... on SimpleProduct {
                  id
                  price
                }
              }
            }
            id
          }
        }
      }
    }
  }
`;
export const GET_CART = gql`
  query GET_CART {
    cart {
      contents {
        nodes {
          key
          product {
            node {
              id
              databaseId
              name
              description
              type
              onSale
              slug
              averageRating
              reviewCount
              image {
                id
                sourceUrl
                srcSet
                altText
                title
              }
              galleryImages {
                nodes {
                  id
                  sourceUrl
                  srcSet
                  altText
                  title
                }
              }
            }
          }
          variation {
            node {
              id
              databaseId
              name
              description
              type
              onSale
              price
              regularPrice
              salePrice
              image {
                id
                sourceUrl
                srcSet
                altText
                title
              }
              attributes {
                nodes {
                  id
                  name
                  value
                }
              }
            }
          }
          quantity
          total
          subtotal
          subtotalTax
        }
      }
      subtotal
      subtotalTax
      shippingTax
      shippingTotal
      total
      totalTax
      feeTax
      feeTotal
      discountTax
      discountTotal
    }
  }
`;

src\pages\category[slug].tsx

import { withRouter } from 'next/router';
import Layout from '@/components/Layout/Layout.component';
import DisplayProducts from '@/components/Product/DisplayProducts.component';
import client from '@/utils/apollo/ApolloClient';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { GET_PRODUCTS_FROM_CATEGORY } from '@/utils/gql/GQL_QUERIES';
const Product = ({ categoryName, products}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
  return (
    <Layout title={`${categoryName ? categoryName : ''}`}>
      {products ? (
        <DisplayProducts products={products} />
      ) : (
        <div className="mt-8 text-2xl text-center">Laster produkt ...</div>
      )}
    </Layout>
  );
};
export default withRouter(Product);
export const getServerSideProps: GetServerSideProps = async ({ query: { id }, }) => {
  const res = await client.query({
    query: GET_PRODUCTS_FROM_CATEGORY,
    variables: { id },
  });
  return {
    props: {
      categoryName: res.data.productCategory.name,
      products: res.data.productCategory.products.nodes,
    },
  };
}

Step 3.1 Shop

src\pages\shop.tsx

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 Products: 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">Ingen Products funnet</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 Products;
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\hooks\useProductFilters.ts

import { useState } from 'react';
import { Product, ProductType } from '@/types/product';
import { getUniqueProductTypes } from '@/utils/functions/productUtils'; 
export const useProductFilters = (products: Product[]) => {
  const [sortBy, setSortBy] = useState('popular');
  const [selectedSizes, setSelectedSizes] = useState<string[]>([]);
  const [selectedColors, setSelectedColors] = useState<string[]>([]);
  const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]);
  const [productTypes, setProductTypes] = useState<ProductType[]>(() =>
    products ? getUniqueProductTypes(products) : [],
  );
  const toggleProductType = (id: string) => {
    setProductTypes((prev) =>
      prev.map((type) =>
        type.id === id ? { ...type, checked: !type.checked } : type,
      ),
    );
  };
  const resetFilters = () => {
    setSelectedSizes([]);
    setSelectedColors([]);
    setPriceRange([0, 1000]);
    setProductTypes((prev) =>
      prev.map((type) => ({ ...type, checked: false })),
    );
  };
  const filterProducts = (products: Product[]) => {
    const filtered = products?.filter((product: Product) => {
      // Filter by price
      const productPrice = parseFloat(product?.price?.replace(/[^0-9.]/g, ''));
      const withinPriceRange =
        productPrice >= priceRange[0] && productPrice <= priceRange[1];
      if (!withinPriceRange) return false;
      // Filter by product type
      const selectedTypes = productTypes
        .filter((t) => t.checked)
        .map((t) => t.name.toLowerCase());
      if (selectedTypes.length > 0) {
        const productCategories =
          product.productCategories?.nodes.map((cat) =>
            cat.name.toLowerCase(),
          ) || [];
        if (!selectedTypes.some((type) => productCategories.includes(type)))
          return false;
      }
      // Filter by size
      if (selectedSizes.length > 0) {
        const productSizes =
          product.allPaSizes?.nodes.map((node) => node.name) || [];
        if (!selectedSizes.some((size) => productSizes.includes(size)))
          return false;
      }
      // Filter by color
      if (selectedColors.length > 0) {
        const productColors =
          product.allPaColors?.nodes.map((node) => node.name) || [];
        if (!selectedColors.some((color) => productColors.includes(color)))
          return false;
      }
      return true;
    });
    // Sort products
    return [...(filtered || [])].sort((a, b) => {
      const priceA = parseFloat(a.price.replace(/[^0-9.]/g, ''));
      const priceB = parseFloat(b.price.replace(/[^0-9.]/g, ''));
      switch (sortBy) {
        case 'price-low':
          return priceA - priceB;
        case 'price-high':
          return priceB - priceA;
        case 'newest':
          return b.databaseId - a.databaseId;
        default: // 'popular'
          return 0;
      }
    });
  };
  return { sortBy, setSortBy, selectedSizes, setSelectedSizes, selectedColors, setSelectedColors, priceRange, setPriceRange, productTypes, toggleProductType, resetFilters, filterProducts, };
};

src\components\Product\ProductCard.component.tsx

import Link from 'next/link';
import Image from 'next/image';
import { paddedPrice } from '@/utils/functions/functions';
interface ProductCardProps {
  databaseId: number;
  name: string;
  price: string;
  slug: string;
  image?: {
    sourceUrl?: string;
  };
}
const ProductCard = ({ databaseId, name, price, slug, image, }: ProductCardProps) => {
  if (price) {
    price = paddedPrice(price, '&nbsp;');
  }
  return (
    <div className="group">
      <div className="aspect-[3/4] overflow-hidden bg-gray-100 relative">
        <Link href={`/product/${slug}?id=${databaseId}`}>
          {image?.sourceUrl ? (
            <Image
              src={image.sourceUrl}
              alt={name}
              fill
              className="w-full h-full object-cover object-center transition duration-300 group-hover:scale-105"
              priority={databaseId === 1}
              sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
            />
          ) : (
            <div className="h-full w-full bg-gray-100 flex items-center justify-center">
              <span className="text-gray-400">No image</span>
            </div>
          )}
        </Link>
      </div>
      <Link href={`/product/${slug}?id=${databaseId}`}>
        <div className="mt-4">
          <p className="text-base font-bold text-center cursor-pointer hover:text-gray-600 transition-colors">
            {name}
          </p>
        </div>
      </Link>
      <div className="mt-2 text-center">
        <span className="text-gray-900">{price}</span>
      </div>
    </div>
  );
};
export default ProductCard;

src\components\Product\ProductFilters.component.tsx

import { Dispatch, SetStateAction } from 'react';
import { Product, ProductType } from '@/types/product';
import Button from '@/components/UI/Button.component';
import Checkbox from '@/components/UI/Checkbox.component';
import RangeSlider from '@/components/UI/RangeSlider.component';
interface ProductFiltersProps {
  selectedSizes: string[];
  setSelectedSizes: Dispatch<SetStateAction<string[]>>;
  selectedColors: string[];
  setSelectedColors: Dispatch<SetStateAction<string[]>>;
  priceRange: [number, number];
  setPriceRange: Dispatch<SetStateAction<[number, number]>>;
  productTypes: ProductType[];
  toggleProductType: (id: string) => void;
  products: Product[];
  resetFilters: () => void;
}
const ProductFilters = ({
  selectedSizes,
  setSelectedSizes,
  selectedColors,
  setSelectedColors,
  priceRange,
  setPriceRange,
  productTypes,
  toggleProductType,
  products,
  resetFilters,
}: ProductFiltersProps) => {
  // Get unique sizes from all products
  const sizes = Array.from(
    new Set(
      products.flatMap(
        (product: Product) =>
          product.allPaSizes?.nodes.map(
            (node: { name: string }) => node.name,
          ) || [],
      ),
    ),
  ).sort((a, b) => a.localeCompare(b));
  // Get unique colors from all products
  const availableColors = products
    .flatMap((product: Product) => product.allPaColors?.nodes || [])
    .filter((color, index, self) => 
      index === self.findIndex((c) => c.slug === color.slug)
    )
    .sort((a, b) => a.name.localeCompare(b.name));
  const colors = availableColors.map((color) => ({
    name: color.name,
    class: `bg-${color.slug}-500`
  }));
  const toggleSize = (size: string) => {
    setSelectedSizes((prev) =>
      prev.includes(size) ? prev.filter((s) => s !== size) : [...prev, size],
    );
  };
  const toggleColor = (color: string) => {
    setSelectedColors((prev) =>
      prev.includes(color) ? prev.filter((c) => c !== color) : [...prev, color],
    );
  };
  return (
    <div className="w-full md:w-64 flex-shrink-0">
      <div className="bg-white px-8 pb-8 sm:px-6 sm:pb-6 rounded-lg shadow-sm">
        <div className="mb-8">
          <h3 className="font-semibold mb-4">PRODUCT TYPE</h3>
          <div className="space-y-2">
            {productTypes.map((type) => (
              <Checkbox key={type.id} id={type.id} label={type.name} checked={type.checked} count={type.count} onChange={() => toggleProductType(type.id)}/>
            ))}
          </div>
        </div>
        <div className="mb-8">
          <h3 className="font-semibold mb-4">PRICE</h3>
          <RangeSlider
            id="price-range"
            label="Price"
            min={0}
            max={1000}
            value={priceRange[1]}
            startValue={priceRange[0]}
            onChange={(value) => setPriceRange([priceRange[0], value])}
            formatValue={(value) => `${value}`}
          />
        </div>
        <div className="mb-8">
          <h3 className="font-semibold mb-4">SIZE</h3>
          <div className="grid gap-2">
            {sizes.map((size) => (
              <Button
                key={size}
                handleButtonClick={() => toggleSize(size)}
                variant="filter"
                selected={selectedSizes.includes(size)}
              >
                {size}
              </Button>
            ))}
          </div>
        </div>
        <div className="mb-8">
          <h3 className="font-semibold mb-4">COLOR</h3>
          <div className="grid grid-cols-3 gap-2">
            {colors.map((color) => (
              <button
                key={color.name}
                onClick={() => toggleColor(color.name)}
                className={`w-8 h-8 rounded-full  flex items-center justify-center text-xs ${color.class} ${selectedColors.includes(color.name) ? 'ring-2 ring-offset-2 ring-gray-900' : '' }`}
                title={color.name}
              />
            ))}
          </div>
        </div>
        <Button
          handleButtonClick={resetFilters}
          variant="reset"
        >
          Resett filter
        </Button>
      </div>
    </div>
  );
};
export default ProductFilters;

src\components\Product\ProductList.component.tsx

import { Product } from '@/types/product';
import { useProductFilters } from '@/hooks/useProductFilters';
import ProductFilters from './ProductFilters.component';
import ProductCard from './ProductCard.component';
interface ProductListProps {
  products: Product[];
  title: string;
}
const ProductList = ({ products, title }: ProductListProps) => {
  const {
    sortBy,
    setSortBy,
    selectedSizes,
    setSelectedSizes,
    selectedColors,
    setSelectedColors,
    priceRange,
    setPriceRange,
    productTypes,
    toggleProductType,
    resetFilters,
    filterProducts
  } = useProductFilters(products);
  const filteredProducts = filterProducts(products);
  return (
    <div className="flex flex-col md:flex-row gap-8">
      <ProductFilters
        selectedSizes={selectedSizes}
        setSelectedSizes={setSelectedSizes}
        selectedColors={selectedColors}
        setSelectedColors={setSelectedColors}
        priceRange={priceRange}
        setPriceRange={setPriceRange}
        productTypes={productTypes}
        toggleProductType={toggleProductType}
        products={products}
        resetFilters={resetFilters}
      />
      {/* Main Content */}
      <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={sortBy}
              onChange={(e) => setSortBy(e.target.value)}
              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\UI\Checkbox.component.tsx

import { ChangeEvent } from 'react';
interface ICheckboxProps {
  id: string;
  label: string;
  count:number;
  checked: boolean;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}
/**
 * A reusable checkbox component with a label
 * @function Checkbox
 * @param {string} id - Unique identifier for the checkbox
 * @param {string} label - Label text to display next to the checkbox
 * @param {boolean} checked - Whether the checkbox is checked
 * @param {function} onChange - Handler for when the checkbox state changes
 * @returns {JSX.Element} - Rendered component
 */
const Checkbox = ({ id, label, checked, onChange, count }: ICheckboxProps) => {
  return (
    <label htmlFor={id} className="flex items-center py-2 cursor-pointer">
      <input
        id={id}
        type="checkbox"
        className="form-checkbox h-5 w-5 cursor-pointer"
        checked={checked}
        onChange={onChange}
      />
      <span className="ml-3 text-base">{label} ({count})</span>
    </label>
  );
};
export default Checkbox;

src\components\UI\RangeSlider.component.tsx

import { ChangeEvent } from 'react';
interface IRangeSliderProps {
  id: string;
  label: string;
  min: number;
  max: number;
  value: number;
  onChange: (value: number) => void;
  startValue?: number;
  formatValue?: (value: number) => string;
}
/**
 * A reusable range slider component with labels
 * @function RangeSlider
 * @param {string} id - Unique identifier for the slider
 * @param {string} label - Accessible label for the slider
 * @param {number} min - Minimum value of the range
 * @param {number} max - Maximum value of the range
 * @param {number} value - Current value of the slider
 * @param {function} onChange - Handler for when the slider value changes
 * @param {number} startValue - Optional starting value to display (defaults to min)
 * @param {function} formatValue - Optional function to format the displayed values
 * @returns {JSX.Element} - Rendered component
 */
const RangeSlider = ({
  id,
  label,
  min,
  max,
  value,
  onChange,
  startValue = min,
  formatValue = (val: number) => val.toString(),
}: IRangeSliderProps) => {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    onChange(parseInt(e.target.value));
  };
  return (
    <div>
      <label htmlFor={id} className="sr-only">
        {label}
      </label>
      <input
        id={id}
        type="range"
        min={min}
        max={max}
        value={value}
        onChange={handleChange}
        className="w-full cursor-pointer"
      />
      <div className="flex justify-between mt-2">
        <span>{formatValue(startValue)}</span>
        <span>{formatValue(value)}</span>
      </div>
    </div>
  );
};
export default RangeSlider;

src\hooks\useProductFilters.ts

import { useState } from 'react';
import { Product, ProductType } from '@/types/product';
import { getUniqueProductTypes } from '@/utils/functions/productUtils'; 
export const useProductFilters = (products: Product[]) => {
  const [sortBy, setSortBy] = useState('popular');
  const [selectedSizes, setSelectedSizes] = useState<string[]>([]);
  const [selectedColors, setSelectedColors] = useState<string[]>([]);
  const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]);
  const [productTypes, setProductTypes] = useState<ProductType[]>(() =>
    products ? getUniqueProductTypes(products) : [],
  );
  const toggleProductType = (id: string) => {
    setProductTypes((prev) =>
      prev.map((type) =>
        type.id === id ? { ...type, checked: !type.checked } : type,
      ),
    );
  };
  const resetFilters = () => {
    setSelectedSizes([]);
    setSelectedColors([]);
    setPriceRange([0, 1000]);
    setProductTypes((prev) =>
      prev.map((type) => ({ ...type, checked: false })),
    );
  };
  const filterProducts = (products: Product[]) => {
    const filtered = products?.filter((product: Product) => {
      // Filter by price
      const productPrice = parseFloat(product?.price?.replace(/[^0-9.]/g, ''));
      const withinPriceRange =
        productPrice >= priceRange[0] && productPrice <= priceRange[1];
      if (!withinPriceRange) return false;
      // Filter by product type
      const selectedTypes = productTypes
        .filter((t) => t.checked)
        .map((t) => t.name.toLowerCase());
      if (selectedTypes.length > 0) {
        const productCategories =
          product.productCategories?.nodes.map((cat) =>
            cat.name.toLowerCase(),
          ) || [];
        if (!selectedTypes.some((type) => productCategories.includes(type)))
          return false;
      }
      // Filter by size
      if (selectedSizes.length > 0) {
        const productSizes =
          product.allPaSizes?.nodes.map((node) => node.name) || [];
        if (!selectedSizes.some((size) => productSizes.includes(size)))
          return false;
      }
      // Filter by color
      if (selectedColors.length > 0) {
        const productColors =
          product.allPaColors?.nodes.map((node) => node.name) || [];
        if (!selectedColors.some((color) => productColors.includes(color)))
          return false;
      }
      return true;
    });
    // Sort products
    return [...(filtered || [])].sort((a, b) => {
      const priceA = parseFloat(a.price.replace(/[^0-9.]/g, ''));
      const priceB = parseFloat(b.price.replace(/[^0-9.]/g, ''));
      switch (sortBy) {
        case 'price-low':
          return priceA - priceB;
        case 'price-high':
          return priceB - priceA;
        case 'newest':
          return b.databaseId - a.databaseId;
        default: // 'popular'
          return 0;
      }
    });
  };
  return { sortBy, setSortBy, selectedSizes, setSelectedSizes, selectedColors, setSelectedColors, priceRange, setPriceRange, productTypes, toggleProductType, resetFilters, filterProducts, };
};

src\styles\globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
  --background: #ffffff;
  --foreground: #171717;
}
@media (prefers-color-scheme: dark) {
  :root {
    --background: #0a0a0a;
    --foreground: #ededed;
  }
}
body {
  color: var(--foreground);
  background: var(--background);
  font-family: Arial, Helvetica, sans-serif;
}
.bg-allpacolors-500 {
  background-color: pink;
}
.bg-green-500 {
  --tw-bg-opacity: 1;
  background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1));
}

src\types\product.ts

export interface Image {
  __typename: string;
  sourceUrl?: string;
}
export interface Node {
  __typename: string;
  price: string;
  regularPrice: string;
  salePrice?: string;
}
export interface ProductCategory {
  name: string;
  count: number;
  slug: string;
}
export interface ColorNode {
  name: string;
  slug: string;
}
export interface SizeNode {
  name: string;
}
export interface AttributeNode {
  name: string;
  value: string;
}
export interface Product {
  __typename: string;
  databaseId: number;
  name: string;
  onSale: boolean;
  slug: string;
  image: Image;
  price: string;
  regularPrice: string;
  salePrice?: string;
  productCategories?: {
    nodes: ProductCategory[];
  };
  allPaColors?: {
    nodes: ColorNode[];
  };
  allPaSizes?: {
    nodes: SizeNode[];
  };
  variations: {
    nodes: Array<{
      price: string;
      regularPrice: string;
      salePrice?: string;
      attributes?: {
        nodes: AttributeNode[];
      };
    }>;
  };
}
export interface ProductType {
  id: string;
  name: string;
  count: number;
  checked: boolean;
}

src\utils\functions\productUtils.ts

import { Product, ProductCategory, ProductType } from '@/types/product';
export const getUniqueProductTypes = (products: Product[]): ProductType[] => {
  // Use Map to ensure unique categories by slug
  const categoryMap = new Map<string, ProductType>();
  products?.forEach((product) => {
    product.productCategories?.nodes.forEach((cat: ProductCategory) => {
      if (!categoryMap.has(cat.slug)) {
        categoryMap.set(cat.slug, {
          id: cat.slug,
          name: cat.name,
          count: cat.count,
          checked: false,
        });
      }
    });
  });
  // Convert Map values to array and sort by name
  return Array.from(categoryMap.values()).sort((a, b) =>
    a.name.localeCompare(b.name),
  );
};

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
      }
    }
  }
`;

src\utils\gql\GQL_QUERIES.ts

import { gql } from '@apollo/client';
export const GET_SINGLE_PRODUCT = gql`
  query Product($id: ID!) {
    product(id: $id, idType: DATABASE_ID) {
      id
      databaseId
      averageRating
      slug
      description
      onSale
      image {
        id
        uri
        title
        srcSet
        sourceUrl
      }
      name
      ... on SimpleProduct {
        salePrice
        regularPrice
        price
        id
        stockQuantity
      }
      ... on VariableProduct {
        salePrice
        regularPrice
        price
        id
        allPaColors {
          nodes {
            name
          }
        }
        allPaSizes {
          nodes {
            name
          }
        }
        variations {
          nodes {
            id
            databaseId
            name
            stockStatus
            stockQuantity
            purchasable
            onSale
            salePrice
            regularPrice
          }
        }
      }
      ... on ExternalProduct {
        price
        id
        externalUrl
      }
      ... on GroupProduct {
        products {
          nodes {
            ... on SimpleProduct {
              id
              price
            }
          }
        }
        id
      }
    }
  }
`;
/**
 * Fetch first 4 products from a specific category
 */
export const FETCH_FIRST_PRODUCTS_FROM_HOODIES_QUERY = `
 query MyQuery {
  products(first: 4, where: {category: "Hoodies"}) {
    nodes {
      productId
      name
      onSale
      slug
      image {
        sourceUrl
      }
      ... on SimpleProduct {
        price
        regularPrice
        salePrice
      }
      ... on VariableProduct {
        price
        regularPrice
        salePrice
      }
    }
  }
}
 `;
/**
 * Fetch first 200 Woocommerce products from GraphQL
 */
export const FETCH_ALL_PRODUCTS_QUERY = gql`
  query MyQuery {
    products(first: 50) {
      nodes {
        databaseId
        name
        onSale
        slug
        image {
          sourceUrl
        }
        productCategories {
          nodes {
            name
            count
            slug
          }
        }
        ... on SimpleProduct {
          databaseId
          price
          regularPrice
          salePrice
        }
        ... on VariableProduct {
          databaseId
          price
          regularPrice
          salePrice
          allPaColors {
            nodes {
              name
              slug
            }
          }
          allPaSizes {
            nodes {
              name
            }
          }
          variations {
            nodes {
              price
              regularPrice
              salePrice
              attributes {
                nodes {
                  name
                  value
                }
              }
            }
          }
        }
      }
    }
  }
`;
/**
 * Fetch first 20 categories from GraphQL
 */
export const FETCH_ALL_CATEGORIES_QUERY = gql`
  query Categories {
    productCategories(first: 20) {
      nodes {
        id
        name
        count
        slug
      }
    }
  }
`;
export const GET_PRODUCTS_FROM_CATEGORY = gql`
  query ProductsFromCategory($id: ID!) {
    productCategory(id: $id) {
      id
      name
      count
      products(first: 50) {
        nodes {
          id
          databaseId
          onSale
          averageRating
          slug
          description
          image {
            id
            uri
            title
            srcSet
            sourceUrl
          }
          name
          ... on SimpleProduct {
            salePrice
            regularPrice
            onSale
            price
            id
          }
          ... on VariableProduct {
            salePrice
            regularPrice
            onSale
            price
            id
          }
          ... on ExternalProduct {
            price
            id
            externalUrl
          }
          ... on GroupProduct {
            products {
              nodes {
                ... on SimpleProduct {
                  id
                  price
                }
              }
            }
            id
          }
        }
      }
    }
  }
`;
export const GET_CART = gql`
  query GET_CART {
    cart {
      contents {
        nodes {
          key
          product {
            node {
              id
              databaseId
              name
              description
              type
              onSale
              slug
              averageRating
              reviewCount
              image {
                id
                sourceUrl
                srcSet
                altText
                title
              }
              galleryImages {
                nodes {
                  id
                  sourceUrl
                  srcSet
                  altText
                  title
                }
              }
            }
          }
          variation {
            node {
              id
              databaseId
              name
              description
              type
              onSale
              price
              regularPrice
              salePrice
              image {
                id
                sourceUrl
                srcSet
                altText
                title
              }
              attributes {
                nodes {
                  id
                  name
                  value
                }
              }
            }
          }
          quantity
          total
          subtotal
          subtotalTax
        }
      }
      subtotal
      subtotalTax
      shippingTax
      shippingTotal
      total
      totalTax
      feeTax
      feeTotal
      discountTax
      discountTotal
    }
  }
`;

.env

NEXT_PUBLIC_GRAPHQL_URL="https://wpclidemo.dev/graphql"
NEXT_PUBLIC_ALGOLIA_INDEX_NAME= "algolia"
NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL="https://res.cloudinary.com/placeholder-337_utsb7h.jpg"
NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL="https://via.placeholder.com/600"
NEXT_PUBLIC_ALGOLIA_APP_ID = "changeme"
NEXT_PUBLIC_ALGOLIA_PUBLIC_API_KEY = "changeme"
NODE_TLS_REJECT_UNAUTHORIZED=0
NODE_ENV="development"

next.config.ts

import type { NextConfig } from "next";
const nextConfig: NextConfig = {
  /* config options here */
  reactStrictMode: true,
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'swewoocommerce.dfweb.no',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'via.placeholder.com',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'funtapgames.com',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'wpclidemo.dev',
        pathname: '**',
      },
      {
        protocol: 'https',
        hostname: 'placeholder.pics',
        pathname: '**',
      },
    ],
  },
};
export default nextConfig;

package.json

{
  "name": "graphwoonext",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@apollo/client": "^3.12.11",
    "graphql": "^16.10.0",
    "lodash": "^4.17.21",
    "next": "15.1.7",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "uuid": "^11.0.5"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}

Last updated

Was this helpful?