feat: add login and cart provider

This commit is contained in:
paolosantarsiero 2024-12-26 21:35:53 +01:00
parent 88762ba1bc
commit ce96340833
68 changed files with 2421 additions and 1744 deletions

View File

@ -1,11 +1,9 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from 'components/opengraph-image';
import { getPage } from 'lib/shopify';
export const runtime = 'edge'; export const runtime = 'edge';
export default async function Image({ params }: { params: { page: string } }) { export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page); const title = '';
const title = page.seo?.title || page.title;
return await OpengraphImage({ title }); return await OpengraphImage({ title });
} }

View File

@ -1,23 +1,19 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Prose from 'components/prose'; import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
export async function generateMetadata(props: { export async function generateMetadata(props: {
params: Promise<{ page: string }>; params: Promise<{ page: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const params = await props.params; const params = await props.params;
const page = await getPage(params.page);
if (!page) return notFound();
return { return {
title: page.seo?.title || page.title, title: '',
description: page.seo?.description || page.bodySummary, description: '',
openGraph: { openGraph: {
publishedTime: page.createdAt, publishedTime: '',
modifiedTime: page.updatedAt, modifiedTime: '',
type: 'article' type: 'article'
} }
}; };
@ -25,20 +21,17 @@ export async function generateMetadata(props: {
export default async function Page(props: { params: Promise<{ page: string }> }) { export default async function Page(props: { params: Promise<{ page: string }> }) {
const params = await props.params; const params = await props.params;
const page = await getPage(params.page);
if (!page) return notFound();
return ( return (
<> <>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1> <h1 className="mb-8 text-5xl font-bold">{''}</h1>
<Prose className="mb-8" html={page.body} /> <Prose className="mb-8" html={''} />
<p className="text-sm italic"> <p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, { {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
}).format(new Date(page.updatedAt))}.`} }).format(new Date())}.`}
</p> </p>
</> </>
); );

View File

@ -0,0 +1,50 @@
import { woocommerce } from "lib/woocomerce/woocommerce";
import { NextAuthOptions, Session, User } from "next-auth";
import { JWT } from "next-auth/jwt";
import NextAuth from "next-auth/next";
import CredentialsProvider from 'next-auth/providers/credentials';
export const authOptions = {
secret: process.env.NEXTAUTH_SECRET,
session: {
strategy: "jwt", // Use JWT for session handling
},
providers: [
CredentialsProvider({
name: 'woocommerce',
credentials: {
username: { label: 'Username', type: 'text', placeholder: 'Username' },
password: { label: 'Password', type: 'password', placeholder: 'Password' },
},
async authorize(credentials, req) {
if (!credentials?.username || !credentials?.password) {
return null;
}
const user = await woocommerce.login(credentials.username, credentials.password);
// If no error and we have user data, return it
if (user) {
return user
}
// Return null if user data could not be retrieved
return null
}
}),
],
callbacks: {
async jwt({ token, user }: { token: JWT, user: User }) {
if (user) {
console.debug('Set token user', user);
token.user = user;
}
return token;
},
async session({ session, token }: {session: Session, token: JWT}) {
console.debug('Set session token', token.user);
session.user = token.user;
return session;
},
},
} satisfies NextAuthOptions;
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST };

54
app/api/cart/route.ts Normal file
View File

@ -0,0 +1,54 @@
import { storeApi } from 'lib/woocomerce/storeApi';
import { getServerSession } from 'next-auth';
import { NextRequest, NextResponse } from 'next/server';
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('');
}
const cart = await storeApi.getCart();
return NextResponse.json(cart, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch cart' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const { id, quantity, variation } = await req.json();
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 });
}
}
export async function PUT(req: NextRequest) {
try {
const { key, quantity } = await req.json();
if (quantity > 0) {
const cart = await storeApi.updateItem({ key, quantity });
return NextResponse.json(cart, { status: 200 });
} else {
const cart = await storeApi.removeFromCart({ key });
return NextResponse.json(cart, { status: 200 });
}
} catch (error) {
return NextResponse.json({ error: 'Failed to update cart item' }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
try {
const { key } = await req.json();
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 });
}
}

View File

@ -1,6 +0,0 @@
import { revalidate } from 'lib/shopify';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req);
}

9
app/checkout/page.tsx Normal file
View File

@ -0,0 +1,9 @@
export default async function CheckoutPage(props: { params: Promise<{ id: number }> }) {
const params = await props.params;
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)]">
<h1>Checkout</h1>
</section>
);
}

View File

@ -0,0 +1,18 @@
import { ThreeItemGridItem } from 'components/grid/three-items';
import { Product } from 'lib/woocomerce/models/product';
import { woocommerce } from 'lib/woocomerce/woocommerce';
export default async function ProductPage(props: { params: Promise<{ name: string }> }) {
const params = await props.params;
const products: Product[] = (await (woocommerce.get('products', { category: params.name })));
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)]">
{products.map((product, index) => (
<ThreeItemGridItem key={product.id} size={index === 0 ? 'full' : 'half'} item={product} />
))}
</section>
);
}

View File

@ -1,10 +1,11 @@
import { CartProvider } from 'components/cart/cart-context'; import { CartProvider } from 'components/cart/cart-context';
import { Navbar } from 'components/layout/navbar'; import { Navbar } from 'components/layout/navbar';
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 { getCart } from 'lib/shopify';
import { ensureStartsWith } from 'lib/utils'; import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers'; 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,14 +38,13 @@ export const metadata = {
}; };
export default async function RootLayout({ children }: { children: ReactNode }) { export default async function RootLayout({ children }: { children: ReactNode }) {
const cartId = (await cookies()).get('cartId')?.value; const cart = await storeApi.getCart();
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId);
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">
<CartProvider cartPromise={cart}> <NextAuthProvider>
<CartProvider value={cart}>
<Navbar /> <Navbar />
<main> <main>
{children} {children}
@ -52,6 +52,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
<WelcomeToast /> <WelcomeToast />
</main> </main>
</CartProvider> </CartProvider>
</NextAuthProvider>
</body> </body>
</html> </html>
); );

View File

@ -9,7 +9,7 @@ export const metadata = {
} }
}; };
export default function HomePage() { export default async function HomePage() {
return ( return (
<> <>
<ThreeItemGrid/> <ThreeItemGrid/>

View File

@ -1,149 +0,0 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { GridTileImage } from 'components/grid/tile';
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 { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
import Link from 'next/link';
import { Suspense } from 'react';
export async function generateMetadata(props: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {};
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
robots: {
index: indexable,
follow: indexable,
googleBot: {
index: indexable,
follow: indexable
}
},
openGraph: url
? {
images: [
{
url,
width,
height,
alt
}
]
}
: null
};
}
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
const productJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.title,
description: product.description,
image: product.featuredImage.url,
offers: {
'@type': 'AggregateOffer',
availability: product.availableForSale
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount,
lowPrice: product.priceRange.minVariantPrice.amount
}
};
return (
<ProductProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd)
}}
/>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
<div className="h-full w-full basis-full lg:basis-4/6">
<Suspense
fallback={
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
}
>
<Gallery
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
</Suspense>
</div>
<div className="basis-full lg:basis-2/6">
<Suspense fallback={null}>
<ProductDescription product={product} />
</Suspense>
</div>
</div>
<RelatedProducts id={product.id} />
</div>
<Footer />
</ProductProvider>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
if (!relatedProducts.length) return null;
return (
<div className="py-8">
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
{relatedProducts.map((product) => (
<li
key={product.handle}
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
>
<Link
className="relative h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
/>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,97 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
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 { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { Image } from 'lib/woocomerce/models/base';
import { Product } from 'lib/woocomerce/models/product';
import { woocommerce } from 'lib/woocomerce/woocommerce';
import { Suspense } from 'react';
export async function generateMetadata(props: {
params: Promise<{ name: string }>;
}): Promise<Metadata> {
const params = await props.params;
const product: Product | undefined = (await (woocommerce.get('products', { slug: params.name })))?.[0];
if (!product) return notFound();
const indexable = !product.tags.find((tag) => tag.name?.includes(HIDDEN_PRODUCT_TAG));
return {
title: product.name,
description: product.description,
robots: {
index: indexable,
follow: indexable,
googleBot: {
index: indexable,
follow: indexable
}
},
};
}
export default async function ProductPage(props: { params: Promise<{ name: string }> }) {
const params = await props.params;
const product: Product | undefined = (await (woocommerce.get('products', { slug: params.name })))?.[0];
if (!product) return notFound();
const productJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images?.[0]?.src,
offers: {
'@type': 'AggregateOffer',
availability: product.stock_quantity > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
priceCurrency: product.price,
highPrice: product.max_price,
lowPrice: product.min_price
}
};
return (
<ProductProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd)
}}
/>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
<div className="h-full w-full basis-full lg:basis-4/6">
<Suspense
fallback={
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
}
>
<Gallery
images={product.images.slice(0, 5).map((image: Partial<Image>) => ({
id: image.id!,
src: image.src!,
altText: image.alt!
}))}
/>
</Suspense>
</div>
<div className="basis-full lg:basis-2/6">
<Suspense fallback={null}>
<ProductDescription product={product} />
</Suspense>
</div>
</div>
</div>
<Footer />
</ProductProvider>
);
}

View File

@ -1,11 +1,8 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from 'components/opengraph-image';
import { getCollection } from 'lib/shopify';
export const runtime = 'edge'; export const runtime = 'edge';
export default async function Image({ params }: { params: { collection: string } }) { export default async function Image({ params }: { params: { collection: string } }) {
const collection = await getCollection(params.collection);
const title = collection?.seo?.title || collection?.title;
return await OpengraphImage({ title }); return await OpengraphImage({ title: '' });
} }

View File

@ -1,5 +1,4 @@
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter'; import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants'; import { sorting } from 'lib/constants';
import ChildrenWrapper from './children-wrapper'; import ChildrenWrapper from './children-wrapper';
@ -9,7 +8,6 @@ export default function SearchLayout({ children }: { children: React.ReactNode }
<> <>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white"> <div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
<div className="order-first w-full flex-none md:max-w-[125px]"> <div className="order-first w-full flex-none md:max-w-[125px]">
<Collections />
</div> </div>
<div className="order-last min-h-screen w-full md:order-none"> <div className="order-last min-h-screen w-full md:order-none">
<ChildrenWrapper>{children}</ChildrenWrapper> <ChildrenWrapper>{children}</ChildrenWrapper>

View File

@ -1,7 +1,7 @@
import Grid from 'components/grid'; import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify'; import { woocommerce } from 'lib/woocomerce/woocommerce';
export const metadata = { export const metadata = {
title: 'Search', title: 'Search',
@ -15,7 +15,7 @@ export default async function SearchPage(props: {
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, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getProducts({ sortKey, reverse, query: searchValue }); const products = (await (woocommerce.get('products', { search: searchValue, orderby: sortKey })));
const resultsText = products.length > 1 ? 'results' : 'result'; const resultsText = products.length > 1 ? 'results' : 'result';
return ( return (

View File

@ -1,4 +1,4 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { validateEnvironmentVariables } from 'lib/utils'; import { validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
@ -21,34 +21,35 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: new Date().toISOString() lastModified: new Date().toISOString()
})); }));
const collectionsPromise = getCollections().then((collections) => // const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({ // collections.map((collection) => ({
url: `${baseUrl}${collection.path}`, // url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt // lastModified: collection.updatedAt
})) // }))
); // );
const productsPromise = getProducts({}).then((products) => // const productsPromise = getProducts({}).then((products) =>
products.map((product) => ({ // products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`, // url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt // lastModified: product.updatedAt
})) // }))
); // );
const pagesPromise = getPages().then((pages) => // const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({ // pages.map((page) => ({
url: `${baseUrl}/${page.handle}`, // url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt // lastModified: page.updatedAt
})) // }))
); // );
let fetchedRoutes: Route[] = []; // let fetchedRoutes: Route[] = [];
try { // try {
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat(); // fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
} catch (error) { // } catch (error) {
throw JSON.stringify(error, null, 2); // throw JSON.stringify(error, null, 2);
} // }
// return [...routesMap, ...fetchedRoutes];
return [...routesMap, ...fetchedRoutes];
return [...routesMap];
} }

