mirror of
https://github.com/vercel/commerce.git
synced 2025-05-19 16:07:01 +00:00
Updates
This commit is contained in:
parent
6527974989
commit
3e513eaf99
@ -1,10 +1,9 @@
|
|||||||
import Footer from 'components/layout/footer/footer';
|
import Footer from 'components/layout/footer/footer';
|
||||||
import Header from 'components/layout/header/header';
|
import Header from 'components/layout/header/header';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { supportedLanguages } from '../../i18n-config';
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -33,34 +32,30 @@ const inter = Inter({
|
|||||||
variable: '--font-inter'
|
variable: '--font-inter'
|
||||||
});
|
});
|
||||||
|
|
||||||
export function generateStaticParams() {
|
// export function generateStaticParams() {
|
||||||
return supportedLanguages.locales.map((locale) => ({ locale: locale.id }));
|
// return supportedLanguages.locales.map((locale) => ({ locale: locale.id }));
|
||||||
}
|
// }
|
||||||
|
|
||||||
interface LocaleLayoutProps {
|
export default function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params
|
||||||
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
params: {
|
params: { locale: string };
|
||||||
locale: string;
|
}) {
|
||||||
};
|
const locale = useLocale();
|
||||||
}
|
|
||||||
|
|
||||||
export default async function LocaleLayout({ children, params: { locale } }: LocaleLayoutProps) {
|
// Show a 404 error if the user requests an unknown locale
|
||||||
let messages;
|
if (params.locale !== locale) {
|
||||||
|
|
||||||
try {
|
|
||||||
messages = (await import(`../../messages/${locale}.json`)).default;
|
|
||||||
} catch (error) {
|
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className={inter.variable}>
|
<html lang={locale} className={inter.variable}>
|
||||||
<body className="flex min-h-screen flex-col">
|
<body className="flex min-h-screen flex-col">
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<Header />
|
||||||
<Header />
|
<main className="flex-1">{children}</main>
|
||||||
<main className="flex-1">{children}</main>
|
<Footer />
|
||||||
<Footer />
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
69
components/cart/actions.ts
Normal file
69
components/cart/actions.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export const addItem = async (variantId: string | undefined): Promise<String | undefined> => {
|
||||||
|
let cartId = cookies().get('cartId')?.value;
|
||||||
|
let cart;
|
||||||
|
|
||||||
|
if (cartId) {
|
||||||
|
cart = await getCart(cartId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cartId || !cart) {
|
||||||
|
cart = await createCart();
|
||||||
|
cartId = cart.id;
|
||||||
|
cookies().set('cartId', cartId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!variantId) {
|
||||||
|
return 'Missing product variant ID';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Error adding item to cart';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeItem = async (lineId: string): Promise<String | undefined> => {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return 'Missing cart ID';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removeFromCart(cartId, [lineId]);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Error removing item from cart';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateItemQuantity = async ({
|
||||||
|
lineId,
|
||||||
|
variantId,
|
||||||
|
quantity
|
||||||
|
}: {
|
||||||
|
lineId: string;
|
||||||
|
variantId: string;
|
||||||
|
quantity: number;
|
||||||
|
}): Promise<String | undefined> => {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return 'Missing cart ID';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateCart(cartId, [
|
||||||
|
{
|
||||||
|
id: lineId,
|
||||||
|
merchandiseId: variantId,
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Error updating item quantity';
|
||||||
|
}
|
||||||
|
};
|
68
components/cart/add-to-cart.tsx
Normal file
68
components/cart/add-to-cart.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { addItem } from 'components/cart/actions';
|
||||||
|
import LoadingDots from 'components/loading-dots';
|
||||||
|
import { ProductVariant } from 'lib/shopify/types';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
export function AddToCart({
|
||||||
|
variants,
|
||||||
|
availableForSale
|
||||||
|
}: {
|
||||||
|
variants: ProductVariant[];
|
||||||
|
availableForSale: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||||
|
const variant = variants.find((variant: ProductVariant) =>
|
||||||
|
variant.selectedOptions.every(
|
||||||
|
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const selectedVariantId = variant?.id || defaultVariantId;
|
||||||
|
const title = !availableForSale
|
||||||
|
? 'Out of stock'
|
||||||
|
: !selectedVariantId
|
||||||
|
? 'Please select options'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label="Add item to cart"
|
||||||
|
disabled={isPending || !availableForSale || !selectedVariantId}
|
||||||
|
title={title}
|
||||||
|
onClick={() => {
|
||||||
|
// Safeguard in case someone messes with `disabled` in devtools.
|
||||||
|
if (!availableForSale || !selectedVariantId) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const error = await addItem(selectedVariantId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Trigger the error boundary in the root error.js
|
||||||
|
throw new Error(error.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
|
||||||
|
{
|
||||||
|
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
|
||||||
|
'cursor-not-allowed': isPending
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 ml-4">
|
||||||
|
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
|
||||||
|
</div>
|
||||||
|
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
@ -1,64 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
import CartIcon from 'components/icons/cart';
|
|
||||||
import CartModal from './modal';
|
|
||||||
|
|
||||||
import type { Cart } from 'lib/shopify/types';
|
|
||||||
|
|
||||||
export default function CartButton({
|
|
||||||
cart,
|
|
||||||
cartIdUpdated
|
|
||||||
}: {
|
|
||||||
cart: Cart;
|
|
||||||
cartIdUpdated: boolean;
|
|
||||||
}) {
|
|
||||||
const [, setCookie] = useCookies(['cartId']);
|
|
||||||
const [cartIsOpen, setCartIsOpen] = useState(false);
|
|
||||||
const quantityRef = useRef(cart.totalQuantity);
|
|
||||||
|
|
||||||
// Temporary hack to update the `cartId` cookie when it changes since we cannot update it
|
|
||||||
// on the server-side (yet).
|
|
||||||
useEffect(() => {
|
|
||||||
if (cartIdUpdated) {
|
|
||||||
setCookie('cartId', cart.id, {
|
|
||||||
path: '/',
|
|
||||||
sameSite: 'strict',
|
|
||||||
secure: process.env.NODE_ENV === 'production'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, [setCookie, cartIdUpdated, cart.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Open cart modal when when quantity changes.
|
|
||||||
if (cart.totalQuantity !== quantityRef.current) {
|
|
||||||
// But only if it's not already open (quantity also changes when editing items in cart).
|
|
||||||
if (!cartIsOpen) {
|
|
||||||
setCartIsOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always update the quantity reference
|
|
||||||
quantityRef.current = cart.totalQuantity;
|
|
||||||
}
|
|
||||||
}, [cartIsOpen, cart.totalQuantity, quantityRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CartModal isOpen={cartIsOpen} onClose={() => setCartIsOpen(false)} cart={cart} />
|
|
||||||
|
|
||||||
<button
|
|
||||||
aria-label="Open cart"
|
|
||||||
onClick={() => {
|
|
||||||
setCartIsOpen(true);
|
|
||||||
}}
|
|
||||||
className="relative right-0 top-0"
|
|
||||||
data-testid="open-cart"
|
|
||||||
>
|
|
||||||
<CartIcon quantity={cart.totalQuantity} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
10
components/cart/close-cart.tsx
Normal file
10
components/cart/close-cart.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function CloseCart({ className }: { className?: string }) {
|
||||||
|
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">
|
||||||
|
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110 ', className)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,53 +1,43 @@
|
|||||||
import CloseIcon from 'components/icons/close';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import LoadingDots from 'components/ui/loading-dots';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { startTransition, useState } from 'react';
|
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { removeItem } from 'components/cart/actions';
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [removing, setRemoving] = useState(false);
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
async function handleRemove() {
|
|
||||||
setRemoving(true);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/cart`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: JSON.stringify({
|
|
||||||
lineId: item.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
alert(data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRemoving(false);
|
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Remove cart item"
|
aria-label="Remove cart item"
|
||||||
onClick={handleRemove}
|
onClick={() => {
|
||||||
disabled={removing}
|
startTransition(async () => {
|
||||||
|
const error = await removeItem(item.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Trigger the error boundary in the root error.js
|
||||||
|
throw new Error(error.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed px-0': removing
|
'cursor-not-allowed px-0': isPending
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{removing ? (
|
{isPending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-white" />
|
||||||
) : (
|
) : (
|
||||||
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
|
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { startTransition, useState } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import MinusIcon from 'components/icons/minus';
|
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||||
import PlusIcon from 'components/icons/plus';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import LoadingDots from 'components/ui/loading-dots';
|
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
|
||||||
export default function EditItemQuantityButton({
|
export default function EditItemQuantityButton({
|
||||||
@ -15,52 +15,45 @@ export default function EditItemQuantityButton({
|
|||||||
type: 'plus' | 'minus';
|
type: 'plus' | 'minus';
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [editing, setEditing] = useState(false);
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
async function handleEdit() {
|
|
||||||
setEditing(true);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/cart`, {
|
|
||||||
method: type === 'minus' && item.quantity - 1 === 0 ? 'DELETE' : 'PUT',
|
|
||||||
body: JSON.stringify({
|
|
||||||
lineId: item.id,
|
|
||||||
variantId: item.merchandise.id,
|
|
||||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
alert(data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditing(false);
|
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||||
onClick={handleEdit}
|
onClick={() => {
|
||||||
disabled={editing}
|
startTransition(async () => {
|
||||||
|
const error =
|
||||||
|
type === 'minus' && item.quantity - 1 === 0
|
||||||
|
? await removeItem(item.id)
|
||||||
|
: await updateItemQuantity({
|
||||||
|
lineId: item.id,
|
||||||
|
variantId: item.merchandise.id,
|
||||||
|
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Trigger the error boundary in the root error.js
|
||||||
|
throw new Error(error.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed': editing,
|
'cursor-not-allowed': isPending,
|
||||||
'ml-auto': type === 'minus'
|
'ml-auto': type === 'minus'
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{editing ? (
|
{isPending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-black dark:bg-white" />
|
||||||
) : type === 'plus' ? (
|
) : type === 'plus' ? (
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
) : (
|
) : (
|
||||||
<MinusIcon className="h-4 w-4" />
|
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -1,23 +1,14 @@
|
|||||||
import { createCart, getCart } from 'lib/shopify';
|
import { getCart } from 'lib/shopify';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import CartButton from './button';
|
import CartModal from './modal';
|
||||||
|
|
||||||
export default async function Cart() {
|
export default async function Cart() {
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cartIdUpdated = false;
|
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
if (cartId) {
|
if (cartId) {
|
||||||
cart = await getCart(cartId);
|
cart = await getCart(cartId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the `cartId` from the cookie is not set or the cart is empty
|
return <CartModal cart={cart} />;
|
||||||
// (old carts becomes `null` when you checkout), then get a new `cartId`
|
|
||||||
// and re-fetch the cart.
|
|
||||||
if (!cartId || !cart) {
|
|
||||||
cart = await createCart();
|
|
||||||
cartIdUpdated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <CartButton cart={cart} cartIdUpdated={cartIdUpdated} />;
|
|
||||||
}
|
}
|
||||||
|
@ -1,84 +1,86 @@
|
|||||||
import { Dialog } from '@headlessui/react';
|
'use client';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import CloseIcon from 'components/icons/close';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import ShoppingBagIcon from 'components/icons/shopping-bag';
|
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||||
import Price from 'components/product/price';
|
import Price from 'components/price';
|
||||||
import { DEFAULT_OPTION } from 'lib/constants';
|
import { DEFAULT_OPTION } from 'lib/constants';
|
||||||
import type { Cart } from 'lib/shopify/types';
|
import type { Cart } from 'lib/shopify/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
|
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';
|
||||||
|
|
||||||
type MerchandiseSearchParams = {
|
type MerchandiseSearchParams = {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CartModal({
|
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||||
isOpen,
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
onClose,
|
const quantityRef = useRef(cart?.totalQuantity);
|
||||||
cart
|
const openCart = () => setIsOpen(true);
|
||||||
}: {
|
const closeCart = () => setIsOpen(false);
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
cart: Cart;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{isOpen && (
|
|
||||||
<Dialog
|
|
||||||
as={motion.div}
|
|
||||||
initial="closed"
|
|
||||||
animate="open"
|
|
||||||
exit="closed"
|
|
||||||
key="dialog"
|
|
||||||
static
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
className="relative z-50"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
variants={{
|
|
||||||
open: { opacity: 1, backdropFilter: 'blur(0.5px)' },
|
|
||||||
closed: { opacity: 0, backdropFilter: 'blur(0px)' }
|
|
||||||
}}
|
|
||||||
className="fixed inset-0 bg-black/30"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 flex justify-end" data-testid="cart">
|
useEffect(() => {
|
||||||
<Dialog.Panel
|
// Open cart modal when quantity changes.
|
||||||
as={motion.div}
|
if (cart?.totalQuantity !== quantityRef.current) {
|
||||||
variants={{
|
// But only if it's not already open (quantity also changes when editing items in cart).
|
||||||
open: { translateX: 0 },
|
if (!isOpen) {
|
||||||
closed: { translateX: '100%' }
|
setIsOpen(true);
|
||||||
}}
|
}
|
||||||
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
|
|
||||||
className="flex w-full flex-col bg-white p-8 text-black dark:bg-black dark:text-white md:w-3/5 lg:w-2/5"
|
// Always update the quantity reference
|
||||||
>
|
quantityRef.current = cart?.totalQuantity;
|
||||||
|
}
|
||||||
|
}, [isOpen, cart?.totalQuantity, quantityRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button aria-label="Open cart" onClick={openCart}>
|
||||||
|
<OpenCart quantity={cart?.totalQuantity} />
|
||||||
|
</button>
|
||||||
|
<Transition show={isOpen}>
|
||||||
|
<Dialog onClose={closeCart} 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 dark:border-neutral-700 dark:bg-black/80 dark:text-white md:w-[390px]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-lg font-bold">My Cart</p>
|
<p className="text-lg font-semibold">My Cart</p>
|
||||||
<button
|
|
||||||
aria-label="Close cart"
|
<button aria-label="Close cart" onClick={closeCart}>
|
||||||
onClick={onClose}
|
<CloseCart />
|
||||||
className="text-black transition-colors hover:text-gray-500 dark:text-gray-100"
|
|
||||||
data-testid="close-cart"
|
|
||||||
>
|
|
||||||
<CloseIcon className="h-7" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cart.lines.length === 0 ? (
|
{!cart || cart.lines.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">
|
||||||
<ShoppingBagIcon 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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
{cart.lines.length !== 0 ? (
|
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||||
<div className="flex h-full flex-col justify-between overflow-hidden">
|
<ul className="flex-grow overflow-auto py-4">
|
||||||
<ul className="flex-grow overflow-auto p-6">
|
|
||||||
{cart.lines.map((item, i) => {
|
{cart.lines.map((item, i) => {
|
||||||
const merchandiseSearchParams = {} as MerchandiseSearchParams;
|
const merchandiseSearchParams = {} as MerchandiseSearchParams;
|
||||||
|
|
||||||
@ -94,77 +96,79 @@ export default function CartModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={i} data-testid="cart-item">
|
<li
|
||||||
<Link
|
key={i}
|
||||||
className="flex flex-row space-x-4 py-4"
|
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
||||||
href={merchandiseUrl}
|
>
|
||||||
onClick={onClose}
|
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||||
>
|
<div className="absolute z-40 -mt-2 ml-[55px]">
|
||||||
<div className="relative h-16 w-16 cursor-pointer overflow-hidden bg-white">
|
<DeleteItemButton item={item} />
|
||||||
<Image
|
</div>
|
||||||
className="h-full w-full object-cover"
|
<Link
|
||||||
width={64}
|
href={merchandiseUrl}
|
||||||
height={64}
|
onClick={closeCart}
|
||||||
alt={
|
className="z-30 flex flex-row space-x-4"
|
||||||
item.merchandise.product.featuredImage.altText ||
|
>
|
||||||
item.merchandise.product.title
|
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||||
}
|
<Image
|
||||||
src={item.merchandise.product.featuredImage.url}
|
className="h-full w-full object-cover"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
alt={
|
||||||
|
item.merchandise.product.featuredImage.altText ||
|
||||||
|
item.merchandise.product.title
|
||||||
|
}
|
||||||
|
src={item.merchandise.product.featuredImage.url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col text-base">
|
||||||
|
<span className="leading-tight">
|
||||||
|
{item.merchandise.product.title}
|
||||||
|
</span>
|
||||||
|
{item.merchandise.title !== DEFAULT_OPTION ? (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{item.merchandise.title}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="flex h-16 flex-col justify-between">
|
||||||
|
<Price
|
||||||
|
className="flex justify-end space-y-2 text-right text-sm"
|
||||||
|
amount={item.cost.totalAmount.amount}
|
||||||
|
currencyCode={item.cost.totalAmount.currencyCode}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex flex-1 flex-col text-base">
|
<EditItemQuantityButton item={item} type="minus" />
|
||||||
<span className="font-semibold">
|
<p className="w-6 text-center">
|
||||||
{item.merchandise.product.title}
|
<span className="w-full text-sm">{item.quantity}</span>
|
||||||
</span>
|
|
||||||
{item.merchandise.title !== DEFAULT_OPTION ? (
|
|
||||||
<p className="text-sm" data-testid="cart-product-variant">
|
|
||||||
{item.merchandise.title}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
<EditItemQuantityButton item={item} type="plus" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Price
|
|
||||||
className="flex flex-col justify-between space-y-2 text-sm"
|
|
||||||
amount={item.cost.totalAmount.amount}
|
|
||||||
currencyCode={item.cost.totalAmount.currencyCode}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<div className="flex h-9 flex-row">
|
|
||||||
<DeleteItemButton item={item} />
|
|
||||||
<p className="ml-2 flex w-full items-center justify-center border dark:border-gray-700">
|
|
||||||
<span className="w-full px-2">{item.quantity}</span>
|
|
||||||
</p>
|
|
||||||
<EditItemQuantityButton item={item} type="minus" />
|
|
||||||
<EditItemQuantityButton item={item} type="plus" />
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="border-t border-gray-200 pt-2 text-sm text-black dark:text-white">
|
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||||
<p>Subtotal</p>
|
|
||||||
<Price
|
|
||||||
className="text-right"
|
|
||||||
amount={cart.cost.subtotalAmount.amount}
|
|
||||||
currencyCode={cart.cost.subtotalAmount.currencyCode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<p>Taxes</p>
|
<p>Taxes</p>
|
||||||
<Price
|
<Price
|
||||||
className="text-right"
|
className="text-right text-base text-black dark:text-white"
|
||||||
amount={cart.cost.totalTaxAmount.amount}
|
amount={cart.cost.totalTaxAmount.amount}
|
||||||
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2">
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||||
<p>Shipping</p>
|
<p>Shipping</p>
|
||||||
<p className="text-right">Calculated at checkout</p>
|
<p className="text-right">Calculated at checkout</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex items-center justify-between font-bold">
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||||
<p>Total</p>
|
<p>Total</p>
|
||||||
<Price
|
<Price
|
||||||
className="text-right"
|
className="text-right text-base text-black dark:text-white"
|
||||||
amount={cart.cost.totalAmount.amount}
|
amount={cart.cost.totalAmount.amount}
|
||||||
currencyCode={cart.cost.totalAmount.currencyCode}
|
currencyCode={cart.cost.totalAmount.currencyCode}
|
||||||
/>
|
/>
|
||||||
@ -172,16 +176,16 @@ export default function CartModal({
|
|||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={cart.checkoutUrl}
|
href={cart.checkoutUrl}
|
||||||
className="flex w-full items-center justify-center bg-black p-3 text-sm font-medium uppercase text-white opacity-90 hover:opacity-100 dark:bg-white dark:text-black"
|
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<span>Proceed to Checkout</span>
|
Proceed to Checkout
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</div>
|
</Transition.Child>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
</Transition>
|
||||||
</AnimatePresence>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
24
components/cart/open-cart.tsx
Normal file
24
components/cart/open-cart.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function OpenCart({
|
||||||
|
className,
|
||||||
|
quantity
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
quantity?: number;
|
||||||
|
}) {
|
||||||
|
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">
|
||||||
|
<ShoppingCartIcon
|
||||||
|
className={clsx('h-4 transition-all ease-in-out hover:scale-110 ', className)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{quantity ? (
|
||||||
|
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
|
||||||
|
{quantity}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,26 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
function Grid(props: React.ComponentProps<'ul'>) {
|
|
||||||
return (
|
|
||||||
<ul {...props} className={clsx('grid grid-flow-row gap-4 py-5', props.className)}>
|
|
||||||
{props.children}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GridItem(props: React.ComponentProps<'li'>) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
'relative aspect-square h-full w-full overflow-hidden transition-opacity',
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Grid.Item = GridItem;
|
|
||||||
export default Grid;
|
|
@ -1,53 +0,0 @@
|
|||||||
import { GridTileImage } from 'components/grid/tile';
|
|
||||||
import { getCollectionProducts } from 'lib/shopify';
|
|
||||||
import type { Product } from 'lib/shopify/types';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
function ThreeItemGridItem({
|
|
||||||
item,
|
|
||||||
size,
|
|
||||||
background
|
|
||||||
}: {
|
|
||||||
item: Product;
|
|
||||||
size: 'full' | 'half';
|
|
||||||
background: 'white' | 'pink' | 'purple' | 'black';
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'}
|
|
||||||
>
|
|
||||||
<Link className="block h-full" href={`/product/${item.handle}`}>
|
|
||||||
<GridTileImage
|
|
||||||
src={item.featuredImage.url}
|
|
||||||
width={size === 'full' ? 1080 : 540}
|
|
||||||
height={size === 'full' ? 1080 : 540}
|
|
||||||
priority={true}
|
|
||||||
background={background}
|
|
||||||
alt={item.title}
|
|
||||||
labels={{
|
|
||||||
title: item.title as string,
|
|
||||||
amount: item.priceRange.maxVariantPrice.amount,
|
|
||||||
currencyCode: item.priceRange.maxVariantPrice.currencyCode
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ThreeItemGrid() {
|
|
||||||
// Collections that start with `hidden-*` are hidden from the search page.
|
|
||||||
const homepageItems = await getCollectionProducts('hidden-homepage-featured-items');
|
|
||||||
|
|
||||||
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
|
||||||
|
|
||||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="lg:grid lg:grid-cols-6 lg:grid-rows-2" data-testid="homepage-products">
|
|
||||||
<ThreeItemGridItem size="full" item={firstProduct} background="purple" />
|
|
||||||
<ThreeItemGridItem size="half" item={secondProduct} background="black" />
|
|
||||||
<ThreeItemGridItem size="half" item={thirdProduct} background="pink" />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import Price from 'components/product/price';
|
|
||||||
|
|
||||||
export function GridTileImage({
|
|
||||||
isInteractive = true,
|
|
||||||
background,
|
|
||||||
active,
|
|
||||||
labels,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
isInteractive?: boolean;
|
|
||||||
background?: 'white' | 'pink' | 'purple' | 'black' | 'purple-dark' | 'blue' | 'cyan' | 'gray';
|
|
||||||
active?: boolean;
|
|
||||||
labels?: {
|
|
||||||
title: string;
|
|
||||||
amount: string;
|
|
||||||
currencyCode: string;
|
|
||||||
isSmall?: boolean;
|
|
||||||
};
|
|
||||||
} & React.ComponentProps<typeof Image>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden', {
|
|
||||||
'bg-white dark:bg-white': background === 'white',
|
|
||||||
'bg-[#ff0080] dark:bg-[#ff0080]': background === 'pink',
|
|
||||||
'bg-[#7928ca] dark:bg-[#7928ca]': background === 'purple',
|
|
||||||
'bg-gray-900 dark:bg-gray-900': background === 'black',
|
|
||||||
'bg-violetDark dark:bg-violetDark': background === 'purple-dark',
|
|
||||||
'bg-blue-500 dark:bg-blue-500': background === 'blue',
|
|
||||||
'bg-cyan-500 dark:bg-cyan-500': background === 'cyan',
|
|
||||||
'bg-gray-100 dark:bg-gray-100': background === 'gray',
|
|
||||||
'bg-gray-100 dark:bg-gray-900': !background,
|
|
||||||
relative: labels
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{active !== undefined && active ? (
|
|
||||||
<span className="absolute h-full w-full bg-white opacity-25"></span>
|
|
||||||
) : null}
|
|
||||||
{props.src ? (
|
|
||||||
<Image
|
|
||||||
className={clsx('relative h-full w-full object-contain', {
|
|
||||||
'transition duration-300 ease-in-out hover:scale-105': isInteractive
|
|
||||||
})}
|
|
||||||
{...props}
|
|
||||||
alt={props.title || ''}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{labels ? (
|
|
||||||
<div className="absolute left-0 top-0 w-3/4 text-black dark:text-white">
|
|
||||||
<h3
|
|
||||||
data-testid="product-name"
|
|
||||||
className={clsx(
|
|
||||||
'inline bg-white box-decoration-clone py-3 pl-5 font-semibold leading-loose shadow-[1.25rem_0_0] shadow-white dark:bg-black dark:shadow-black',
|
|
||||||
!labels.isSmall ? 'text-3xl' : 'text-lg'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{labels.title}
|
|
||||||
</h3>
|
|
||||||
<Price
|
|
||||||
className="w-fit bg-white px-5 py-3 text-sm font-semibold dark:bg-black dark:text-white"
|
|
||||||
amount={labels.amount}
|
|
||||||
currencyCode={labels.currencyCode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function CopyRight() {
|
export default function CopyRight() {
|
||||||
|
@ -1,21 +1,9 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Logo from 'components/ui/logo/logo';
|
import Logo from 'components/ui/logo/logo';
|
||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuLink,
|
|
||||||
NavigationMenuList,
|
|
||||||
navigationMenuTriggerStyle
|
|
||||||
} from 'components/ui/navigation-menu';
|
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FC } from 'react';
|
|
||||||
import HeaderRoot from './header-root';
|
import HeaderRoot from './header-root';
|
||||||
|
|
||||||
interface HeaderProps {}
|
const Header = () => {
|
||||||
|
|
||||||
const Header: FC<HeaderProps> = () => {
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -33,33 +21,8 @@ const Header: FC<HeaderProps> = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
||||||
<NavigationMenu delayDuration={0} className="hidden lg:block">
|
Menu
|
||||||
<NavigationMenuList>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<Link href={'/category/barn'} legacyBehavior passHref>
|
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
|
||||||
Junior
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<Link href={'/category/trojor'} legacyBehavior passHref>
|
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
|
||||||
Tröjor
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<Link href={'/category/byxor'} legacyBehavior passHref>
|
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
|
||||||
Byxor
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HeaderRoot>
|
</HeaderRoot>
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
import Grid from 'components/grid';
|
|
||||||
import { GridTileImage } from 'components/grid/tile';
|
|
||||||
import { Product } from 'lib/shopify/types';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function ProductGridItems({ products }: { products: Product[] }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{products.map((product) => (
|
|
||||||
<Grid.Item key={product.handle} className="animate-fadeIn">
|
|
||||||
<Link className="h-full w-full" href={`/product/${product.handle}`}>
|
|
||||||
<GridTileImage
|
|
||||||
alt={product.title}
|
|
||||||
labels={{
|
|
||||||
isSmall: true,
|
|
||||||
title: product.title,
|
|
||||||
amount: product.priceRange.maxVariantPrice.amount,
|
|
||||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
|
||||||
}}
|
|
||||||
src={product.featuredImage?.url}
|
|
||||||
width={600}
|
|
||||||
height={600}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</Grid.Item>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -26,7 +26,7 @@ type HeroSize = keyof typeof heroSize;
|
|||||||
|
|
||||||
const heroSize = {
|
const heroSize = {
|
||||||
fullScreen: 'aspect-[3/4] lg:aspect-auto lg:h-[calc(75vh-4rem)]',
|
fullScreen: 'aspect-[3/4] lg:aspect-auto lg:h-[calc(75vh-4rem)]',
|
||||||
halfScreen: 'aspect-square max-h-[60vh] lg:aspect-auto lg:min-h-[60vh]'
|
halfScreen: 'aspect-square max-h-[50vh] lg:aspect-auto lg:min-h-[50vh]'
|
||||||
};
|
};
|
||||||
|
|
||||||
const Hero = ({ variant, title, text, label, image, link }: HeroProps) => {
|
const Hero = ({ variant, title, text, label, image, link }: HeroProps) => {
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
// Once rollup supports 'use client' module directives then 'next-sanity' will include them and this re-export will no longer be necessary
|
|
||||||
export { PreviewSuspense as default } from 'next-sanity/preview'
|
|
24
components/price.tsx
Normal file
24
components/price.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const Price = ({
|
||||||
|
amount,
|
||||||
|
className,
|
||||||
|
currencyCode = 'USD',
|
||||||
|
currencyCodeClassName
|
||||||
|
}: {
|
||||||
|
amount: string;
|
||||||
|
className?: string;
|
||||||
|
currencyCode: string;
|
||||||
|
currencyCodeClassName?: string;
|
||||||
|
} & React.ComponentProps<'p'>) => (
|
||||||
|
<p suppressHydrationWarning={true} className={className}>
|
||||||
|
{`${new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currencyCode,
|
||||||
|
currencyDisplay: 'narrowSymbol'
|
||||||
|
}).format(parseFloat(amount))}`}
|
||||||
|
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Price;
|
@ -1,22 +1,15 @@
|
|||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
type ParamsMap = {
|
type Combination = {
|
||||||
[key: string]: string; // ie. { color: 'Red', size: 'Large', ... }
|
|
||||||
};
|
|
||||||
|
|
||||||
type OptimizedVariant = {
|
|
||||||
id: string;
|
id: string;
|
||||||
availableForSale: boolean;
|
availableForSale: boolean;
|
||||||
params: URLSearchParams;
|
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
|
||||||
[key: string]: string | boolean | URLSearchParams; // ie. { color: 'Red', size: 'Large', ... }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VariantSelector({
|
export function VariantSelector({
|
||||||
@ -27,8 +20,7 @@ export function VariantSelector({
|
|||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const currentParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
|
||||||
const hasNoOptionsOrJustOneOption =
|
const hasNoOptionsOrJustOneOption =
|
||||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||||
|
|
||||||
@ -36,96 +28,77 @@ export function VariantSelector({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard any unexpected options or values from url and create params map.
|
const combinations: Combination[] = variants.map((variant) => ({
|
||||||
const paramsMap: ParamsMap = Object.fromEntries(
|
id: variant.id,
|
||||||
Array.from(currentParams.entries()).filter(([key, value]) =>
|
availableForSale: variant.availableForSale,
|
||||||
options.find((option) => option.name.toLowerCase() === key && option.values.includes(value))
|
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
|
||||||
|
...variant.selectedOptions.reduce(
|
||||||
|
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||||
|
{}
|
||||||
)
|
)
|
||||||
);
|
}));
|
||||||
|
|
||||||
// Optimize variants for easier lookups.
|
|
||||||
const optimizedVariants: OptimizedVariant[] = variants.map((variant) => {
|
|
||||||
const optimized: OptimizedVariant = {
|
|
||||||
id: variant.id,
|
|
||||||
availableForSale: variant.availableForSale,
|
|
||||||
params: new URLSearchParams()
|
|
||||||
};
|
|
||||||
|
|
||||||
variant.selectedOptions.forEach((selectedOption) => {
|
|
||||||
const name = selectedOption.name.toLowerCase();
|
|
||||||
const value = selectedOption.value;
|
|
||||||
|
|
||||||
optimized[name] = value;
|
|
||||||
optimized.params.set(name, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return optimized;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the first variant that is:
|
|
||||||
//
|
|
||||||
// 1. Available for sale
|
|
||||||
// 2. Matches all options specified in the url (note that this
|
|
||||||
// could be a partial match if some options are missing from the url).
|
|
||||||
//
|
|
||||||
// If no match (full or partial) is found, use the first variant that is
|
|
||||||
// available for sale.
|
|
||||||
const selectedVariant: OptimizedVariant | undefined =
|
|
||||||
optimizedVariants.find(
|
|
||||||
(variant) =>
|
|
||||||
variant.availableForSale &&
|
|
||||||
Object.entries(paramsMap).every(([key, value]) => variant[key] === value)
|
|
||||||
) || optimizedVariants.find((variant) => variant.availableForSale);
|
|
||||||
|
|
||||||
const selectedVariantParams = new URLSearchParams(selectedVariant?.params);
|
|
||||||
const currentUrl = createUrl(pathname, currentParams);
|
|
||||||
const selectedVariantUrl = createUrl(pathname, selectedVariantParams);
|
|
||||||
|
|
||||||
if (currentUrl !== selectedVariantUrl) {
|
|
||||||
router.replace(selectedVariantUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return options.map((option) => (
|
return options.map((option) => (
|
||||||
<dl className="mb-8" key={option.id}>
|
<dl className="mb-8" key={option.id}>
|
||||||
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||||
<dd className="flex flex-wrap gap-3">
|
<dd className="flex flex-wrap gap-3">
|
||||||
{option.values.map((value) => {
|
{option.values.map((value) => {
|
||||||
// Base option params on selected variant params.
|
const optionNameLowerCase = option.name.toLowerCase();
|
||||||
const optionParams = new URLSearchParams(selectedVariantParams);
|
|
||||||
// Update the params using the current option to reflect how the url would change.
|
|
||||||
optionParams.set(option.name.toLowerCase(), value);
|
|
||||||
|
|
||||||
const optionUrl = createUrl(pathname, optionParams);
|
// Base option params on current params so we can preserve any other param state in the url.
|
||||||
|
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
// The option is active if it in the url params.
|
// Update the option params using the current option to reflect how the url *would* change,
|
||||||
const isActive = selectedVariantParams.get(option.name.toLowerCase()) === value;
|
// if the option was clicked.
|
||||||
|
optionSearchParams.set(optionNameLowerCase, value);
|
||||||
|
const optionUrl = createUrl(pathname, optionSearchParams);
|
||||||
|
|
||||||
// The option is available for sale if it fully matches the variant in the option's url params.
|
// In order to determine if an option is available for sale, we need to:
|
||||||
// It's super important to note that this is the options params, *not* the selected variant's params.
|
//
|
||||||
// This is the "magic" that will cross check possible future variant combinations and preemptively
|
// 1. Filter out all other param state
|
||||||
// disable combinations that are not possible.
|
// 2. Filter out invalid options
|
||||||
const isAvailableForSale = optimizedVariants.find((a) =>
|
// 3. Check if the option combination is available for sale
|
||||||
Array.from(optionParams.entries()).every(([key, value]) => a[key] === value)
|
//
|
||||||
)?.availableForSale;
|
// This is the "magic" that will cross check possible variant combinations and preemptively
|
||||||
|
// disable combinations that are not available. For example, if the color gray is only available in size medium,
|
||||||
|
// then all other sizes should be disabled.
|
||||||
|
const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
|
||||||
|
options.find(
|
||||||
|
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const isAvailableForSale = combinations.find((combination) =>
|
||||||
|
filtered.every(
|
||||||
|
([key, value]) => combination[key] === value && combination.availableForSale
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The option is active if it's in the url params.
|
||||||
|
const isActive = searchParams.get(optionNameLowerCase) === value;
|
||||||
|
|
||||||
|
// You can't disable a link, so we need to render something that isn't clickable.
|
||||||
const DynamicTag = isAvailableForSale ? Link : 'p';
|
const DynamicTag = isAvailableForSale ? Link : 'p';
|
||||||
|
const dynamicProps = {
|
||||||
|
...(isAvailableForSale && { scroll: false })
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicTag
|
<DynamicTag
|
||||||
key={value}
|
key={value}
|
||||||
|
aria-disabled={!isAvailableForSale}
|
||||||
href={optionUrl}
|
href={optionUrl}
|
||||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex h-12 min-w-[48px] items-center justify-center rounded-full px-2 text-sm',
|
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||||
{
|
{
|
||||||
'cursor-default ring-2 ring-black dark:ring-white': isActive,
|
'cursor-default ring-2 ring-blue-600': isActive,
|
||||||
'ring-1 ring-gray-300 transition duration-300 ease-in-out hover:scale-110 hover:bg-gray-100 hover:ring-black dark:ring-gray-700 dark:hover:bg-transparent dark:hover:ring-white':
|
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
|
||||||
!isActive && isAvailableForSale,
|
!isActive && isAvailableForSale,
|
||||||
'relative z-10 cursor-not-allowed overflow-hidden bg-gray-100 ring-1 ring-gray-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-gray-300 before:transition-transform dark:bg-gray-900 dark:ring-gray-700 before:dark:bg-gray-700':
|
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
|
||||||
!isAvailableForSale
|
!isAvailableForSale
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
data-testid={isActive ? 'selected-variant' : 'variant'}
|
{...dynamicProps}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</DynamicTag>
|
</DynamicTag>
|
||||||
|
@ -10,7 +10,7 @@ const Prose: FunctionComponent<TextProps> = ({ html, className }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-gray-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
|
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
dangerouslySetInnerHTML={{ __html: html as string }}
|
dangerouslySetInnerHTML={{ __html: html as string }}
|
||||||
|
@ -8,8 +8,6 @@ import dynamic from 'next/dynamic';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
|
|
||||||
const WishlistButton = dynamic(() => import('components/ui/wishlist-button'));
|
|
||||||
|
|
||||||
const SanityImage = dynamic(() => import('components/ui/sanity-image'));
|
const SanityImage = dynamic(() => import('components/ui/sanity-image'));
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -30,11 +28,6 @@ const ProductCard: FC<Props> = ({ product, className, variant = 'default' }) =>
|
|||||||
>
|
>
|
||||||
{variant === 'default' && (
|
{variant === 'default' && (
|
||||||
<div className={'relative flex h-full w-full flex-col justify-center'}>
|
<div className={'relative flex h-full w-full flex-col justify-center'}>
|
||||||
<WishlistButton
|
|
||||||
className={'absolute right-4 top-4 z-10'}
|
|
||||||
productId={product.id}
|
|
||||||
variant={product?.variants ? (product.variants[0] as any) : null}
|
|
||||||
/>
|
|
||||||
<div className="relative h-full w-full overflow-hidden">
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
{product?.images && (
|
{product?.images && (
|
||||||
<SanityImage
|
<SanityImage
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export { default } from './wishlist-button';
|
|
@ -1,79 +0,0 @@
|
|||||||
import type { Product, ProductVariant } from 'lib/storm/types/product'
|
|
||||||
import { cn } from 'lib/utils'
|
|
||||||
import { Heart } from 'lucide-react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import React, { FC, useState } from 'react'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
productId: Product['id']
|
|
||||||
variant: ProductVariant
|
|
||||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
||||||
|
|
||||||
const WishlistButton: FC<Props> = ({
|
|
||||||
productId,
|
|
||||||
variant,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const t = useTranslations('ui.button')
|
|
||||||
|
|
||||||
// @ts-ignore Wishlist is not always enabled
|
|
||||||
// const itemInWishlist = data?.items?.find(
|
|
||||||
// // @ts-ignore Wishlist is not always enabled
|
|
||||||
// (item) => item.product_id === productId && item.variant_id === variant.id
|
|
||||||
// )
|
|
||||||
|
|
||||||
const handleWishlistChange = async (e: any) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (loading) return
|
|
||||||
|
|
||||||
// A login is required before adding an item to the wishlist
|
|
||||||
// if (!customer) {
|
|
||||||
// setModalView('LOGIN_VIEW')
|
|
||||||
// return openModal()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// setLoading(true)
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// if (itemInWishlist) {
|
|
||||||
// await removeItem({ id: itemInWishlist.id! })
|
|
||||||
// } else {
|
|
||||||
// await addItem({
|
|
||||||
// productId,
|
|
||||||
// variantId: variant?.id!,
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// setLoading(false)
|
|
||||||
// } catch (err) {
|
|
||||||
// setLoading(false)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label={t('wishList')}
|
|
||||||
className={cn(
|
|
||||||
'w-10 h-10 text-high-contrast bg-app border border-high-contrast flex items-center justify-center font-semibold cursor-pointer text-sm duration-200 ease-in-out transition-[color,background-color,opacity] ',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
// onClick={handleWishlistChange}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={cn(
|
|
||||||
'duration-200 ease-in-out w-5 h-5 transition-[transform,fill] text-current',
|
|
||||||
{
|
|
||||||
['fill-low-contrast']: loading,
|
|
||||||
// ['fill-high-contrast']: itemInWishlist,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WishlistButton
|
|
@ -1,25 +0,0 @@
|
|||||||
export type SortFilterItem = {
|
|
||||||
title: string;
|
|
||||||
slug: string | null;
|
|
||||||
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
|
|
||||||
reverse: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultSort: SortFilterItem = {
|
|
||||||
title: 'Relevance',
|
|
||||||
slug: null,
|
|
||||||
sortKey: 'RELEVANCE',
|
|
||||||
reverse: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sorting: SortFilterItem[] = [
|
|
||||||
defaultSort,
|
|
||||||
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
|
|
||||||
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
|
|
||||||
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
|
|
||||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
|
||||||
export const DEFAULT_OPTION = 'Default Title';
|
|
||||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
|
@ -147,61 +147,6 @@ export interface Product {
|
|||||||
locale?: string
|
locale?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchProductsBody {
|
|
||||||
/**
|
|
||||||
* The search query string to filter the products by.
|
|
||||||
*/
|
|
||||||
search?: string
|
|
||||||
/**
|
|
||||||
* The category ID to filter the products by.
|
|
||||||
*/
|
|
||||||
categoryId?: string
|
|
||||||
/**
|
|
||||||
* The brand ID to filter the products by.
|
|
||||||
*/
|
|
||||||
brandId?: string
|
|
||||||
/**
|
|
||||||
* The sort key to sort the products by.
|
|
||||||
* @example 'trending-desc' | 'latest-desc' | 'price-asc' | 'price-desc'
|
|
||||||
*/
|
|
||||||
sort?: string
|
|
||||||
/**
|
|
||||||
* The locale code, used to localize the product data (if the provider supports it).
|
|
||||||
*/
|
|
||||||
locale?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a list of products based on the given search criteria.
|
|
||||||
*/
|
|
||||||
export type SearchProductsHook = {
|
|
||||||
data: {
|
|
||||||
/**
|
|
||||||
* List of products matching the query.
|
|
||||||
*/
|
|
||||||
products: Product[]
|
|
||||||
/**
|
|
||||||
* Indicates if there are any products matching the query.
|
|
||||||
*/
|
|
||||||
found: boolean
|
|
||||||
}
|
|
||||||
body: SearchProductsBody
|
|
||||||
input: SearchProductsBody
|
|
||||||
fetcherInput: SearchProductsBody
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Product API schema
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ProductsSchema = {
|
|
||||||
endpoint: {
|
|
||||||
options: {}
|
|
||||||
handlers: {
|
|
||||||
getProducts: SearchProductsHook
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Product operations
|
* Product operations
|
||||||
|
@ -37,9 +37,9 @@
|
|||||||
"framer-motion": "^8.5.5",
|
"framer-motion": "^8.5.5",
|
||||||
"is-empty-iterable": "^3.0.0",
|
"is-empty-iterable": "^3.0.0",
|
||||||
"lucide-react": "^0.194.0",
|
"lucide-react": "^0.194.0",
|
||||||
"next": "13.4.7",
|
"next": "13.4.13",
|
||||||
"next-intl": "^2.14.6",
|
"next-intl": "3.0.0-beta.9",
|
||||||
"next-sanity": "^4.3.2",
|
"next-sanity": "^5.2.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-cookie": "^4.1.1",
|
"react-cookie": "^4.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user