mirror of
https://github.com/vercel/commerce.git
synced 2025-06-28 01:11:24 +00:00
feat: handle product variations
This commit is contained in:
parent
5880b80676
commit
87dd5ef8e8
@ -1,3 +1,4 @@
|
||||
import { storeApi } from 'lib/woocomerce/storeApi';
|
||||
import { woocommerce } from 'lib/woocomerce/woocommerce';
|
||||
import { NextAuthOptions, Session, User } from 'next-auth';
|
||||
import { JWT } from 'next-auth/jwt';
|
||||
@ -42,6 +43,15 @@ export const authOptions = {
|
||||
console.debug('Set session token', token.user);
|
||||
session.user = token.user;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async signIn() {
|
||||
storeApi._seCartToken('');
|
||||
},
|
||||
async signOut() {
|
||||
storeApi._seCartToken('');
|
||||
storeApi._setAuthorizationToken('');
|
||||
}
|
||||
}
|
||||
} satisfies NextAuthOptions;
|
||||
|
@ -6,11 +6,7 @@ import { authOptions } from '../auth/[...nextauth]/route';
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.token) {
|
||||
storeApi._setAuthorizationToken(session.user.token);
|
||||
} else {
|
||||
storeApi._setAuthorizationToken('');
|
||||
}
|
||||
storeApi._setAuthorizationToken(session?.user?.token ?? '');
|
||||
const cart = await storeApi.getCart();
|
||||
return NextResponse.json(cart, { status: 200 });
|
||||
} catch (error) {
|
||||
@ -24,7 +20,7 @@ export async function POST(req: NextRequest) {
|
||||
const cart = await storeApi.addToCart({ id, quantity, variation });
|
||||
return NextResponse.json(cart, { status: 200 });
|
||||
} 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 });
|
||||
}
|
||||
} 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 });
|
||||
return NextResponse.json(cart, { status: 200 });
|
||||
} 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
12
app/api/customer/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -4,7 +4,6 @@ import { NextAuthProvider } from 'components/next-session-provider';
|
||||
import { WelcomeToast } from 'components/welcome-toast';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { ensureStartsWith } from 'lib/utils';
|
||||
import { storeApi } from 'lib/woocomerce/storeApi';
|
||||
import { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import './globals.css';
|
||||
@ -37,13 +36,11 @@ export const metadata = {
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const cart = await storeApi.getCart();
|
||||
|
||||
return (
|
||||
<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">
|
||||
<NextAuthProvider>
|
||||
<CartProvider value={cart}>
|
||||
<CartProvider>
|
||||
<Navbar />
|
||||
<main>
|
||||
{children}
|
||||
|
@ -7,22 +7,24 @@ import { useState } from 'react';
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { replace } = useRouter();
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await signIn('credentials', { username, password, redirect: false });
|
||||
replace('/');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const res = await signIn('credentials', { username, password, redirect: false, });
|
||||
if (res?.ok) {
|
||||
router.replace('/');
|
||||
} else {
|
||||
setError('Invalid username or password');
|
||||
}
|
||||
};
|
||||
|
||||
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">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}>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
@ -64,6 +66,13 @@ export default function LoginPage() {
|
||||
Login
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -1,13 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Gallery } from 'components/product/gallery';
|
||||
import { ProductProvider } from 'components/product/product-context';
|
||||
import { ProductDescription } from 'components/product/product-description';
|
||||
import { VariantSelector } from 'components/product/variant-selector';
|
||||
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||
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 { Suspense } from 'react';
|
||||
|
||||
@ -42,6 +44,10 @@ export default async function ProductPage(props: { params: Promise<{ name: strin
|
||||
const product: Product | undefined = (
|
||||
await woocommerce.get('products', { slug: params.name })
|
||||
)?.[0];
|
||||
let variations: ProductVariations[] = [];
|
||||
if (product?.variations?.length) {
|
||||
variations = await woocommerce.get(`products/${product?.id}/variations`);
|
||||
}
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
@ -88,9 +94,15 @@ export default async function ProductPage(props: { params: Promise<{ name: strin
|
||||
</div>
|
||||
|
||||
<div className="basis-full lg:basis-2/6">
|
||||
{variations && (
|
||||
<Suspense fallback={null}>
|
||||
<VariantSelector options={product.attributes} variations={variations} />
|
||||
</Suspense>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
<ProductDescription product={product} />
|
||||
</Suspense>
|
||||
<AddToCart product={product} variations={variations}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,7 +9,6 @@ export default async function OrderPage(props: { params: Promise<{ id: number }>
|
||||
const data = await getServerSession(authOptions);
|
||||
try {
|
||||
const order = await woocommerce.get('orders', { id: params.id });
|
||||
console.log(order);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
@ -13,9 +13,9 @@ export default async function SearchPage(props: {
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
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';
|
||||
|
||||
return (
|
||||
|
144
app/signup/page.tsx
Normal file
144
app/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,12 +1,17 @@
|
||||
'use client';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LogoutButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md bg-indigo-500 p-3 text-white"
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
onClick={() => {
|
||||
signOut({ redirect: false });
|
||||
router.replace('/')
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
|
@ -2,15 +2,17 @@
|
||||
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
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';
|
||||
|
||||
function SubmitButton({}: {}) {
|
||||
function SubmitButton({disabled = false}: {disabled: boolean}) {
|
||||
const buttonClasses =
|
||||
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
|
||||
|
||||
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">
|
||||
<PlusIcon className="h-5" />
|
||||
</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 {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 (
|
||||
<form
|
||||
@ -29,7 +46,7 @@ export function AddToCart({ product }: { product: Product }) {
|
||||
const cart = await (
|
||||
await fetch('/api/cart', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: product.id, quantity: 1, variation: [] })
|
||||
body: JSON.stringify({ id: product.id, quantity: 1, variation: productVariant })
|
||||
})
|
||||
).json();
|
||||
setNewCart(cart);
|
||||
@ -38,7 +55,7 @@ export function AddToCart({ product }: { product: Product }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SubmitButton />
|
||||
<SubmitButton disabled={variations?.length && !productVariant.length ? true : false}/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
@ -3,16 +3,6 @@
|
||||
import { Cart } from 'lib/woocomerce/models/cart';
|
||||
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 = {
|
||||
cart: Cart | undefined;
|
||||
setNewCart: (cart: Cart) => void;
|
||||
@ -20,15 +10,24 @@ type CartContextType = {
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
|
||||
export function CartProvider({ value, children }: { value: Cart; children: React.ReactNode }) {
|
||||
const [cart, setCart] = useState<Cart | undefined>(value);
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const [cart, setCart] = useState<Cart | undefined>(undefined);
|
||||
const setNewCart = (cart: Cart) => {
|
||||
setCart(cart);
|
||||
};
|
||||
|
||||
const fetchCart = async () => {
|
||||
try {
|
||||
const cart = await (await fetch('/api/cart')).json();
|
||||
setNewCart(cart);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setCart(value);
|
||||
}, [value]);
|
||||
fetchCart();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CartContext.Provider
|
||||
|
@ -113,6 +113,11 @@ export default function CartModal() {
|
||||
>
|
||||
<div className="flex flex-1 flex-col text-base">
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -48,7 +48,7 @@ export async function ThreeItemGrid() {
|
||||
const [firstProduct, secondProduct, thirdProduct] = products;
|
||||
|
||||
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) => (
|
||||
<ThreeItemGridItem key={product.id} size={index === 0 ? 'full' : 'half'} item={product} />
|
||||
))}
|
||||
|
@ -1,13 +1,9 @@
|
||||
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';
|
||||
|
||||
export default async function UserIcon() {
|
||||
const isAuthenticated = (await getServerSession(authOptions))?.user?.token;
|
||||
|
||||
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">
|
||||
<UserCircleIcon className="h-4 transition-all ease-in-out hover:scale-110" />
|
||||
</div>
|
||||
|
@ -7,5 +7,6 @@ type Props = {
|
||||
};
|
||||
|
||||
export const NextAuthProvider = ({ children }: Props) => {
|
||||
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
};
|
||||
|
@ -52,7 +52,7 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
|
||||
() => ({
|
||||
state,
|
||||
updateOption,
|
||||
updateImage
|
||||
updateImage,
|
||||
}),
|
||||
[state]
|
||||
);
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Price from 'components/price';
|
||||
import Prose from 'components/prose';
|
||||
import { Product } from 'lib/woocomerce/models/product';
|
||||
@ -18,7 +17,6 @@ export function ProductDescription({ product }: { product: Product }) {
|
||||
html={product.description}
|
||||
/>
|
||||
) : null}
|
||||
<AddToCart product={product} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,64 +2,40 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
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;
|
||||
availableForSale: boolean;
|
||||
[key: string]: string | boolean;
|
||||
|
||||
type FilterVariation = {
|
||||
name: string | undefined;
|
||||
values: string[] | undefined;
|
||||
};
|
||||
|
||||
export function VariantSelector({
|
||||
options,
|
||||
variants
|
||||
variations,
|
||||
}: {
|
||||
options: ProductOption[];
|
||||
variants: ProductVariant[];
|
||||
options: Partial<Attribute>[];
|
||||
variations: ProductVariations[];
|
||||
}) {
|
||||
const { state, updateOption } = useProduct();
|
||||
const updateURL = useUpdateURL();
|
||||
const hasNoOptionsOrJustOneOption =
|
||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||
|
||||
if (hasNoOptionsOrJustOneOption) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const combinations: Combination[] = variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
availableForSale: variant.availableForSale,
|
||||
...variant.selectedOptions.reduce(
|
||||
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||
{}
|
||||
)
|
||||
const combinations: FilterVariation[] = options?.map(attribute => ({
|
||||
name: attribute.name,
|
||||
values: attribute?.options?.map(option => option),
|
||||
}));
|
||||
|
||||
return options.map((option) => (
|
||||
<form key={option.id}>
|
||||
return combinations.map((option) => (
|
||||
<form key={option.name}>
|
||||
<dl className="mb-8">
|
||||
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||
<dd className="flex flex-wrap gap-3">
|
||||
{option.values.map((value) => {
|
||||
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
|
||||
)
|
||||
);
|
||||
|
||||
{option?.values?.map((value) => {
|
||||
const optionNameLowerCase = option?.name?.toLowerCase();
|
||||
// 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 (
|
||||
<button
|
||||
@ -68,17 +44,15 @@ export function VariantSelector({
|
||||
updateURL(newState);
|
||||
}}
|
||||
key={value}
|
||||
aria-disabled={!isAvailableForSale}
|
||||
disabled={!isAvailableForSale}
|
||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||
title={`${option.name} ${value}`}
|
||||
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',
|
||||
{
|
||||
'cursor-default ring-2 ring-blue-600': isActive,
|
||||
'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':
|
||||
!isAvailableForSale
|
||||
''
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
@ -1,23 +1,23 @@
|
||||
export type SortFilterItem = {
|
||||
title: string;
|
||||
slug: string | null;
|
||||
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
|
||||
reverse: boolean;
|
||||
sortKey: 'rating' | 'popularity' | 'date' | 'price';
|
||||
order?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export const defaultSort: SortFilterItem = {
|
||||
title: 'Relevance',
|
||||
slug: null,
|
||||
sortKey: 'RELEVANCE',
|
||||
reverse: false
|
||||
sortKey: 'popularity',
|
||||
order: 'desc'
|
||||
};
|
||||
|
||||
export const sorting: SortFilterItem[] = [
|
||||
defaultSort,
|
||||
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
|
||||
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
|
||||
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
|
||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
||||
{ title: 'Trending', slug: 'trending-desc', sortKey: 'rating', order: 'desc' }, // asc
|
||||
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'date', order: 'desc' },
|
||||
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'price', order: 'asc' }, // asc
|
||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'price', order: 'desc' }
|
||||
];
|
||||
|
||||
export const TAGS = {
|
||||
|
@ -45,5 +45,6 @@ export type Attribute = {
|
||||
export type Default_Attribute = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
option: string;
|
||||
};
|
||||
|
@ -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
|
||||
|
||||
// console.log("params:", params);
|
||||
const values = [];
|
||||
|
||||
let queryString = '';
|
||||
@ -225,11 +224,13 @@ export default class WooCommerceRestApi<T extends WooRestApiOptions> {
|
||||
delete params.id;
|
||||
}
|
||||
|
||||
// Add query params to url
|
||||
if (Object.keys(params).length !== 0) {
|
||||
for (const key in params) {
|
||||
url = url + '?' + key + '=' + params[key];
|
||||
}
|
||||
const queryParams: string[] = [];
|
||||
for (const key in params) {
|
||||
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);
|
||||
// return url;
|
||||
// }
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
|
@ -131,7 +131,7 @@ export interface ProductVariations {
|
||||
shipping_class: string;
|
||||
shipping_class_id: number;
|
||||
image: Partial<Image>;
|
||||
attributes: Partial<Attribute>[];
|
||||
attributes: Partial<Default_Attribute>[];
|
||||
menu_order: number;
|
||||
meta_data: Partial<Meta_Data>[];
|
||||
}
|
||||
@ -143,6 +143,7 @@ export interface ProductAttributes {
|
||||
type: string;
|
||||
order_by: string;
|
||||
has_archives: boolean;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface ProductAttributesTerms {
|
||||
|
@ -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.
|
||||
*/
|
||||
class WooCommerceStoreApiClient {
|
||||
public client: AxiosInstance;
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
const headers: RawAxiosRequestHeaders = {
|
||||
@ -18,23 +18,31 @@ class WooCommerceStoreApiClient {
|
||||
baseURL,
|
||||
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) {
|
||||
if (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> {
|
||||
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: {
|
||||
@ -57,6 +65,6 @@ class WooCommerceStoreApiClient {
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
@ -3,9 +3,9 @@ import WooCommerceRestApi, { WooRestApiOptions } from './models/client';
|
||||
const option: WooRestApiOptions = {
|
||||
url: process.env.WOOCOMMERCE_URL ?? 'http://wordpress.localhost',
|
||||
consumerKey:
|
||||
process.env.WOOCOMMERCE_CONSUMER_KEY ?? 'ck_1fb0a3c9b50ae813c31c7effc086a809d8416d90',
|
||||
process.env.WOOCOMMERCE_CONSUMER_KEY ?? 'ck_2307cad3b7ab10eb2c439fd8c50ef69740967768',
|
||||
consumerSecret:
|
||||
process.env.WOOCOMMERCE_CONSUMER_SECRET ?? 'cs_ee4f1c9e061d07a7cb6025b69d414189a9157e20',
|
||||
process.env.WOOCOMMERCE_CONSUMER_SECRET ?? 'cs_2e2e94e6b9507cca5f7080ff8f856ac84c7b72d5',
|
||||
isHttps: false,
|
||||
version: 'wc/v3',
|
||||
queryStringAuth: false // Force Basic Authentication as query string true and using under
|
||||
|
27
middleware.ts
Normal file
27
middleware.ts
Normal 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*']
|
||||
};
|
@ -17,7 +17,8 @@
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"sonner": "^1.7.0"
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
|
1396
pnpm-lock.yaml
generated
1396
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user