View File

@ -1,10 +1,11 @@
import { getCollectionProducts } from 'lib/shopify'; import { Product } from 'lib/woocomerce/models/product';
import { woocommerce } from 'lib/woocomerce/woocommerce';
import Link from 'next/link'; import Link from 'next/link';
import { GridTileImage } from './grid/tile'; import { GridTileImage } from './grid/tile';
export async function Carousel() { export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page. // Collections that start with `hidden-*` are hidden from the search page.
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' }); const products: Product[] = (await (woocommerce.get('products')));
if (!products?.length) return null; if (!products?.length) return null;
@ -16,18 +17,18 @@ export async function Carousel() {
<ul className="flex animate-carousel gap-4"> <ul className="flex animate-carousel gap-4">
{carouselProducts.map((product, i) => ( {carouselProducts.map((product, i) => (
<li <li
key={`${product.handle}${i}`} key={`${product.id}${i}`}
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3" className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
> >
<Link href={`/product/${product.handle}`} className="relative h-full w-full"> <Link href={`/product/${product.id}`} className="relative h-full w-full">
<GridTileImage <GridTileImage
alt={product.title} alt={product.name}
label={{ label={{
title: product.title, title: product.name,
amount: product.priceRange.maxVariantPrice.amount, amount: product.price,
currencyCode: product.priceRange.maxVariantPrice.currencyCode currencyCode: 'EUR'
}} }}
src={product.featuredImage?.url} src={product.images?.[0]?.src || ''}
fill fill
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw" sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
/> />

View File

@ -1,109 +0,0 @@
'use server';
import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = (await cookies()).get('cartId')?.value;
if (!cartId || !selectedVariantId) {
return 'Error adding item to cart';
}
try {
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error adding item to cart';
}
}
export async function removeItem(prevState: any, merchandiseId: string) {
let cartId = (await cookies()).get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try {
const cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
if (lineItem && lineItem.id) {
await removeFromCart(cartId, [lineItem.id]);
revalidateTag(TAGS.cart);
} else {
return 'Item not found in cart';
}
} catch (e) {
return 'Error removing item from cart';
}
}
export async function updateItemQuantity(
prevState: any,
payload: {
merchandiseId: string;
quantity: number;
}
) {
let cartId = (await cookies()).get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
const { merchandiseId, quantity } = payload;
try {
const cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
if (lineItem && lineItem.id) {
if (quantity === 0) {
await removeFromCart(cartId, [lineItem.id]);
} else {
await updateCart(cartId, [
{
id: lineItem.id,
merchandiseId,
quantity
}
]);
}
} else if (quantity > 0) {
// If the item doesn't exist in the cart and quantity > 0, add it
await addToCart(cartId, [{ merchandiseId, quantity }]);
}
revalidateTag(TAGS.cart);
} catch (e) {
console.error(e);
return 'Error updating item quantity';
}
}
export async function redirectToCheckout() {
let cartId = (await cookies()).get('cartId')?.value;
let cart = await getCart(cartId);
redirect(cart!.checkoutUrl);
}
export async function createCartAndSetCookie() {
let cart = await createCart();
(await cookies()).set('cartId', cart.id!);
}

View File

@ -2,53 +2,19 @@
import { PlusIcon } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { addItem } from 'components/cart/actions'; import { Product } from 'lib/woocomerce/models/product';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { useActionState } from 'react';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
function SubmitButton({ function SubmitButton({
availableForSale,
selectedVariantId
}: { }: {
availableForSale: boolean;
selectedVariantId: string | undefined;
}) { }) {
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';
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
if (!availableForSale) {
return (
<button disabled className={clsx(buttonClasses, disabledClasses)}>
Out Of Stock
</button>
);
}
console.log(selectedVariantId);
if (!selectedVariantId) {
return ( return (
<button <button
aria-label="Please select an option" aria-label="Please select an option"
disabled className={clsx(buttonClasses)}
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
return (
<button
aria-label="Add to cart"
className={clsx(buttonClasses, {
'hover:opacity-90': true
})}
> >
<div className="absolute left-0 ml-4"> <div className="absolute left-0 ml-4">
<PlusIcon className="h-5" /> <PlusIcon className="h-5" />
@ -59,30 +25,20 @@ function SubmitButton({
} }
export function AddToCart({ product }: { product: Product }) { export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product; const { setNewCart } = useCart();
const { addCartItem } = useCart();
const { state } = useProduct();
const [message, formAction] = useActionState(addItem, null);
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
);
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId;
const actionWithVariant = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
return ( return (
<form <form
action={async () => { action={async () => {
addCartItem(finalVariant, product); try {
await actionWithVariant(); const cart = await (await fetch('/api/cart', {method: 'POST', body: JSON.stringify({ id: product.id, quantity: 1, variation: [] })},)).json();
setNewCart(cart);
} catch (error) {
console.error(error);
}
}} }}
> >
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} /> <SubmitButton />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form> </form>
); );
} }

View File

@ -1,178 +1,47 @@
'use client'; 'use client';
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types'; import { Cart } from 'lib/woocomerce/models/cart';
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
type UpdateType = 'plus' | 'minus' | 'delete'; type UpdateType = 'plus' | 'minus' | 'delete';
type CartAction = type UpdatePayload = { key: string | number; quantity: number;};
| { type: 'UPDATE_ITEM'; payload: { merchandiseId: string; updateType: UpdateType } } type AddPayload = { id: string | number; quantity: number; variation: { attribute: string; value: string }[] };
| { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product } }; type RemovePayload = { key: string | number };
type CartContextType = { type CartContextType = {
cart: Cart | undefined; cart: Cart | undefined;
updateCartItem: (merchandiseId: string, updateType: UpdateType) => void; setNewCart: (cart: Cart) => void;
addCartItem: (variant: ProductVariant, product: Product) => void;
}; };
const CartContext = createContext<CartContextType | undefined>(undefined); const CartContext = createContext<CartContextType | undefined>(undefined);
function calculateItemCost(quantity: number, price: string): string {
return (Number(price) * quantity).toString();
}
function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null {
if (updateType === 'delete') return null;
const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
if (newQuantity === 0) return null;
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
const newTotalAmount = calculateItemCost(newQuantity, singleItemAmount.toString());
return {
...item,
quantity: newQuantity,
cost: {
...item.cost,
totalAmount: {
...item.cost.totalAmount,
amount: newTotalAmount
}
}
};
}
function createOrUpdateCartItem(
existingItem: CartItem | undefined,
variant: ProductVariant,
product: Product
): CartItem {
const quantity = existingItem ? existingItem.quantity + 1 : 1;
const totalAmount = calculateItemCost(quantity, variant.price.amount);
return {
id: existingItem?.id,
quantity,
cost: {
totalAmount: {
amount: totalAmount,
currencyCode: variant.price.currencyCode
}
},
merchandise: {
id: variant.id,
title: variant.title,
selectedOptions: variant.selectedOptions,
product: {
id: product.id,
handle: product.handle,
title: product.title,
featuredImage: product.featuredImage
}
}
};
}
function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost'> {
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = lines.reduce((sum, item) => sum + Number(item.cost.totalAmount.amount), 0);
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
return {
totalQuantity,
cost: {
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
totalAmount: { amount: totalAmount.toString(), currencyCode },
totalTaxAmount: { amount: '0', currencyCode }
}
};
}
function createEmptyCart(): Cart {
return {
id: undefined,
checkoutUrl: '',
totalQuantity: 0,
lines: [],
cost: {
subtotalAmount: { amount: '0', currencyCode: 'USD' },
totalAmount: { amount: '0', currencyCode: 'USD' },
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
}
};
}
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const currentCart = state || createEmptyCart();
switch (action.type) {
case 'UPDATE_ITEM': {
const { merchandiseId, updateType } = action.payload;
const updatedLines = currentCart.lines
.map((item) =>
item.merchandise.id === merchandiseId ? updateCartItem(item, updateType) : item
)
.filter(Boolean) as CartItem[];
if (updatedLines.length === 0) {
return {
...currentCart,
lines: [],
totalQuantity: 0,
cost: {
...currentCart.cost,
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
}
};
}
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
}
case 'ADD_ITEM': {
const { variant, product } = action.payload;
const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
const updatedItem = createOrUpdateCartItem(existingItem, variant, product);
const updatedLines = existingItem
? currentCart.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item))
: [...currentCart.lines, updatedItem];
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
}
default:
return currentCart;
}
}
export function CartProvider({ export function CartProvider({
value,
children, children,
cartPromise
}: { }: {
value: Cart;
children: React.ReactNode; children: React.ReactNode;
cartPromise: Promise<Cart | undefined>;
}) { }) {
const initialCart = use(cartPromise); const [cart, setCart] = useState<Cart | undefined>(value);
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer); const setNewCart = (cart: Cart) => {
setCart(cart);
}
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => { useEffect(() => {
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } }); setCart(value);
}; }, [value]);
const addCartItem = (variant: ProductVariant, product: Product) => { return (
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } }); <CartContext.Provider
}; value={{
cart,
const value = useMemo( setNewCart,
() => ({ }}
cart: optimisticCart, >
updateCartItem, {children}
addCartItem </CartContext.Provider>
}),
[optimisticCart]
); );
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
} }
export function useCart() { export function useCart() {

View File

@ -1,26 +1,25 @@
'use client'; 'use client';
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions'; import { CartItem } from 'lib/woocomerce/models/cart';
import type { CartItem } from 'lib/shopify/types'; import { useCart } from './cart-context';
import { useActionState } from 'react';
export function DeleteItemButton({ export function DeleteItemButton({
item, item,
optimisticUpdate
}: { }: {
item: CartItem; item: CartItem;
optimisticUpdate: any;
}) { }) {
const [message, formAction] = useActionState(removeItem, null); const {setNewCart} = useCart();
const merchandiseId = item.merchandise.id;
const actionWithVariant = formAction.bind(null, merchandiseId);
return ( return (
<form <form
action={async () => { action={async () => {
optimisticUpdate(merchandiseId, 'delete'); try {
await actionWithVariant(); const cart = await (await fetch('/api/cart', {method: 'DELETE', body: JSON.stringify({ key: item.key })})).json();
setNewCart(cart);
} catch (error) {
console.error(error);
}
}} }}
> >
<button <button
@ -30,9 +29,6 @@ export function DeleteItemButton({
> >
<XMarkIcon className="mx-[1px] h-4 w-4 text-white dark:text-black" /> <XMarkIcon className="mx-[1px] h-4 w-4 text-white dark:text-black" />
</button> </button>
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form> </form>
); );
} }

View File

@ -2,9 +2,8 @@
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline'; import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions'; import { CartItem } from 'lib/woocomerce/models/cart';
import type { CartItem } from 'lib/shopify/types'; import { useCart } from './cart-context';
import { useActionState } from 'react';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) { function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
return ( return (
@ -30,30 +29,29 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
export function EditItemQuantityButton({ export function EditItemQuantityButton({
item, item,
type, type,
optimisticUpdate
}: { }: {
item: CartItem; item: CartItem;
type: 'plus' | 'minus'; type: 'plus' | 'minus';
optimisticUpdate: any;
}) { }) {
const [message, formAction] = useActionState(updateItemQuantity, null); const {setNewCart} = useCart();
const payload = { const payload = {
merchandiseId: item.merchandise.id, key: item.key,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1,
}; };
const actionWithVariant = formAction.bind(null, payload);
return ( return (
<form <form
action={async () => { action={async () => {
optimisticUpdate(payload.merchandiseId, type); try {
await actionWithVariant(); const cart = await (await fetch('/api/cart', {method: 'PUT', body: JSON.stringify(payload)})).json();
setNewCart(cart);
} catch (error) {
console.error(error);
}
}} }}
> >
<SubmitButton type={type} /> <SubmitButton type={type} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form> </form>
); );
} }

View File

@ -4,53 +4,40 @@ import { Dialog, Transition } from '@headlessui/react';
import { ShoppingCartIcon } from '@heroicons/react/24/outline'; import { ShoppingCartIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots'; import LoadingDots from 'components/loading-dots';
import Price from 'components/price'; import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants'; import { useSession } from 'next-auth/react';
import { createUrl } from 'lib/utils';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Fragment, useEffect, useRef, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { useFormStatus } from 'react-dom'; import { useFormStatus } from 'react-dom';
import { createCartAndSetCookie, redirectToCheckout } from './actions';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
import CloseCart from './close-cart'; import CloseCart from './close-cart';
import { DeleteItemButton } from './delete-item-button'; import { DeleteItemButton } from './delete-item-button';
import { EditItemQuantityButton } from './edit-item-quantity-button'; import { EditItemQuantityButton } from './edit-item-quantity-button';
import OpenCart from './open-cart'; import OpenCart from './open-cart';
type MerchandiseSearchParams = {
[key: string]: string;
};
export default function CartModal() { export default function CartModal() {
const { cart, updateCartItem } = useCart(); const {cart, setNewCart} = useCart();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity); const [userIsLoggedIn, setUserIsLoggedIn] = useState(false);
const openCart = () => setIsOpen(true); const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false); const closeCart = () => setIsOpen(false);
const {data} = useSession();
useEffect(() => { useEffect(() => {
if (!cart) { if (data?.user.token) {
createCartAndSetCookie(); const fetchCart = async () => {
const cart = await (await fetch('/api/cart')).json();
setNewCart(cart);
};
fetchCart();
} }
}, [cart]); }, [data]);
useEffect(() => {
if (
cart?.totalQuantity &&
cart?.totalQuantity !== quantityRef.current &&
cart?.totalQuantity > 0
) {
if (!isOpen) {
setIsOpen(true);
}
quantityRef.current = cart?.totalQuantity;
}
}, [isOpen, cart?.totalQuantity, quantityRef]);
return ( return (
<> <>
<button aria-label="Open cart" onClick={openCart}> <button aria-label="Open cart" onClick={openCart}>
<OpenCart quantity={cart?.totalQuantity} /> <OpenCart quantity={cart?.items?.map((item) => item.quantity).reduce((a, b) => a + b, 0).toString() ?? '0'} />
</button> </button>
<Transition show={isOpen}> <Transition show={isOpen}>
<Dialog onClose={closeCart} className="relative z-50"> <Dialog onClose={closeCart} className="relative z-50">
@ -82,7 +69,7 @@ export default function CartModal() {
</button> </button>
</div> </div>
{!cart || cart.lines.length === 0 ? ( {!cart || cart.items?.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden"> <div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingCartIcon className="h-16" /> <ShoppingCartIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p> <p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
@ -90,24 +77,11 @@ export default function CartModal() {
) : ( ) : (
<div className="flex h-full flex-col justify-between overflow-hidden p-1"> <div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="flex-grow overflow-auto py-4"> <ul className="flex-grow overflow-auto py-4">
{cart.lines {cart.items?.length && cart.items
.sort((a, b) => .sort((a, b) =>
a.merchandise.product.title.localeCompare(b.merchandise.product.title) a.name.localeCompare(b.name)
) )
.map((item, i) => { .map((item, i) => {
const merchandiseSearchParams = {} as MerchandiseSearchParams;
item.merchandise.selectedOptions.forEach(({ name, value }) => {
if (value !== DEFAULT_OPTION) {
merchandiseSearchParams[name.toLowerCase()] = value;
}
});
const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`,
new URLSearchParams(merchandiseSearchParams)
);
return ( return (
<li <li
key={i} key={i}
@ -115,7 +89,7 @@ export default function CartModal() {
> >
<div className="relative flex w-full flex-row justify-between px-1 py-4"> <div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -ml-1 -mt-2"> <div className="absolute z-40 -ml-1 -mt-2">
<DeleteItemButton item={item} optimisticUpdate={updateCartItem} /> <DeleteItemButton item={item} />
</div> </div>
<div className="flex flex-row"> <div className="flex flex-row">
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"> <div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
@ -124,40 +98,34 @@ export default function CartModal() {
width={64} width={64}
height={64} height={64}
alt={ alt={
item.merchandise.product.featuredImage.altText || item.name
item.merchandise.product.title
} }
src={item.merchandise.product.featuredImage.url} src={item.images?.[0]?.src || ''}
/> />
</div> </div>
<Link <Link
href={merchandiseUrl} href={''}
onClick={closeCart} onClick={closeCart}
className="z-30 ml-2 flex flex-row space-x-4" className="z-30 ml-2 flex flex-row space-x-4"
> >
<div className="flex flex-1 flex-col text-base"> <div className="flex flex-1 flex-col text-base">
<span className="leading-tight"> <span className="leading-tight">
{item.merchandise.product.title} {item.name}
</span> </span>
{item.merchandise.title !== DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
) : null}
</div> </div>
</Link> </Link>
</div> </div>
<div className="flex h-16 flex-col justify-between"> <div className="flex h-16 flex-col justify-between">
<Price <Price
className="flex justify-end space-y-2 text-right text-sm" className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount} amount={item.prices?.price}
currencyCode={item.cost.totalAmount.currencyCode} needSplit
currencyCode={item.prices.currency_code}
/> />
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700"> <div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton <EditItemQuantityButton
item={item} item={item}
type="minus" type="minus"
optimisticUpdate={updateCartItem}
/> />
<p className="w-6 text-center"> <p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span> <span className="w-full text-sm">{item.quantity}</span>
@ -165,7 +133,6 @@ export default function CartModal() {
<EditItemQuantityButton <EditItemQuantityButton
item={item} item={item}
type="plus" type="plus"
optimisticUpdate={updateCartItem}
/> />
</div> </div>
</div> </div>
@ -179,8 +146,9 @@ export default function CartModal() {
<p>Taxes</p> <p>Taxes</p>
<Price <Price
className="text-right text-base text-black dark:text-white" className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalTaxAmount.amount} amount={cart.totals?.total_price}
currencyCode={cart.cost.totalTaxAmount.currencyCode} needSplit
currencyCode={'EUR'}
/> />
</div> </div>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700"> <div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
@ -191,12 +159,13 @@ export default function CartModal() {
<p>Total</p> <p>Total</p>
<Price <Price
className="text-right text-base text-black dark:text-white" className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalAmount.amount} amount={cart.totals?.total_price}
currencyCode={cart.cost.totalAmount.currencyCode} needSplit
currencyCode={'EUR'}
/> />
</div> </div>
</div> </div>
<form action={redirectToCheckout}> <form action={'/checkout'}>
<CheckoutButton /> <CheckoutButton />
</form> </form>
</div> </div>

View File

@ -6,7 +6,7 @@ export default function OpenCart({
quantity quantity
}: { }: {
className?: string; className?: string;
quantity?: number; quantity?: string;
}) { }) {
return ( return (
<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">

View File

@ -1,9 +1,9 @@
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify'; import { Product } from 'lib/woocomerce/models/product';
import type { Product } from 'lib/shopify/types'; import { woocommerce } from 'lib/woocomerce/woocommerce';
import Link from 'next/link'; import Link from 'next/link';
function ThreeItemGridItem({ export function ThreeItemGridItem({
item, item,
size, size,
priority priority
@ -18,22 +18,22 @@ function ThreeItemGridItem({
> >
<Link <Link
className="relative block aspect-square h-full w-full" className="relative block aspect-square h-full w-full"
href={`/product/${item.handle}`} href={`/product/${item.slug}`}
prefetch={true} prefetch={true}
> >
<GridTileImage <GridTileImage
src={item.featuredImage.url} src={item.images?.[0]?.src || ''}
fill fill
sizes={ sizes={
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw' size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
} }
priority={priority} priority={priority}
alt={item.title} alt={item.name}
label={{ label={{
position: size === 'full' ? 'center' : 'bottom', position: size === 'full' ? 'center' : 'bottom',
title: item.title as string, title: item.name as string,
amount: item.priceRange.maxVariantPrice.amount, amount: item.price,
currencyCode: item.priceRange.maxVariantPrice.currencyCode currencyCode: 'EUR'
}} }}
/> />
</Link> </Link>
@ -43,19 +43,16 @@ function ThreeItemGridItem({
export async function ThreeItemGrid() { export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page. // Collections that start with `hidden-*` are hidden from the search page.
const homepageItems = await getCollectionProducts({ const products: Product[] = (await woocommerce.get('products'));
collection: 'hidden-homepage-featured-items'
});
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
const [firstProduct, secondProduct, thirdProduct] = homepageItems; 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 lg:max-h-[calc(100vh-200px)]">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} /> {products.map((product, index) => (
<ThreeItemGridItem size="half" item={secondProduct} priority={true} /> <ThreeItemGridItem key={product.id} size={index === 0 ? 'full' : 'half'} item={product} />
<ThreeItemGridItem size="half" item={thirdProduct} /> ))}
</section> </section>
); );
} }

View File

@ -2,16 +2,29 @@ import Link from 'next/link';
import FooterMenu from 'components/layout/footer-menu'; import FooterMenu from 'components/layout/footer-menu';
import LogoSquare from 'components/logo-square'; import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { Suspense } from 'react'; import { Suspense } from 'react';
const { COMPANY_NAME, SITE_NAME } = process.env; const { COMPANY_NAME, SITE_NAME } = process.env;
type Menu = {
title: string;
path: string;
};
export default async function Footer() { export default async function Footer() {
const menu = [
{
title: 'Home',
path: '/'
},
{
title: 'Shop',
path: '/shop'
},
] as Menu[];
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : ''); const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700'; const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
const menu = await getMenu('next-js-frontend-footer-menu');
const copyrightName = COMPANY_NAME || SITE_NAME || ''; const copyrightName = COMPANY_NAME || SITE_NAME || '';
return ( return (

View File

@ -1,16 +1,34 @@
import CartModal from 'components/cart/modal'; import CartModal from 'components/cart/modal';
import LoginModal from 'components/login/modal';
import LogoSquare from 'components/logo-square'; import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify'; import { Category } from 'lib/woocomerce/models/base';
import { Menu } from 'lib/shopify/types'; import { woocommerce } from 'lib/woocomerce/woocommerce';
import Link from 'next/link'; import Link from 'next/link';
import path from 'path';
import { Suspense } from 'react'; import { Suspense } from 'react';
import MobileMenu from './mobile-menu'; import MobileMenu from './mobile-menu';
import Search, { SearchSkeleton } from './search'; import Search, { SearchSkeleton } from './search';
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;
type Menu = {
title: string;
path: string;
};
export async function Navbar() { export async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu'); const categories: Category[] = (await (woocommerce.get('products/categories')));
const menu = [
{
title: 'Home',
path: '/'
},
...categories.map((category) => ({
title: category.name,
path: path.join('/collection', category.id.toString())
}))
] as Menu[];
return ( return (
<nav className="relative flex items-center justify-between p-4 lg:px-6"> <nav className="relative flex items-center justify-between p-4 lg:px-6">
@ -53,6 +71,7 @@ export async function Navbar() {
</Suspense> </Suspense>
</div> </div>
<div className="flex justify-end md:w-1/3"> <div className="flex justify-end md:w-1/3">
<LoginModal />
<CartModal /> <CartModal />
</div> </div>
</div> </div>

View File

@ -1,26 +1,26 @@
import Grid from 'components/grid'; import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types'; import { Product } from 'lib/woocomerce/models/product';
import Link from 'next/link'; import Link from 'next/link';
export default function ProductGridItems({ products }: { products: Product[] }) { export default function ProductGridItems({ products }: { products: Product[] }) {
return ( return (
<> <>
{products.map((product) => ( {products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn"> <Grid.Item key={product.id} className="animate-fadeIn">
<Link <Link
className="relative inline-block h-full w-full" className="relative inline-block h-full w-full"
href={`/product/${product.handle}`} href={`/product/${product.id}`}
prefetch={true} prefetch={true}
> >
<GridTileImage <GridTileImage
alt={product.title} alt={product.name}
label={{ label={{
title: product.title, title: product.name,
amount: product.priceRange.maxVariantPrice.amount, amount: product.price,
currencyCode: product.priceRange.maxVariantPrice.currencyCode currencyCode: 'EUR'
}} }}
src={product.featuredImage?.url} src={product.images?.[0]?.src || ''}
fill fill
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw" sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/> />

133
components/login/modal.tsx Normal file
View File

@ -0,0 +1,133 @@
'use client';
import { Dialog, Transition } from '@headlessui/react';
import { UserCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useCart } from 'components/cart/cart-context';
import { signIn, signOut, useSession } from 'next-auth/react';
import { Fragment, useEffect, useState } from 'react';
export default function LoginModal() {
const [isOpen, setIsOpen] = useState(false);
const [isLogged, setIsLogged] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const openLogin = () => setIsOpen(true);
const closeLogin = () => setIsOpen(false);
const {setNewCart} = useCart();
const {data} = useSession();
useEffect(() => {
if (data?.user.token) {
setIsLogged(true);
}
}, [data]);
const handleLogin = async (event: React.FormEvent) => {
event.preventDefault();
try {
const res = await signIn('credentials', {username, password, redirect: false});
const cart = await (await fetch('/api/cart')).json();
setNewCart(cart);
closeLogin();
} catch (error) {
console.error(error);
}
};
return (
<>
<button className="me-2" aria-label="Open cart" onClick={openLogin}>
<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>
</button>
<Transition show={isOpen}>
<Dialog onClose={closeLogin} className="relative z-50">
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="opacity-0 backdrop-blur-none"
enterTo="opacity-100 backdrop-blur-[.5px]"
leave="transition-all ease-in-out duration-200"
leaveFrom="opacity-100 backdrop-blur-[.5px]"
leaveTo="opacity-0 backdrop-blur-none"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-all ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold">Login</p>
<button aria-label="Close cart" onClick={closeLogin}>
<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">
<XMarkIcon className="h-6 transition-all ease-in-out hover:scale-110" />
</div>
</button>
</div>
{!isLogged ? ( <form onSubmit={handleLogin}>
<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"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
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"
required
/>
</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"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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"
required
/>
</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"
>
Login
</button>
</div>
</form>) : (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<p className="mt-6 text-center text-2xl font-bold">You are logged in.</p>
<button className="mt-6 flex" onClick={() => signOut()}>
Sign out
</button>
</div>
)}
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
</>
);
}

View File

@ -0,0 +1,11 @@
"use client";
import { SessionProvider } from "next-auth/react";
type Props = {
children?: React.ReactNode;
};
export const NextAuthProvider = ({ children }: Props) => {
return <SessionProvider>{children}</SessionProvider>;
};

View File

@ -4,19 +4,22 @@ const Price = ({
amount, amount,
className, className,
currencyCode = 'USD', currencyCode = 'USD',
needSplit = false,
currencyCodeClassName currencyCodeClassName
}: { }: {
amount: string; amount: string;
className?: string; className?: string;
currencyCode: string; currencyCode: string;
needSplit?: boolean;
currencyCodeClassName?: string; currencyCodeClassName?: string;
} & React.ComponentProps<'p'>) => ( } & React.ComponentProps<'p'>) => (
<p suppressHydrationWarning={true} className={className}> <p suppressHydrationWarning={true} className={className}>
{`${new Intl.NumberFormat(undefined, { {`${new Intl.NumberFormat(undefined, {
style: 'currency', style: 'currency',
currency: currencyCode, currency: currencyCode,
currencyDisplay: 'narrowSymbol' currencyDisplay: 'narrowSymbol'
}).format(parseFloat(amount))}`} }).format(parseFloat(amount) / (needSplit ? 100 : 1))}`}
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span> <span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
</p> </p>
); );

View File

@ -5,7 +5,7 @@ import { GridTileImage } from 'components/grid/tile';
import { useProduct, useUpdateURL } from 'components/product/product-context'; import { useProduct, useUpdateURL } from 'components/product/product-context';
import Image from 'next/image'; import Image from 'next/image';
export function Gallery({ images }: { images: { src: string; altText: string }[] }) { export function Gallery({ images }: { images: { id: number, src: string; altText: string }[] }) {
const { state, updateImage } = useProduct(); const { state, updateImage } = useProduct();
const updateURL = useUpdateURL(); const updateURL = useUpdateURL();
const imageIndex = state.image ? parseInt(state.image) : 0; const imageIndex = state.image ? parseInt(state.image) : 0;
@ -65,7 +65,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
const isActive = index === imageIndex; const isActive = index === imageIndex;
return ( return (
<li key={image.src} className="h-20 w-20"> <li key={`${image.id}${index}`} className="h-20 w-20">
<button <button
formAction={() => { formAction={() => {
const newState = updateImage(index.toString()); const newState = updateImage(index.toString());

View File

@ -1,26 +1,24 @@
import { AddToCart } from 'components/cart/add-to-cart'; 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/shopify/types'; import { Product } from 'lib/woocomerce/models/product';
import { VariantSelector } from './variant-selector';
export function ProductDescription({ product }: { product: Product }) { export function ProductDescription({ product }: { product: Product }) {
return ( return (
<> <>
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700"> <div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1> <h1 className="mb-2 text-5xl font-medium">{product.name}</h1>
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white"> <div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
<Price <Price
amount={product.priceRange.maxVariantPrice.amount} amount={product.price}
currencyCode={product.priceRange.maxVariantPrice.currencyCode} currencyCode='EUR'
/> />
</div> </div>
</div> </div>
<VariantSelector options={product.options} variants={product.variants} /> {product.description ? (
{product.descriptionHtml ? (
<Prose <Prose
className="mb-6 text-sm leading-tight dark:text-white/[60%]" className="mb-6 text-sm leading-tight dark:text-white/[60%]"
html={product.descriptionHtml} html={product.description}
/> />
) : null} ) : null}
<AddToCart product={product} /> <AddToCart product={product} />

View File

@ -1,53 +0,0 @@
import productFragment from './product';
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
selectedOptions {
name
value
}
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;

View File

@ -1,10 +0,0 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -1,64 +0,0 @@
import imageFragment from './image';
import seoFragment from './seo';
const productFragment = /* GraphQL */ `
fragment product on Product {
id
handle
availableForSale
title
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
}
}
}
featuredImage {
...image
}
images(first: 20) {
edges {
node {
...image
}
}
}
seo {
...seo
}
tags
updatedAt
}
${imageFragment}
${seoFragment}
`;
export default productFragment;

View File

@ -1,8 +0,0 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

View File

@ -1,455 +0,0 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import { ensureStartsWith } from 'lib/utils';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import {
addToCartMutation,
createCartMutation,
editCartItemsMutation,
removeFromCartMutation
} from './mutations/cart';
import { getCartQuery } from './queries/cart';
import {
getCollectionProductsQuery,
getCollectionQuery,
getCollectionsQuery
} from './queries/collection';
import { getMenuQuery } from './queries/menu';
import { getPageQuery, getPagesQuery } from './queries/page';
import {
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery
} from './queries/product';
import {
Cart,
Collection,
Connection,
Image,
Menu,
Page,
Product,
ShopifyAddToCartOperation,
ShopifyCart,
ShopifyCartOperation,
ShopifyCollection,
ShopifyCollectionOperation,
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyMenuOperation,
ShopifyPageOperation,
ShopifyPagesOperation,
ShopifyProduct,
ShopifyProductOperation,
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation
} from './types';
const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
: '';
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch<T>({
cache = 'force-cache',
headers,
query,
tags,
variables
}: {
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': key,
...headers
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
...(tags && { next: { tags } })
});
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
cause: e.cause?.toString() || 'unknown',
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => {
return array.edges.map((edge) => edge?.node);
};
const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: '0.0',
currencyCode: cart.cost.totalAmount.currencyCode
};
}
return {
...cart,
lines: removeEdgesAndNodes(cart.lines)
};
};
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
if (!collection) {
return undefined;
}
return {
...collection,
path: `/search/${collection.handle}`
};
};
const reshapeCollections = (collections: ShopifyCollection[]) => {
const reshapedCollections = [];
for (const collection of collections) {
if (collection) {
const reshapedCollection = reshapeCollection(collection);
if (reshapedCollection) {
reshapedCollections.push(reshapedCollection);
}
}
}
return reshapedCollections;
};
const reshapeImages = (images: Connection<Image>, productTitle: string) => {
const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
...image,
altText: image.altText || `${productTitle} - ${filename}`
};
});
};
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
return undefined;
}
const { images, variants, ...rest } = product;
return {
...rest,
images: reshapeImages(images, product.title),
variants: removeEdgesAndNodes(variants)
};
};
const reshapeProducts = (products: ShopifyProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
cache: 'no-store'
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string | undefined): Promise<Cart | undefined> {
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart]
});
// Old carts becomes `null` when you checkout.
if (!res.body.data.cart) {
return undefined;
}
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: {
handle: collection,
reverse,
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
}
});
if (!res.body.data.collection) {
console.log(`No collection found for \`${collection}\``);
return [];
}
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
}
export async function getCollections(): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery,
tags: [TAGS.collections]
});
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
handle: '',
title: 'All',
description: 'All products',
seo: {
title: 'All',
description: 'All products'
},
path: '/search',
updatedAt: new Date().toISOString()
},
// Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith('hidden')
)
];
return collections;
}
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title,
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
cache: 'no-store',
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery,
cache: 'no-store'
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],
variables: {
query,
reverse,
sortKey
}
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = (await headers()).get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 401 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

View File

@ -1,45 +0,0 @@
import cartFragment from '../fragments/cart';
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -1,10 +0,0 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -1,56 +0,0 @@
import productFragment from '../fragments/product';
import seoFragment from '../fragments/seo';
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -1,10 +0,0 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -1,41 +0,0 @@
import seoFragment from '../fragments/seo';
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;

View File

@ -1,32 +0,0 @@
import productFragment from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

View File

@ -1,272 +0,0 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
export type CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
export type CartItem = {
id: string | undefined;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: CartProduct;
};
};
export type Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string | undefined;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection<CartItem>;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection<ShopifyProduct>;
};
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};

View File

@ -0,0 +1,49 @@
export type Meta_Data = {
id: number;
key: string;
value: string;
};
export type Dimension = {
length: string;
width: string;
height: string;
};
export type Category = {
id: number;
name: string;
slug: string;
};
export type Tag = {
id: number;
name: string;
slug: string;
};
export type Image = {
id: number;
date_created: Date;
date_created_gmt: Date;
date_modified: Date;
date_modified_gmt: Date;
src: string;
name: string;
alt: string;
};
export type Attribute = {
id: number;
name: string;
position: number;
visible: boolean;
variation: boolean;
options: string[];
};
export type Default_Attribute = {
id: number;
name: string;
option: string;
};

View File

@ -0,0 +1,13 @@
export type Billing = {
first_name: string;
last_name: string;
company: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
email: string;
phone: string;
};

View File

@ -0,0 +1,262 @@
export interface Cart {
items: CartItem[];
coupons: Coupon[];
fees: any[];
totals: Totals;
shipping_address: IngAddress;
billing_address: IngAddress;
needs_payment: boolean;
needs_shipping: boolean;
payment_requirements: string[];
has_calculated_shipping: boolean;
shipping_rates: ShippingRate[];
items_count: number;
items_weight: number;
cross_sells: any[];
errors: any[];
payment_methods: string[];
extensions: Extensions;
}
export interface CartItem {
key: string;
id: number;
quantity: number;
quantity_limits: QuantityLimits;
name: string;
short_description: string;
description: string;
sku: string;
low_stock_remaining: null;
backorders_allowed: boolean;
show_backorder_badge: boolean;
sold_individually: boolean;
permalink: string;
images: Image[];
variation: any[];
item_data: any[];
prices: Prices;
totals: Totals;
catalog_visibility: string;
extensions: Extensions;
}
export interface IngAddress {
first_name: string;
last_name: string;
company: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
email?: string;
phone: string;
}
export interface Coupon {
code: string;
discount_type: string;
totals: CouponTotals;
}
export interface CouponTotals {
total_discount: string;
total_discount_tax: string;
currency_code: string;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
}
export interface Extensions {
}
export interface Image {
id: number;
src: string;
thumbnail: string;
srcset: string;
sizes: string;
name: string;
alt: string;
}
export interface Prices {
price: string;
regular_price: string;
sale_price: string;
price_range: null;
currency_code: string;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
raw_prices: RawPrices;
}
export interface RawPrices {
precision: number;
price: string;
regular_price: string;
sale_price: string;
}
export interface QuantityLimits {
minimum: number;
maximum: number;
multiple_of: number;
editable: boolean;
}
export interface ItemTotals {
line_subtotal: string;
line_subtotal_tax: string;
line_total: string;
line_total_tax: string;
currency_code: string;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
}
export interface ShippingRate {
package_id: number;
name: string;
destination: Destination;
items: ShippingRateItem[];
shipping_rates: ShippingRateShippingRate[];
}
export interface Destination {
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
}
export interface ShippingRateItem {
key: string;
name: string;
quantity: number;
}
export interface ShippingRateShippingRate {
rate_id: string;
name: string;
description: string;
delivery_time: string;
price: string;
taxes: string;
instance_id: number;
method_id: string;
meta_data: MetaDatum[];
selected: boolean;
currency_code: string;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
}
export interface MetaDatum {
key: string;
value: string;
}
export interface Totals {
total_items: string;
total_items_tax: string;
total_fees: string;
total_fees_tax: string;
total_discount: string;
total_discount_tax: string;
total_shipping: string;
total_shipping_tax: string;
total_price: string;
total_tax: string;
tax_lines: TaxLine[];
currency_code: string;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
}
export interface TaxLine {
name: string;
price: string;
rate: string;
}
export interface Extensions {
}
export interface Image {
id: number;
src: string;
thumbnail: string;
srcset: string;
sizes: string;
name: string;
alt: string;
}
export interface Prices {
price: string;
regular_price: string;
sale_price: string;
price_range: null;
currency_code: string;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
raw_prices: RawPrices;
}
export interface RawPrices {
precision: number;
price: string;
regular_price: string;
sale_price: string;
}
export interface QuantityLimits {
minimum: number;
maximum: number;
multiple_of: number;
editable: boolean;
}
export interface Totals {
line_subtotal: string;
line_subtotal_tax: string;
line_total: string;
line_total_tax: string;
currency_code: string;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
}

View File

@ -0,0 +1,430 @@
import axios, { AxiosRequestConfig, RawAxiosRequestHeaders } from 'axios';
import crypto from 'node:crypto';
import OAuth from 'oauth-1.0a';
import Url from 'url-parse';
import { DELETE, IWooRestApiOptions, WooRestApiEndpoint, WooRestApiMethod } from './clientOptions';
import { CouponsParams } from './coupon';
import { CustomersParams } from './customer';
import { Order, OrdersMainParams } from './orders';
import { Product, ProductMainParams } from './product';
/**
* Set the axiosConfig property to the axios config object.
* Could reveive any axios |... config objects.
* @param {AxiosRequestConfig} axiosConfig
*/
export type WooRestApiOptions = IWooRestApiOptions<AxiosRequestConfig>;
/**
* Set all the possible query params for the WooCommerce REST API.
*/
export type WooRestApiParams = CouponsParams &
CustomersParams &
OrdersMainParams &
ProductMainParams &
DELETE;
/**
* Define the response types for each endpoint.
*/
type WooCommerceResponse<T extends WooRestApiEndpoint> =
T extends 'products' ? Product[] :
T extends 'orders' ? Order[] :
any;
/**
* WooCommerce REST API wrapper
*
* @param {Object} opt
*/
export default class WooCommerceRestApi<T extends WooRestApiOptions> {
protected _opt: T;
/**
* Class constructor.
*
* @param {Object} opt
*/
constructor(opt: T) {
this._opt = opt;
/**
* If the class is not instantiated, return a new instance.
* This is useful for the static methods.
*/
if (!(this instanceof WooCommerceRestApi)) {
return new WooCommerceRestApi(opt);
}
/**
* Check if the url is defined.
*/
if (!this._opt.url || this._opt.url === '') {
throw new OptionsException('url is required');
}
/**
* Check if the consumerKey is defined.
*/
if (!this._opt.consumerKey || this._opt.consumerKey === '') {
throw new OptionsException('consumerKey is required');
}
/**
* Check if the consumerSecret is defined.
*/
if (!this._opt.consumerSecret || this._opt.consumerSecret === '') {
throw new OptionsException('consumerSecret is required');
}
/**
* Set default options
*/
this._setDefaultsOptions(this._opt);
}
/**
* Set default options
*
* @param {Object} opt
*/
_setDefaultsOptions(opt: T): void {
this._opt.wpAPIPrefix = opt.wpAPIPrefix || 'wp-json';
this._opt.version = opt.version || 'wc/v3';
this._opt.isHttps = /^https/i.test(this._opt.url);
this._opt.encoding = opt.encoding || 'utf-8';
this._opt.queryStringAuth = opt.queryStringAuth || false;
this._opt.classVersion = '0.0.2';
}
login(username: string, password: string): Promise<any> {
return this._request('POST', 'token', { username, password }, {}, 'jwt-auth/v1');
}
/**
* Parse params to object.
*
* @param {Object} params
* @param {Object} query
* @return {Object} IWooRestApiQuery
*/
// _parseParamsObject<T>(params: Record<string, T>, query: Record<string, any>): IWooRestApiQuery {
// for (const key in params) {
// if (typeof params[key] === "object") {
// // If the value is an object, loop through it and add it to the query object
// for (const subKey in params[key]) {
// query[key + "[" + subKey + "]"] = params[key][subKey];
// }
// } else {
// query[key] = params[key]; // If the value is not an object, add it to the query object
// }
// }
// return query; // Return the query object
// }
/**
* Normalize query string for oAuth 1.0a
* Depends on the _parseParamsObject method
*
* @param {String} url
* @param {Object} params
*
* @return {String}
*/
_normalizeQueryString(url: string, params: Partial<Record<string, any>>): string {
/**
* Exit if url and params are not defined
*/
if (url.indexOf('?') === -1 && Object.keys(params).length === 0) {
return url;
}
const query = new Url(url, true).query; // Parse the query string returned by the url
// console.log("params:", params);
const values = [];
let queryString = '';
// Include params object into URL.searchParams.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// const a = this._parseParamsObject(params, query);
// console.log("A:", a);
/**
* Loop through the params object and push the key and value into the values array
* Example: values = ['key1=value1', 'key2=value2']
*/
for (const key in query) {
values.push(key);
}
values.sort(); // Sort the values array
for (const i in values) {
/*
* If the queryString is not empty, add an ampersand to the end of the string
*/
if (queryString.length) queryString += '&';
/**
* Add the key and value to the queryString
*/
queryString +=
encodeURIComponent(values[i] || '') +
'=' +
encodeURIComponent(<string | number | boolean>(query[values[i] as string] ?? ''));
}
/**
* Replace %5B with [ and %5D with ]
*/
queryString = queryString.replace(/%5B/g, '[').replace(/%5D/g, ']');
/**
* Return the url with the queryString
*/
const urlObject = url.split('?')[0] + '?' + queryString;
return urlObject;
}
/**
* Get URL
*
* @param {String} endpoint
* @param {Object} params
*
* @return {String}
*/
_getUrl(endpoint: string, params: Partial<Record<string, unknown>>, version?: string): string {
const api = this._opt.wpAPIPrefix + '/'; // Add prefix to endpoint
let url = this._opt.url.slice(-1) === '/' ? this._opt.url : this._opt.url + '/';
url = url + api + (version ?? this._opt.version) + '/' + endpoint;
// Add id param to url
if (params.id) {
url = url + '/' + params.id;
delete params.id;
}
// Add query params to url
if (Object.keys(params).length !== 0) {
for (const key in params) {
url = url + '?' + key + '=' + params[key];
}
}
/**
* If port is defined, add it to the url
*/
if (this._opt.port) {
const hostname = new Url(url).hostname;
url = url.replace(hostname, hostname + ':' + this._opt.port);
}
/**
* If isHttps is true, normalize the query string
*/
// if (this._opt.isHttps) {
// url = this._normalizeQueryString(url, params);
// return url;
// }
return url;
}
/**
* Create Hmac was deprecated fot this version at 16.11.2022
* Get OAuth 1.0a since it is mandatory for WooCommerce REST API
* You must use OAuth 1.0a "one-legged" authentication to ensure REST API credentials cannot be intercepted by an attacker.
* Reference: https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication-over-http
* @return {Object}
*/
_getOAuth(): OAuth {
const data = {
consumer: {
key: this._opt.consumerKey,
secret: this._opt.consumerSecret
},
signature_method: 'HMAC-SHA256',
hash_function: (base: any, key: any) => {
return crypto.createHmac('sha256', key).update(base).digest('base64');
}
};
return new OAuth(data);
}
/**
* Axios request
* Mount the options to send to axios and send the request.
*
* @param {String} method
* @param {String} endpoint
* @param {Object} data
* @param {Object} params
*
* @return {Object}
*/
_request<T extends WooRestApiEndpoint>(
method: WooRestApiMethod,
endpoint: T,
data?: Record<string, unknown>,
params: Record<string, unknown> = {},
version?: string,
): Promise<WooCommerceResponse<T>> {
const url = this._getUrl(endpoint, params, version);
const header: RawAxiosRequestHeaders = {
Accept: 'application/json'
};
// only set "User-Agent" in node environment
// the checking method is identical to upstream axios
if (
typeof process !== 'undefined' &&
Object.prototype.toString.call(process) === '[object process]'
) {
header['User-Agent'] = 'WooCommerce REST API - TS Client/' + this._opt.classVersion;
}
let options: AxiosRequestConfig = {
url,
method,
responseEncoding: this._opt.encoding,
timeout: this._opt.timeout,
responseType: 'json',
headers: { ...header },
params: {},
data: data ? JSON.stringify(data) : null
};
/**
* If isHttps is false, add the query string to the params object
*/
if (this._opt.isHttps) {
if (this._opt.queryStringAuth) {
options.params = {
consumer_key: this._opt.consumerKey,
consumer_secret: this._opt.consumerSecret
};
} else {
options.auth = {
username: this._opt.consumerKey,
password: this._opt.consumerSecret
};
}
options.params = { ...options.params, ...params };
} else {
options.params = this._getOAuth().authorize({
url,
method
});
}
if (options.data) {
options.headers = {
...header,
'Content-Type': `application/json; charset=${this._opt.encoding}`
};
}
// Allow set and override Axios options.
options = { ...options, ...this._opt.axiosConfig };
return axios(options).then((response) => response.data as WooCommerceResponse<T>);
}
/**
* GET requests
*
* @param {String} endpoint
* @param {Object} params
*
* @return {Object}
*/
get<T extends WooRestApiEndpoint>(endpoint: T, params?: Partial<WooRestApiParams>): Promise<WooCommerceResponse<T>> {
return this._request('GET', endpoint, undefined, params);
}
/**
* POST requests
*
* @param {String} endpoint
* @param {Object} data
* @param {Object} params
*
* @return {Object}
*/
post<T extends WooRestApiEndpoint>(
endpoint: T,
data: Record<string, unknown>,
params?: Partial<WooRestApiParams>
): Promise<WooCommerceResponse<T>> {
return this._request('POST', endpoint, data, params);
}
/**
* PUT requests
*
* @param {String} endpoint
* @param {Object} data
* @param {Object} params
*
* @return {Object}
*/
put<T extends WooRestApiEndpoint>(
endpoint: T,
data: Record<string, unknown>,
params?: Partial<WooRestApiParams>
): Promise<WooCommerceResponse<T>> {
return this._request('PUT', endpoint, data, params);
}
/**
* DELETE requests
*
* @param {String} endpoint
* @param {Object} params
* @param {Object} params
*
* @return {Object}
*/
delete<T extends WooRestApiEndpoint>(
endpoint: T,
data: Pick<WooRestApiParams, 'force'>,
params: Pick<WooRestApiParams, 'id'>
): Promise<WooCommerceResponse<T>> {
return this._request('DELETE', endpoint, data, params);
}
/**
* OPTIONS requests
*
* @param {String} endpoint
* @param {Object} params
*
* @return {Object}
*/
options<T extends WooRestApiEndpoint>(
endpoint: T,
params?: Partial<WooRestApiParams>
): Promise<WooCommerceResponse<T>> {
return this._request('OPTIONS', endpoint, {}, params);
}
}
/**
* Options Exception.
*/
export class OptionsException {
public name: 'Options Error';
public message: string;
/**
* Constructor.
*
* @param {String} message
*/
constructor(message: string) {
this.name = 'Options Error';
this.message = message;
}
}

View File

@ -0,0 +1,87 @@
export declare type WooRestApiVersion = "wc/v3";
// | "wc/v2"
// | "wc/v1"
// | "wc-api/v3"
// | "wc-api/v2"
// | "wc-api/v1";
export declare type WooRestApiEncoding = "utf-8" | "ascii";
export declare type WooRestApiMethod =
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "OPTIONS";
export declare type WooRestApiEndpoint =
| "coupons"
| "customers"
| "orders"
| "products"
| "products/attributes"
| "products/categories"
| "products/shipping_classes"
| "products/tags"
| "products/reviews"
| "system_status"
| "reports" // TODO: add support for reports
| "settings" // TODO: add support for settings
| "webhooks" // TODO: add support for webhooks
| "shipping" // TODO: add support for shipping
| "shipping_methods" // TODO: add support for shipping_methods
| "taxes" // TODO: add support for taxes
| "payment_gateways" // TODO: add support for payment_gateways
| string; // I need to have next endpoint: "orders/<id>/notes"
export declare type IWooRestApiQuery = Record<string, unknown>;
export type IWooCredentials = {
/* Your API consumer key */
consumerKey: string;
/* Your API consumer secret */
consumerSecret: string;
};
export type WooCommerceRestApiTypeFunctions = {
get: <T>(endpoint: string, params?: T) => Promise<any>;
post: <T>(endpoint: string, params?: T) => Promise<any>;
put: <T>(endpoint: string, params?: T) => Promise<any>;
delete: <T>(endpoint: string, params?: T) => Promise<any>;
};
export interface IWooRestApiOptions<T> extends IWooCredentials {
/* Your Store URL, example: http://woo.dev/ */
url: string;
/* Custom WP REST API URL prefix, used to support custom prefixes created with the `rest_url_prefix filter` */
wpAPIPrefix?: string;
/* API version, default is `v3` */
version?: WooRestApiVersion;
/* Encoding, default is 'utf-8' */
encoding?: WooRestApiEncoding;
/* When `true` and using under HTTPS force Basic Authentication as query string, default is `false` */
queryStringAuth?: boolean;
/* Provide support for URLs with ports, eg: `8080` */
port?: number;
/* Provide support for custom timeout, eg: `5000` */
timeout?: number;
/* Define the custom Axios config, also override this library options */
axiosConfig?: T;
/* Version of this library */
classVersion?: string;
/* Https or Http */
isHttps?: boolean;
}
export interface DELETE {
id: number | string;
force?: boolean | string;
}

View File

@ -0,0 +1,41 @@
import { Meta_Data } from './base';
export interface Coupon {
id: number;
code: string;
amount: string;
date_created: Date;
date_created_gmt: Date;
date_modified: Date;
date_modified_gmt: Date;
discount_type: string;
description: string;
date_expires: string;
date_expires_gmt: string;
usage_count: number;
individual_use: boolean;
product_ids: number[];
excluded_product_ids: number[];
usage_limit: number;
usage_limit_per_user: number;
limit_usage_to_x_items: number;
free_shipping: boolean;
product_categories: number[];
excluded_product_categories: number[];
exclude_sale_items: boolean;
minimum_amount: string;
maximum_amount: string;
email_restrictions: string[];
used_by: string[];
meta_data: Meta_Data[];
}
export type Coupon_Lines = {
id: number;
code: string;
discount: string;
discount_tax: string;
meta_data: Partial<Meta_Data>;
};
export type CouponsParams = Partial<Coupon>;

View File

@ -0,0 +1,23 @@
import { Meta_Data } from './base';
import { Billing } from './billing';
import { Shipping } from './shipping';
export interface Customer {
id: number;
date_created: Date;
date_created_gmt: Date;
date_modified: Date;
date_modified_gmt: Date;
email: string;
first_name: string;
last_name: string;
role: string;
username: string;
billing: Billing;
shipping: Shipping;
is_paying_customer: boolean;
avatar_url: string;
meta_data: Meta_Data[];
}
export type CustomersParams = Partial<Customer>;

View File

@ -0,0 +1,13 @@
import { Meta_Data } from './base';
import { Taxes } from './taxes';
export type Fee_Lines = {
id: number;
name: string;
tax_class: string;
tax_status: string;
total: string;
total_tax: string;
taxes: Partial<Taxes>[];
meta_data: Partial<Meta_Data>;
};

View File

@ -0,0 +1,19 @@
import { Meta_Data } from "./base";
import { Tax } from "./taxes";
export type Line_Item = {
id: number;
name: string;
product_id: number;
variation_id: number;
quantity: number;
tax_class: string;
subtotal: string;
subtotal_tax: string;
total: string;
total_tax: string;
taxes: Tax[];
meta_data: Meta_Data;
sku: string;
price: number;
};

View File

@ -0,0 +1,11 @@
export type Link = {
self: Array<{
href: string;
}>;
collection: Array<{
href: string;
}>;
up: Array<{
href: string;
}>;
};

View File

@ -0,0 +1,83 @@
import { Meta_Data } from './base';
import { Billing } from './billing';
import { Coupon_Lines } from './coupon';
import { Fee_Lines } from './fee';
import { Line_Item } from './item';
import { Order_Refund_Line_Item, Refund } from './refund';
import { Shipping, Shipping_Line } from './shipping';
import { Tax_Line } from './taxes';
export interface Order {
id: number;
parent_id: number;
number: string;
order_key: string;
created_via: string;
version: string;
status: string;
currency: string;
date_created: Date;
date_created_gmt: Date;
date_modified: Date;
date_modified_gmt: Date;
discount_total: string;
discount_tax: string;
shipping_total: string;
shipping_tax: string;
cart_tax: string;
total: string;
total_tax: string;
prices_include_tax: boolean;
customer_id: number;
customer_ip_address: string;
customer_user_agent: string;
customer_note: string;
billing: Partial<Billing>;
shipping: Shipping;
payment_method: string;
payment_method_title: string;
transaction_id: string;
date_paid: string;
date_paid_gmt: string;
date_completed: string;
date_completed_gmt: string;
cart_hash: string;
meta_data: Partial<Meta_Data>[];
line_items: Partial<Line_Item>[];
tax_lines: Partial<Tax_Line>[];
shipping_lines: Partial<Shipping_Line>[];
fee_lines: Partial<Fee_Lines>[];
coupon_lines: Partial<Coupon_Lines>[];
refunds: Partial<Refund>[];
set_paid: boolean;
}
export interface OrderNotes {
id: number;
author: string;
date_created: Date;
date_created_gmt: Date;
note: string;
customer_note: boolean;
added_by_user: boolean;
}
export interface OrderRefunds {
id: number;
date_created: Date;
date_created_gmt: Date;
amount: string;
reason: string;
refunded_by: number;
refunded_payment: boolean;
meta_data: Partial<Meta_Data>[];
line_items: Partial<Order_Refund_Line_Item>[];
api_refund: boolean;
}
export type OrderParams = Partial<Order>;
export type OrderNotesParams = Partial<OrderNotes>;
export type OrderRefundsParams = Partial<OrderRefunds>;
/**
* Union type for all possible params for Orders
*/
export type OrdersMainParams = OrderParams & OrderNotesParams & OrderRefundsParams;

View File

@ -0,0 +1,36 @@
export interface PaymentGatewaysSetting {
id: string;
label: string;
description: string;
type:
| 'text'
| 'email'
| 'number'
| 'color'
| 'password'
| 'textarea'
| 'select'
| 'multiselect'
| 'radio'
| 'image_width'
| 'checkbox';
value: string;
default: string;
tip: string;
placeholder: string;
}
export interface PaymentGateways {
id: string;
title: string;
description: string;
order: number;
enabled: boolean;
method_title: string;
method_description: string;
method_supports: string[];
settings: Partial<PaymentGatewaysSetting>[];
}
export type PaymentGatewayParams = Partial<PaymentGateways>;
export type PaymentGatewaySettingsParams = Partial<PaymentGatewaysSetting>;

View File

@ -0,0 +1,221 @@
import {
Attribute,
Category,
Default_Attribute,
Dimension,
Image,
Meta_Data,
Tag
} from './base';
export interface Product {
id: number;
name: string;
slug: string;
permalink: string;
date_created: Date;
date_created_gmt: Date;
date_modified: Date;
date_modified_gmt: Date;
catalog_visibility: string;
description: string;
short_description: string;
price: string;
regular_price: string;
sale_price: string;
date_on_sale_from: Date;
date_on_sale_from_gmt: Date;
date_on_sale_to: Date;
date_on_sale_to_gmt: Date;
price_html: string;
purchasable: boolean;
total_sales: number;
virtual: boolean;
downloadable: boolean;
external_url: string;
button_text: string;
tax_status: string;
manage_stock: boolean;
stock_quantity: number;
backorders: string;
backorders_allowed: boolean;
backordered: boolean;
sold_individually: boolean;
weight: string;
dimensions: Partial<Dimension>;
shipping_required: boolean;
shipping_taxable: boolean;
shipping_class: string;
shipping_class_id: number;
reviews_allowed: boolean;
average_rating: string;
rating_count: number;
related_ids: number[];
upsell_ids: number[];
cross_sell_ids: number[];
parent_id: number;
purchase_note: string;
categories: Partial<Category>[];
tags: Partial<Tag>[];
images: Partial<Image>[];
attributes: Partial<Attribute>[];
default_attributes: Partial<Default_Attribute>[];
variations: number[];
grouped_Product: number[];
menu_order: number;
meta_data: Partial<Meta_Data>[];
per_page: number;
page: number;
context: 'views' | 'edit' | string;
search: string;
after: string;
before: string;
modified_after: string;
modified_before: string;
dates_are_gmt: boolean;
exclude: number[];
include: number[];
offset: number;
order: 'asc' | 'desc' | string;
orderby:
| 'id'
| 'include'
| 'name'
| 'date'
| 'title'
| 'slug'
| 'price'
| 'popularity'
| 'rating'
| string;
parent: number[];
parent_exclude: number[];
status: 'draft' | 'any' | 'pending' | 'publish' | 'private' | string;
type: 'simple' | 'grouped' | 'external' | 'variable' | string;
sku: string;
featured: boolean;
category: string;
tag: string;
attribute: string;
attribute_term: string;
tax_class: string;
on_sale: boolean;
min_price: string;
max_price: string;
stock_status: 'instock' | 'outofstock' | 'onbackorder' | string;
}
export interface ProductVariations {
id: number;
date_created: Date;
date_created_gmt: Date;
date_modified: Date;
date_modified_gmt: Date;
description: string;
permalink: string;
sku: string;
price: string;
regular_price: string;
sale_price: string;
date_on_sale_from: Date;
date_on_sale_from_gmt: Date;
date_on_sale_to: Date;
date_on_sale_to_gmt: Date;
on_sale: boolean;
status: string;
purchasable: boolean;
virtual: boolean;
downloadable: boolean;
tax_status: string;
tax_class: string;
manage_stock: boolean;
stock_quantity: number;
stock_status: string;
backorders: string;
backorders_allowed: boolean;
backordered: boolean;
weight: string;
dimensions: Partial<Dimension>;
shipping_class: string;
shipping_class_id: number;
image: Partial<Image>;
attributes: Partial<Attribute>[];
menu_order: number;
meta_data: Partial<Meta_Data>[];
}
export interface ProductAttributes {
id: number;
name: string;
slug: string;
type: string;
order_by: string;
has_archives: boolean;
}
export interface ProductAttributesTerms {
id: number;
name: string;
slug: string;
description: string;
menu_order: number;
count: number;
}
export interface ProductCategories {
id: number;
name: string;
slug: string;
parent: number;
description: string;
display: string;
image: Partial<Image>;
menu_order: number;
count: number;
}
export interface ProductShippingClass {
id: number;
name: string;
slug: string;
description: string;
count: number;
}
export interface ProductTags {
id: number;
name: string;
slug: string;
description: string;
count: number;
}
export interface ProductReviews {
id: number;
date_created: Date;
date_created_gmt: Date;
parent_id: number;
status: string;
reviewer: string;
reviewer_email: string;
review: string;
verified: boolean;
}
type ProductParams = Partial<Product>;
type ProductVariationsParams = Partial<ProductVariations>;
type ProductAttributesParams = Partial<ProductAttributes>;
type ProductAttributesTermsParams = Partial<ProductAttributesTerms>;
type ProductCategoriesParams = Partial<ProductCategories>;
type ProductShippingClassesParams = Partial<ProductShippingClass>;
type ProductTagsParams = Partial<ProductTags>;
type ProductReviewsParams = Partial<ProductReviews>;
export type ProductMainParams =
| (ProductParams & ProductVariationsParams & ProductAttributesParams)
| ProductAttributesTermsParams
| ProductCategoriesParams
| ProductShippingClassesParams
| ProductTagsParams
| ProductReviewsParams;

View File

@ -0,0 +1,32 @@
import { Meta_Data } from "./base";
export type Refund = {
id: number;
reason: string;
total: string;
};
export type Order_Refund_Line = {
id: number;
total: string;
subtotal: string;
refund_total: number;
};
export type Order_Refund_Line_Item = {
id: number;
name: string;
product_id: number;
variation_id: number;
quantity: number;
tax_class: string;
subtotal: string;
subtotal_tax: string;
total: string;
total_tax: string;
taxes: Partial<Order_Refund_Line>[];
meta_data: Partial<Meta_Data>[];
sku: string;
price: string;
refund_total: number;
};

View File

@ -0,0 +1,79 @@
import { Meta_Data } from './base';
import { Tax } from './taxes';
export type Shipping = {
first_name: string;
last_name: string;
company: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
};
export type Shipping_Line = {
id: number;
method_title: string;
method_id: string;
total: string;
total_tax: string;
taxes: Tax[];
meta_data: Meta_Data;
};
export interface ShippingZone {
id: number;
name: string;
order: number;
}
export interface ShippingZoneLocation {
code: string;
type: 'postcode' | 'state' | 'country' | 'continent';
}
export interface ShippingZoneMethodSetting {
id: string;
label: string;
description: string;
type:
| 'text'
| 'email'
| 'number'
| 'color'
| 'password'
| 'textarea'
| 'select'
| 'multiselect'
| 'radio'
| 'image_width'
| 'checkbox';
value: string;
default: string;
tip: string;
placeholder: string;
}
export interface ShippingZoneMethod {
instace_id: number;
title: string;
order: number;
enabled: boolean;
method_id: string;
method_title: string;
method_description: string;
method_supports: Partial<ShippingZoneMethodSetting>[];
}
export interface ShippingMethods {
id: string;
title: string;
description: string;
}
export type ShippingZonesParams = Partial<ShippingZone>;
export type ShippingZonesLocationsParams = Partial<ShippingZoneLocation>;
export type ShippingZonesMethodsParams = Partial<ShippingZoneMethod>;
export type ShippingMethodsParams = Partial<ShippingMethods>;

View File

@ -0,0 +1,48 @@
import { Meta_Data } from './base';
export type Tax = {
id: number;
rate_code: string;
rate_id: number;
label: string;
compound: boolean;
tax_total: string;
shipping_tax_total: string;
meta_data: Meta_Data;
};
export type Tax_Line = {
id: number;
rate_code: string;
rate_id: number;
label: string;
compound: boolean;
tax_total: string;
shipping_tax_total: string;
meta_data: Partial<Meta_Data>;
};
export interface TaxRate {
id: number;
country: string;
state: string;
postcode: string;
city: string;
postcodes: string[];
cities: string[];
rate: string;
name: string;
priority: number;
compound: boolean;
shipping: boolean;
order: number;
class: string;
}
export interface TaxClass {
slug: string;
name: string;
}
export type TaxRatesParams = Partial<TaxRate>;
export type TaxClassesParams = Partial<TaxClass>;

View File

@ -0,0 +1,32 @@
import { Link } from './link';
export interface Webhooks {
id: number;
name: string;
status: 'all' | 'active' | 'paused' | 'disabled' | string;
topic: string;
resource: string;
event: string;
hooks: string[];
delivery_url: string;
secret: string;
date_created: Date;
date_created_gmt: Date;
date_modified: Date;
date_modified_gmt: Date;
links: Partial<Link>;
context: 'view' | 'edit' | string;
page: 1 | number;
per_page: 10 | 25 | 50 | 100 | number;
search: string;
after: string;
before: string;
exclude: number[];
include: number[];
offset: number;
order: 'asc' | 'desc' | string;
orderby: 'id' | 'include' | 'name' | 'date' | 'title' | 'slug' | string;
force: boolean;
}
export type WebhooksParams = Partial<Webhooks>;

View File

@ -0,0 +1,56 @@
import axios, { AxiosInstance, RawAxiosRequestHeaders } from 'axios';
import { Cart } from './models/cart';
/**
* WooCommerce Store API Client for server-side requests.
* 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;
constructor(baseURL: string) {
const headers: RawAxiosRequestHeaders = {
'Content-Type': 'application/json',
'Accept': '*/*',
};
this.client = axios.create({
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}`;
}
}
async getCart(params?: Record<string, string | number>): Promise<Cart> {
return this.client.get<Cart>('/cart', { params }).then((response) => response.data);
}
async addToCart(payload: { id: string | number; quantity: number; variation: { attribute: string; value: string }[] }): Promise<Cart> {
return this.client.post<Cart>('/cart/add-item', payload).then((response) => response.data);
}
async updateItem(payload: { key: string | number; quantity: number; }): Promise<Cart> {
return this.client.post<Cart>('/cart/update-item', payload).then((response) => response.data);
}
async removeFromCart(payload: { key: string | number }): Promise<Cart> {
return this.client.post<Cart>(`/cart/remove-item?key=${payload.key}`).then((response) => response.data);
}
}
// Example usage.
const baseURL = 'http://wordpress.localhost/wp-json/wc/store/v1'; // Replace with your WooCommerce API URL.
export const storeApi = new WooCommerceStoreApiClient(baseURL);

View File

@ -0,0 +1,11 @@
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",
consumerSecret: process.env.WOOCOMMERCE_CONSUMER_SECRET ?? "cs_ee4f1c9e061d07a7cb6025b69d414189a9157e20",
isHttps: false,
version: "wc/v3",
queryStringAuth: false // Force Basic Authentication as query string true and using under
}
export const woocommerce = new WooCommerceRestApi(option);

View File

@ -3,9 +3,8 @@ export default {
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: "http",
hostname: 'cdn.shopify.com', hostname: "**",
pathname: '/s/files/**'
} }
] ]
} }

View File

@ -14,6 +14,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.3.1", "geist": "^1.3.1",
"next": "15.0.4", "next": "15.0.4",
"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"
@ -24,11 +25,15 @@
"@types/node": "22.10.1", "@types/node": "22.10.1",
"@types/react": "19.0.0", "@types/react": "19.0.0",
"@types/react-dom": "19.0.1", "@types/react-dom": "19.0.1",
"@types/url-parse": "^1.4.11",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.7.9",
"oauth-1.0a": "^2.2.6",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "3.4.2", "prettier": "3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.16",
"typescript": "5.7.2" "typescript": "5.7.2",
"url-parse": "^1.5.10"
} }
} }

237
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ importers:
next: next:
specifier: 15.0.4 specifier: 15.0.4
version: 15.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 15.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(next@15.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: react:
specifier: 19.0.0 specifier: 19.0.0
version: 19.0.0 version: 19.0.0
@ -48,9 +51,18 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: 19.0.1 specifier: 19.0.1
version: 19.0.1 version: 19.0.1
'@types/url-parse':
specifier: ^1.4.11
version: 1.4.11
autoprefixer: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.4.20(postcss@8.4.49) version: 10.4.20(postcss@8.4.49)
axios:
specifier: ^1.7.9
version: 1.7.9
oauth-1.0a:
specifier: ^2.2.6
version: 2.2.6
postcss: postcss:
specifier: ^8.4.49 specifier: ^8.4.49
version: 8.4.49 version: 8.4.49
@ -66,6 +78,9 @@ importers:
typescript: typescript:
specifier: 5.7.2 specifier: 5.7.2
version: 5.7.2 version: 5.7.2
url-parse:
specifier: ^1.5.10
version: 1.5.10
packages: packages:
@ -73,6 +88,10 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@babel/runtime@7.26.0':
resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
engines: {node: '>=6.9.0'}
'@emnapi/runtime@1.3.1': '@emnapi/runtime@1.3.1':
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
@ -299,6 +318,9 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -371,6 +393,9 @@ packages:
'@types/react@19.0.0': '@types/react@19.0.0':
resolution: {integrity: sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==} resolution: {integrity: sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==}
'@types/url-parse@1.4.11':
resolution: {integrity: sha512-FKvKIqRaykZtd4n47LbK/W/5fhQQ1X7cxxzG9A48h0BGN+S04NH7ervcCjM8tyR0lyGru83FAHSmw2ObgKoESg==}
ansi-regex@5.0.1: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -397,6 +422,9 @@ packages:
arg@5.0.2: arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autoprefixer@10.4.20: autoprefixer@10.4.20:
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -404,6 +432,9 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.1.0 postcss: ^8.1.0
axios@1.7.9:
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -459,10 +490,18 @@ packages:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'} engines: {node: '>=12.5.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@4.1.1: commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -475,6 +514,10 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.0.3: detect-libc@2.0.3:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -512,10 +555,23 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
foreground-child@3.3.0: foreground-child@3.3.0:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'} engines: {node: '>=14'}
form-data@4.0.1:
resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
engines: {node: '>= 6'}
fraction.js@4.3.7: fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@ -585,6 +641,9 @@ packages:
resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
hasBin: true hasBin: true
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
lilconfig@3.1.3: lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -604,6 +663,10 @@ packages:
lru-cache@10.4.3: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -612,6 +675,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@9.0.5: minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
@ -628,6 +699,20 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
next-auth@4.24.11:
resolution: {integrity: sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==}
peerDependencies:
'@auth/core': 0.34.2
next: ^12.2.5 || ^13 || ^14 || ^15
nodemailer: ^6.6.5
react: ^17.0.2 || ^18 || ^19
react-dom: ^17.0.2 || ^18 || ^19
peerDependenciesMeta:
'@auth/core':
optional: true
nodemailer:
optional: true
next@15.0.4: next@15.0.4:
resolution: {integrity: sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==} resolution: {integrity: sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@ -660,14 +745,31 @@ packages:
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
oauth-1.0a@2.2.6:
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
oauth@0.9.15:
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
object-hash@3.0.0: object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
oidc-token-hash@5.0.3:
resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==}
engines: {node: ^10.13.0 || >=12.0.0}
openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
package-json-from-dist@1.0.1: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -746,6 +848,14 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@5.2.6:
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
preact: '>=10'
preact@10.25.3:
resolution: {integrity: sha512-dzQmIFtM970z+fP9ziQ3yG4e3ULIbwZzJ734vaMVUTaKQ2+Ru1Ou/gjshOYVHCcd1rpAelC6ngjvjDXph98unQ==}
prettier-plugin-tailwindcss@0.6.9: prettier-plugin-tailwindcss@0.6.9:
resolution: {integrity: sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==} resolution: {integrity: sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
@ -806,6 +916,15 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
pretty-format@3.8.0:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -825,6 +944,12 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
resolve@1.22.8: resolve@1.22.8:
resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
hasBin: true hasBin: true
@ -954,9 +1079,16 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -970,6 +1102,9 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yaml@2.6.1: yaml@2.6.1:
resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@ -979,6 +1114,10 @@ snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@babel/runtime@7.26.0':
dependencies:
regenerator-runtime: 0.14.1
'@emnapi/runtime@1.3.1': '@emnapi/runtime@1.3.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -1161,6 +1300,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.17.1 fastq: 1.17.1
'@panva/hkdf@1.2.1': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@ -1246,6 +1387,8 @@ snapshots:
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
'@types/url-parse@1.4.11': {}
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-regex@6.1.0: {} ansi-regex@6.1.0: {}
@ -1265,6 +1408,8 @@ snapshots:
arg@5.0.2: {} arg@5.0.2: {}
asynckit@0.4.0: {}
autoprefixer@10.4.20(postcss@8.4.49): autoprefixer@10.4.20(postcss@8.4.49):
dependencies: dependencies:
browserslist: 4.24.2 browserslist: 4.24.2
@ -1275,6 +1420,14 @@ snapshots:
postcss: 8.4.49 postcss: 8.4.49
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
axios@1.7.9:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.1
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
@ -1336,8 +1489,14 @@ snapshots:
color-string: 1.9.1 color-string: 1.9.1
optional: true optional: true
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@4.1.1: {} commander@4.1.1: {}
cookie@0.7.2: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -1348,6 +1507,8 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
delayed-stream@1.0.0: {}
detect-libc@2.0.3: detect-libc@2.0.3:
optional: true optional: true
@ -1381,11 +1542,19 @@ snapshots:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
follow-redirects@1.15.9: {}
foreground-child@3.3.0: foreground-child@3.3.0:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
form-data@4.0.1:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
fraction.js@4.3.7: {} fraction.js@4.3.7: {}
fsevents@2.3.3: fsevents@2.3.3:
@ -1449,6 +1618,8 @@ snapshots:
jiti@1.21.6: {} jiti@1.21.6: {}
jose@4.15.9: {}
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
@ -1461,6 +1632,10 @@ snapshots:
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@ -1468,6 +1643,12 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@9.0.5: minimatch@9.0.5:
dependencies: dependencies:
brace-expansion: 2.0.1 brace-expansion: 2.0.1
@ -1482,6 +1663,21 @@ snapshots:
nanoid@3.3.8: {} nanoid@3.3.8: {}
next-auth@4.24.11(next@15.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.0
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 15.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.25.3
preact-render-to-string: 5.2.6(preact@10.25.3)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
uuid: 8.3.2
next@15.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): next@15.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies: dependencies:
'@next/env': 15.0.4 '@next/env': 15.0.4
@ -1513,10 +1709,25 @@ snapshots:
normalize-range@0.1.2: {} normalize-range@0.1.2: {}
oauth-1.0a@2.2.6: {}
oauth@0.9.15: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-hash@2.2.0: {}
object-hash@3.0.0: {} object-hash@3.0.0: {}
oidc-token-hash@5.0.3: {}
openid-client@5.7.1:
dependencies:
jose: 4.15.9
lru-cache: 6.0.0
object-hash: 2.2.0
oidc-token-hash: 5.0.3
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@ -1584,12 +1795,25 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
preact-render-to-string@5.2.6(preact@10.25.3):
dependencies:
preact: 10.25.3
pretty-format: 3.8.0
preact@10.25.3: {}
prettier-plugin-tailwindcss@0.6.9(prettier@3.4.2): prettier-plugin-tailwindcss@0.6.9(prettier@3.4.2):
dependencies: dependencies:
prettier: 3.4.2 prettier: 3.4.2
prettier@3.4.2: {} prettier@3.4.2: {}
pretty-format@3.8.0: {}
proxy-from-env@1.1.0: {}
querystringify@2.2.0: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
react-dom@19.0.0(react@19.0.0): react-dom@19.0.0(react@19.0.0):
@ -1607,6 +1831,10 @@ snapshots:
dependencies: dependencies:
picomatch: 2.3.1 picomatch: 2.3.1
regenerator-runtime@0.14.1: {}
requires-port@1.0.0: {}
resolve@1.22.8: resolve@1.22.8:
dependencies: dependencies:
is-core-module: 2.15.1 is-core-module: 2.15.1
@ -1765,8 +1993,15 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@8.3.2: {}
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
@ -1783,4 +2018,6 @@ snapshots:
string-width: 5.1.2 string-width: 5.1.2
strip-ansi: 7.1.0 strip-ansi: 7.1.0
yallist@4.0.0: {}
yaml@2.6.1: {} yaml@2.6.1: {}

View File

@ -23,6 +23,6 @@
} }
] ]
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "types/**/*.d.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

31
types/next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
import 'next-auth';
import 'next-auth/jwt';
declare module 'next-auth' {
interface Session {
user: {
token: string;
user_email: string;
user_nicename: string;
user_display_name: string;
}
}
interface User {
token: string;
user_email: string;
user_nicename: string;
user_display_name: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
user: {
token: string;
user_email: string;
user_nicename: string;
user_display_name: string;
}
}
}