Sử dụng Nextjs kết nối store woocomerce, category product, graphql Example 1 (ok)
Last updated
Was this helpful?
Last updated
Was this helpful?
Khai báo ... on SimpleProduct, ... on VariableProduct, ... on ExternalProduct, ... on VariableProduct xem ở đây nhé!
Khai báo mutation addToCart
Source wpclidemo-dev-graphql-woocomerce
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;
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, ' ');
}
if (regularPrice) {
regularPrice = paddedPrice(regularPrice, ' ');
}
if (salePrice) {
salePrice = paddedPrice(salePrice, ' ');
}
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
}
}
}
}
}
}
}
}
`;
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, ' ');
}
if (regularPrice) {
regularPrice = paddedPrice(regularPrice, ' ');
}
if (salePrice) {
salePrice = paddedPrice(salePrice, ' ');
}
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
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;
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,
};
};
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,
},
};
}
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, ' ');
}
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"
}
}