feat: handle product variations

This commit is contained in:
paolosantarsiero 2024-12-29 15:02:58 +01:00
parent 5880b80676
commit 87dd5ef8e8
28 changed files with 705 additions and 1161 deletions

View File

@ -1,3 +1,4 @@
import { storeApi } from 'lib/woocomerce/storeApi';
import { woocommerce } from 'lib/woocomerce/woocommerce'; import { woocommerce } from 'lib/woocomerce/woocommerce';
import { NextAuthOptions, Session, User } from 'next-auth'; import { NextAuthOptions, Session, User } from 'next-auth';
import { JWT } from 'next-auth/jwt'; import { JWT } from 'next-auth/jwt';
@ -42,6 +43,15 @@ export const authOptions = {
console.debug('Set session token', token.user); console.debug('Set session token', token.user);
session.user = token.user; session.user = token.user;
return session; return session;
},
},
events: {
async signIn() {
storeApi._seCartToken('');
},
async signOut() {
storeApi._seCartToken('');
storeApi._setAuthorizationToken('');
} }
} }
} satisfies NextAuthOptions; } satisfies NextAuthOptions;

View File

@ -6,11 +6,7 @@ import { authOptions } from '../auth/[...nextauth]/route';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (session?.user?.token) { storeApi._setAuthorizationToken(session?.user?.token ?? '');
storeApi._setAuthorizationToken(session.user.token);
} else {
storeApi._setAuthorizationToken('');
}
const cart = await storeApi.getCart(); const cart = await storeApi.getCart();
return NextResponse.json(cart, { status: 200 }); return NextResponse.json(cart, { status: 200 });
} catch (error) { } catch (error) {
@ -24,7 +20,7 @@ export async function POST(req: NextRequest) {
const cart = await storeApi.addToCart({ id, quantity, variation }); const cart = await storeApi.addToCart({ id, quantity, variation });
return NextResponse.json(cart, { status: 200 }); return NextResponse.json(cart, { status: 200 });
} catch (error) { } catch (error) {
return NextResponse.json({ error: 'Failed to add item to cart' }, { status: 500 }); return NextResponse.json({ error: 'Failed to add item to cart', message: JSON.stringify(error) }, { status: 500 });
} }
} }
@ -39,7 +35,7 @@ export async function PUT(req: NextRequest) {
return NextResponse.json(cart, { status: 200 }); return NextResponse.json(cart, { status: 200 });
} }
} catch (error) { } catch (error) {
return NextResponse.json({ error: 'Failed to update cart item' }, { status: 500 }); return NextResponse.json({ error: 'Failed to update cart item', message: JSON.stringify(error) }, { status: 500 });
} }
} }
@ -49,6 +45,6 @@ export async function DELETE(req: NextRequest) {
const cart = await storeApi.removeFromCart({ key }); const cart = await storeApi.removeFromCart({ key });
return NextResponse.json(cart, { status: 200 }); return NextResponse.json(cart, { status: 200 });
} catch (error) { } catch (error) {
return NextResponse.json({ error: 'Failed to remove item from cart' }, { status: 500 }); return NextResponse.json({ error: 'Failed to remove item from cart', message: JSON.stringify(error) }, { status: 500 });
} }
} }

12
app/api/customer/route.ts Normal file
View File

@ -0,0 +1,12 @@
import { woocommerce } from "lib/woocomerce/woocommerce";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const data = await req.json();
const cart = await woocommerce.post('customers', data);
return NextResponse.json(cart, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to add item to cart' }, { status: 500 });
}
}

View File

@ -4,7 +4,6 @@ import { NextAuthProvider } from 'components/next-session-provider';
import { WelcomeToast } from 'components/welcome-toast'; import { WelcomeToast } from 'components/welcome-toast';
import { GeistSans } from 'geist/font/sans'; import { GeistSans } from 'geist/font/sans';
import { ensureStartsWith } from 'lib/utils'; import { ensureStartsWith } from 'lib/utils';
import { storeApi } from 'lib/woocomerce/storeApi';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import './globals.css'; import './globals.css';
@ -37,13 +36,11 @@ export const metadata = {
}; };
export default async function RootLayout({ children }: { children: ReactNode }) { export default async function RootLayout({ children }: { children: ReactNode }) {
const cart = await storeApi.getCart();
return ( return (
<html lang="en" className={GeistSans.variable}> <html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white"> <body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<NextAuthProvider> <NextAuthProvider>
<CartProvider value={cart}> <CartProvider>
<Navbar /> <Navbar />
<main> <main>
{children} {children}

View File

@ -7,22 +7,24 @@ import { useState } from 'react';
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const { replace } = useRouter(); const [error, setError] = useState('');
const router = useRouter();
const handleLogin = async (event: React.FormEvent) => { const handleLogin = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
try { const res = await signIn('credentials', { username, password, redirect: false, });
await signIn('credentials', { username, password, redirect: false }); if (res?.ok) {
replace('/'); router.replace('/');
} catch (error) { } else {
console.error(error); setError('Invalid username or password');
} }
}; };
return ( return (
<section className="mx-auto mt-4 grid max-w-screen-2xl justify-center gap-4 px-4 pb-4"> <section className="mx-auto mt-4 grid max-w-screen-2xl justify-center gap-4 px-4 pb-4">
<h1 className="text-2xl font-bold">Login</h1> <h1 className="text-2xl font-bold">Login</h1>
<div className="flex h-screen justify-center"> <div className="flex flex-col h-screen w-full max-w-md">
{error && <p className="text-red-500">{error}</p>}
<form onSubmit={handleLogin}> <form onSubmit={handleLogin}>
<div className="mt-4"> <div className="mt-4">
<label <label
@ -64,6 +66,13 @@ export default function LoginPage() {
Login Login
</button> </button>
</div> </div>
<span className="block mt-6 text-center text-sm text-gray-600 dark:text-gray-300">
Don't have an account?{' '}
<a href="/signup" className="text-indigo-600 hover:underline">
Sign up
</a>
</span>
</form> </form>
</div> </div>
</section> </section>

View File

@ -1,13 +1,15 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { AddToCart } from 'components/cart/add-to-cart';
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery'; import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context'; import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description'; import { ProductDescription } from 'components/product/product-description';
import { VariantSelector } from 'components/product/variant-selector';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { Image } from 'lib/woocomerce/models/base'; import { Image } from 'lib/woocomerce/models/base';
import { Product } from 'lib/woocomerce/models/product'; import { Product, ProductVariations } from 'lib/woocomerce/models/product';
import { woocommerce } from 'lib/woocomerce/woocommerce'; import { woocommerce } from 'lib/woocomerce/woocommerce';
import { Suspense } from 'react'; import { Suspense } from 'react';
@ -42,6 +44,10 @@ export default async function ProductPage(props: { params: Promise<{ name: strin
const product: Product | undefined = ( const product: Product | undefined = (
await woocommerce.get('products', { slug: params.name }) await woocommerce.get('products', { slug: params.name })
)?.[0]; )?.[0];
let variations: ProductVariations[] = [];
if (product?.variations?.length) {
variations = await woocommerce.get(`products/${product?.id}/variations`);
}
if (!product) return notFound(); if (!product) return notFound();
@ -88,9 +94,15 @@ export default async function ProductPage(props: { params: Promise<{ name: strin
</div> </div>
<div className="basis-full lg:basis-2/6"> <div className="basis-full lg:basis-2/6">
{variations && (
<Suspense fallback={null}>
<VariantSelector options={product.attributes} variations={variations} />
</Suspense>
)}
<Suspense fallback={null}> <Suspense fallback={null}>
<ProductDescription product={product} /> <ProductDescription product={product} />
</Suspense> </Suspense>
<AddToCart product={product} variations={variations}/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,7 +9,6 @@ export default async function OrderPage(props: { params: Promise<{ id: number }>
const data = await getServerSession(authOptions); const data = await getServerSession(authOptions);
try { try {
const order = await woocommerce.get('orders', { id: params.id }); const order = await woocommerce.get('orders', { id: params.id });
console.log(order);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@ -13,9 +13,9 @@ export default async function SearchPage(props: {
}) { }) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const { sort, q: searchValue } = searchParams as { [key: string]: string }; const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; const { sortKey, order } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await woocommerce.get('products', { search: searchValue, orderby: sortKey }); const products = await woocommerce.get('products', { search: searchValue, orderby: sortKey, order });
const resultsText = products.length > 1 ? 'results' : 'result'; const resultsText = products.length > 1 ? 'results' : 'result';
return ( return (

144
app/signup/page.tsx Normal file
View File

@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { z } from 'zod';
type FormData = {
username: string;
email: string;
password: string;
confirmPassword: string;
}
const customerSchema = z.object({
username: z.string().min(3),
email: z.string().email({ message: "Invalid email" }),
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});;
export default function SignUpPage() {
const initialState = { username: '', email: '', password: '', confirmPassword: '' };
const [formData, setFormData] = useState<FormData>(initialState);
const [error, setError] = useState<FormData>(initialState);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
}
const handleSignup = async (event: React.FormEvent) => {
event.preventDefault();
try {
customerSchema.parse(formData);
setError(initialState);
await fetch('/api/customer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: formData.username,
first_name: '',
last_name: '',
email: formData.email,
password: formData.password
}),
});
} catch (error) {
if (error instanceof z.ZodError) {
const errorObj: FormData = initialState;
error.errors.forEach((err) => {
const key = err.path[0] as keyof FormData;
errorObj[key] = err.message as string;
});
console.log(errorObj);
setError(errorObj);
}
}
};
return (
<section className="mx-auto mt-4 grid max-w-screen-2xl justify-center gap-4 px-4 pb-4">
<h1 className="text-2xl font-bold">Sign up</h1>
<div className="flex h-screen justify-center">
<form onSubmit={handleSignup}>
<div className="mt-4">
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Username
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 p-3 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-lg"
/>
{error['username'] && <p className="text-red-500">{error['username']}</p>}
</div>
<div className="mt-4">
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 p-3 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-lg"
/>
{error['email'] && <p className="text-red-500">{error['email']}</p>}
</div>
<div className="mt-4">
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 p-3 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-lg"
/>
{error['password'] && <p className="text-red-500">{error['password']}</p>}
</div>
<div className="mt-4">
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Confirm Password
</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 p-3 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-lg"
/>
{error['confirmPassword'] && <p className="text-red-500">{error['confirmPassword']}</p>}
</div>
<div className="mt-6">
<button
type="submit"
className="flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-3 text-lg font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Sign up
</button>
</div>
</form>
</div>
</section>
);
}

View File

@ -1,12 +1,17 @@
'use client'; 'use client';
import { signOut } from 'next-auth/react'; import { signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export default function LogoutButton() { export default function LogoutButton() {
const router = useRouter();
return ( return (
<button <button
type="button" type="button"
className="w-full rounded-md bg-indigo-500 p-3 text-white" className="w-full rounded-md bg-indigo-500 p-3 text-white"
onClick={() => signOut({ callbackUrl: '/' })} onClick={() => {
signOut({ redirect: false });
router.replace('/')
}}
> >
Logout Logout
</button> </button>

View File

@ -2,15 +2,17 @@
import { PlusIcon } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { Product } from 'lib/woocomerce/models/product'; import { useProduct } from 'components/product/product-context';
import { Product, ProductVariations } from 'lib/woocomerce/models/product';
import { useMemo } from 'react';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
function SubmitButton({}: {}) { function SubmitButton({disabled = false}: {disabled: boolean}) {
const buttonClasses = const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
return ( return (
<button aria-label="Please select an option" className={clsx(buttonClasses)}> <button aria-label="Please select an option" disabled={disabled} className={clsx(buttonClasses)}>
<div className="absolute left-0 ml-4"> <div className="absolute left-0 ml-4">
<PlusIcon className="h-5" /> <PlusIcon className="h-5" />
</div> </div>
@ -19,8 +21,23 @@ function SubmitButton({}: {}) {
); );
} }
export function AddToCart({ product }: { product: Product }) { export function AddToCart({ product, variations }: { product: Product, variations?: ProductVariations[] }) {
const { setNewCart } = useCart(); const { setNewCart } = useCart();
const {state} = useProduct();
const productVariant = useMemo(() => {
const keys = Object.keys(state).filter((key) => key !== 'id' && key !== 'image').map((key) => ({
attribute: key.toLowerCase(),
value: state[key]
}));
const productExist = variations?.find((variation) => {
const attributes = variation.attributes.map((attr) => ({name: attr.name, option: attr.option})) || [];
return attributes.every((attribute) => attribute.option === keys.find((key) => key.attribute === attribute.name)?.value);
});
return productExist ? keys : [];
}, [state, variations]);
return ( return (
<form <form
@ -29,7 +46,7 @@ export function AddToCart({ product }: { product: Product }) {
const cart = await ( const cart = await (
await fetch('/api/cart', { await fetch('/api/cart', {
method: 'POST', method: 'POST',
body: JSON.stringify({ id: product.id, quantity: 1, variation: [] }) body: JSON.stringify({ id: product.id, quantity: 1, variation: productVariant })
}) })
).json(); ).json();
setNewCart(cart); setNewCart(cart);
@ -38,7 +55,7 @@ export function AddToCart({ product }: { product: Product }) {
} }
}} }}
> >
<SubmitButton /> <SubmitButton disabled={variations?.length && !productVariant.length ? true : false}/>
</form> </form>
); );
} }

View File

@ -3,16 +3,6 @@
import { Cart } from 'lib/woocomerce/models/cart'; import { Cart } from 'lib/woocomerce/models/cart';
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
type UpdateType = 'plus' | 'minus' | 'delete';
type UpdatePayload = { key: string | number; quantity: number };
type AddPayload = {
id: string | number;
quantity: number;
variation: { attribute: string; value: string }[];
};
type RemovePayload = { key: string | number };
type CartContextType = { type CartContextType = {
cart: Cart | undefined; cart: Cart | undefined;
setNewCart: (cart: Cart) => void; setNewCart: (cart: Cart) => void;
@ -20,15 +10,24 @@ type CartContextType = {
const CartContext = createContext<CartContextType | undefined>(undefined); const CartContext = createContext<CartContextType | undefined>(undefined);
export function CartProvider({ value, children }: { value: Cart; children: React.ReactNode }) { export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<Cart | undefined>(value); const [cart, setCart] = useState<Cart | undefined>(undefined);
const setNewCart = (cart: Cart) => { const setNewCart = (cart: Cart) => {
setCart(cart); setCart(cart);
}; };
const fetchCart = async () => {
try {
const cart = await (await fetch('/api/cart')).json();
setNewCart(cart);
} catch (err) {
console.error(err);
}
}
useEffect(() => { useEffect(() => {
setCart(value); fetchCart();
}, [value]); }, []);
return ( return (
<CartContext.Provider <CartContext.Provider

View File

@ -113,6 +113,11 @@ export default function CartModal() {
> >
<div className="flex flex-1 flex-col text-base"> <div className="flex flex-1 flex-col text-base">
<span className="leading-tight">{item.name}</span> <span className="leading-tight">{item.name}</span>
{item.variation.map((variation, i) => (
<span key={i} className="text-sm text-neutral-500 dark:text-neutral-400">
{variation.attribute}: {variation.value}
</span>
))}
</div> </div>
</Link> </Link>
</div> </div>

View File

@ -48,7 +48,7 @@ export async function ThreeItemGrid() {
const [firstProduct, secondProduct, thirdProduct] = products; const [firstProduct, secondProduct, thirdProduct] = products;
return ( return (
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]"> <section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
{products.map((product, index) => ( {products.map((product, index) => (
<ThreeItemGridItem key={product.id} size={index === 0 ? 'full' : 'half'} item={product} /> <ThreeItemGridItem key={product.id} size={index === 0 ? 'full' : 'half'} item={product} />
))} ))}

View File

@ -1,13 +1,9 @@
import { UserCircleIcon } from '@heroicons/react/24/outline'; import { UserCircleIcon } from '@heroicons/react/24/outline';
import { authOptions } from 'app/api/auth/[...nextauth]/route';
import { getServerSession } from 'next-auth';
import Link from 'next/link'; import Link from 'next/link';
export default async function UserIcon() { export default async function UserIcon() {
const isAuthenticated = (await getServerSession(authOptions))?.user?.token;
return ( return (
<Link href={!isAuthenticated ? '/login' : '/profile'} className="ms-2" aria-label="login"> <Link href={'/login'} className="ms-2" aria-label="login">
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"> <div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<UserCircleIcon className="h-4 transition-all ease-in-out hover:scale-110" /> <UserCircleIcon className="h-4 transition-all ease-in-out hover:scale-110" />
</div> </div>

View File

@ -7,5 +7,6 @@ type Props = {
}; };
export const NextAuthProvider = ({ children }: Props) => { export const NextAuthProvider = ({ children }: Props) => {
return <SessionProvider>{children}</SessionProvider>; return <SessionProvider>{children}</SessionProvider>;
}; };

View File

@ -52,7 +52,7 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
() => ({ () => ({
state, state,
updateOption, updateOption,
updateImage updateImage,
}), }),
[state] [state]
); );

View File

@ -1,4 +1,3 @@
import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price'; import Price from 'components/price';
import Prose from 'components/prose'; import Prose from 'components/prose';
import { Product } from 'lib/woocomerce/models/product'; import { Product } from 'lib/woocomerce/models/product';
@ -18,7 +17,6 @@ export function ProductDescription({ product }: { product: Product }) {
html={product.description} html={product.description}
/> />
) : null} ) : null}
<AddToCart product={product} />
</> </>
); );
} }

View File

@ -2,64 +2,40 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useProduct, useUpdateURL } from 'components/product/product-context'; import { useProduct, useUpdateURL } from 'components/product/product-context';
import { ProductOption, ProductVariant } from 'lib/shopify/types'; import { Attribute } from 'lib/woocomerce/models/base';
import { ProductVariations } from 'lib/woocomerce/models/product';
type Combination = {
id: string; type FilterVariation = {
availableForSale: boolean; name: string | undefined;
[key: string]: string | boolean; values: string[] | undefined;
}; };
export function VariantSelector({ export function VariantSelector({
options, options,
variants variations,
}: { }: {
options: ProductOption[]; options: Partial<Attribute>[];
variants: ProductVariant[]; variations: ProductVariations[];
}) { }) {
const { state, updateOption } = useProduct(); const { state, updateOption } = useProduct();
const updateURL = useUpdateURL(); const updateURL = useUpdateURL();
const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1);
if (hasNoOptionsOrJustOneOption) { const combinations: FilterVariation[] = options?.map(attribute => ({
return null; name: attribute.name,
} values: attribute?.options?.map(option => option),
const combinations: Combination[] = variants.map((variant) => ({
id: variant.id,
availableForSale: variant.availableForSale,
...variant.selectedOptions.reduce(
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
{}
)
})); }));
return options.map((option) => ( return combinations.map((option) => (
<form key={option.id}> <form key={option.name}>
<dl className="mb-8"> <dl className="mb-8">
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt> <dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
<dd className="flex flex-wrap gap-3"> <dd className="flex flex-wrap gap-3">
{option.values.map((value) => { {option?.values?.map((value) => {
const optionNameLowerCase = option.name.toLowerCase(); const optionNameLowerCase = option?.name?.toLowerCase();
// Base option params on current selectedOptions so we can preserve any other param state.
const optionParams = { ...state, [optionNameLowerCase]: value };
// Filter out invalid options and check if the option combination is available for sale.
const filtered = Object.entries(optionParams).filter(([key, value]) =>
options.find(
(option) => option.name.toLowerCase() === key && option.values.includes(value)
)
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale
)
);
// The option is active if it's in the selected options. // The option is active if it's in the selected options.
const isActive = state[optionNameLowerCase] === value; const isActive = optionNameLowerCase ? state[optionNameLowerCase] === value : false;
if (!optionNameLowerCase) return null;
return ( return (
<button <button
@ -68,17 +44,15 @@ export function VariantSelector({
updateURL(newState); updateURL(newState);
}} }}
key={value} key={value}
aria-disabled={!isAvailableForSale} title={`${option.name} ${value}`}
disabled={!isAvailableForSale}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
className={clsx( className={clsx(
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900', 'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
{ {
'cursor-default ring-2 ring-blue-600': isActive, 'cursor-default ring-2 ring-blue-600': isActive,
'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600': 'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600':
!isActive && isAvailableForSale, !isActive,
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700': 'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
!isAvailableForSale ''
} }
)} )}
> >

View File

@ -1,23 +1,23 @@
export type SortFilterItem = { export type SortFilterItem = {
title: string; title: string;
slug: string | null; slug: string | null;
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE'; sortKey: 'rating' | 'popularity' | 'date' | 'price';
reverse: boolean; order?: 'asc' | 'desc';
}; };
export const defaultSort: SortFilterItem = { export const defaultSort: SortFilterItem = {
title: 'Relevance', title: 'Relevance',
slug: null, slug: null,
sortKey: 'RELEVANCE', sortKey: 'popularity',
reverse: false order: 'desc'
}; };
export const sorting: SortFilterItem[] = [ export const sorting: SortFilterItem[] = [
defaultSort, defaultSort,
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc { title: 'Trending', slug: 'trending-desc', sortKey: 'rating', order: 'desc' }, // asc
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true }, { title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'date', order: 'desc' },
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc { title: 'Price: Low to high', slug: 'price-asc', sortKey: 'price', order: 'asc' }, // asc
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true } { title: 'Price: High to low', slug: 'price-desc', sortKey: 'price', order: 'desc' }
]; ];
export const TAGS = { export const TAGS = {

View File

@ -45,5 +45,6 @@ export type Attribute = {
export type Default_Attribute = { export type Default_Attribute = {
id: number; id: number;
name: string; name: string;
slug: string;
option: string; option: string;
}; };

View File

@ -158,7 +158,6 @@ export default class WooCommerceRestApi<T extends WooRestApiOptions> {
} }
const query = new Url(url, true).query; // Parse the query string returned by the url const query = new Url(url, true).query; // Parse the query string returned by the url
// console.log("params:", params);
const values = []; const values = [];
let queryString = ''; let queryString = '';
@ -225,11 +224,13 @@ export default class WooCommerceRestApi<T extends WooRestApiOptions> {
delete params.id; delete params.id;
} }
// Add query params to url const queryParams: string[] = [];
if (Object.keys(params).length !== 0) {
for (const key in params) { for (const key in params) {
url = url + '?' + key + '=' + params[key]; queryParams.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key] as string | number | boolean)}`);
} }
if (queryParams.length > 0) {
url += '?' + queryParams.join('&');
} }
/** /**
@ -247,6 +248,7 @@ export default class WooCommerceRestApi<T extends WooRestApiOptions> {
// url = this._normalizeQueryString(url, params); // url = this._normalizeQueryString(url, params);
// return url; // return url;
// } // }
return url; return url;
} }

View File

@ -131,7 +131,7 @@ export interface ProductVariations {
shipping_class: string; shipping_class: string;
shipping_class_id: number; shipping_class_id: number;
image: Partial<Image>; image: Partial<Image>;
attributes: Partial<Attribute>[]; attributes: Partial<Default_Attribute>[];
menu_order: number; menu_order: number;
meta_data: Partial<Meta_Data>[]; meta_data: Partial<Meta_Data>[];
} }
@ -143,6 +143,7 @@ export interface ProductAttributes {
type: string; type: string;
order_by: string; order_by: string;
has_archives: boolean; has_archives: boolean;
options: string[];
} }
export interface ProductAttributesTerms { export interface ProductAttributesTerms {

View File

@ -6,7 +6,7 @@ import { Cart } from './models/cart';
* To use this in the client-side, you need to create a new route of api endpoint in your Next.js app. * To use this in the client-side, you need to create a new route of api endpoint in your Next.js app.
*/ */
class WooCommerceStoreApiClient { class WooCommerceStoreApiClient {
public client: AxiosInstance; private client: AxiosInstance;
constructor(baseURL: string) { constructor(baseURL: string) {
const headers: RawAxiosRequestHeaders = { const headers: RawAxiosRequestHeaders = {
@ -18,23 +18,31 @@ class WooCommerceStoreApiClient {
baseURL, baseURL,
headers headers
}); });
this.client.interceptors.response.use((response) => {
console.log('cart-token', response.headers['cart-token']);
this.client.defaults.headers['cart-token'] = response.headers['cart-token'];
return response;
});
} }
_setAuthorizationToken(token: string) { _setAuthorizationToken(token: string) {
if (token) { if (token) {
this.client.defaults.headers['Authorization'] = `Bearer ${token}`; this.client.defaults.headers['Authorization'] = `Bearer ${token}`;
} else {
this._deleteAuthorizationToken();
} }
} }
_deleteAuthorizationToken() {
this.client.defaults.headers['Authorization'] = '';
}
_seCartToken(cartToken: string) {
this.client.defaults.headers['cart-token'] = cartToken;
}
async getCart(params?: Record<string, string | number>): Promise<Cart> { async getCart(params?: Record<string, string | number>): Promise<Cart> {
return this.client.get<Cart>('/cart', { params }).then((response) => response.data); return this.client.get<Cart>('/cart', { params }).then(async (response) => {
this._seCartToken(response.headers['cart-token']);
return response.data;
});
} }
async addToCart(payload: { async addToCart(payload: {
@ -57,6 +65,6 @@ class WooCommerceStoreApiClient {
} }
// Example usage. // Example usage.
const baseURL = 'http://wordpress.localhost/wp-json/wc/store/v1'; // Replace with your WooCommerce API URL. const baseURL = 'http://wordpress.localhost/wp-json/wc/store/v1';
export const storeApi = new WooCommerceStoreApiClient(baseURL); export const storeApi = new WooCommerceStoreApiClient(baseURL);

View File

@ -3,9 +3,9 @@ import WooCommerceRestApi, { WooRestApiOptions } from './models/client';
const option: WooRestApiOptions = { const option: WooRestApiOptions = {
url: process.env.WOOCOMMERCE_URL ?? 'http://wordpress.localhost', url: process.env.WOOCOMMERCE_URL ?? 'http://wordpress.localhost',
consumerKey: consumerKey:
process.env.WOOCOMMERCE_CONSUMER_KEY ?? 'ck_1fb0a3c9b50ae813c31c7effc086a809d8416d90', process.env.WOOCOMMERCE_CONSUMER_KEY ?? 'ck_2307cad3b7ab10eb2c439fd8c50ef69740967768',
consumerSecret: consumerSecret:
process.env.WOOCOMMERCE_CONSUMER_SECRET ?? 'cs_ee4f1c9e061d07a7cb6025b69d414189a9157e20', process.env.WOOCOMMERCE_CONSUMER_SECRET ?? 'cs_2e2e94e6b9507cca5f7080ff8f856ac84c7b72d5',
isHttps: false, isHttps: false,
version: 'wc/v3', version: 'wc/v3',
queryStringAuth: false // Force Basic Authentication as query string true and using under queryStringAuth: false // Force Basic Authentication as query string true and using under

27
middleware.ts Normal file
View File

@ -0,0 +1,27 @@
import { getToken } from 'next-auth/jwt';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// Lista delle pagine protette
const protectedRoutes = ['/profile'];
export async function middleware(req: NextRequest) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
console.log('token', token);
const isProtectedRoute = protectedRoutes.some((route) => req.nextUrl.pathname.startsWith(route));
if (isProtectedRoute && !token) {
const loginUrl = new URL('/login', req.url);
return NextResponse.redirect(loginUrl);
}
if (req.nextUrl.pathname.startsWith('/login') && token) {
const profileUrl = new URL('/profile', req.url);
return NextResponse.redirect(profileUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/login', '/profile', '/profile/:path*']
};

View File

@ -17,7 +17,8 @@
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"sonner": "^1.7.0" "sonner": "^1.7.0",
"zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",

1146
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff