mirror of
https://github.com/vercel/commerce.git
synced 2025-06-17 20:51:21 +00:00
move components folder
This commit is contained in:
parent
f009bc19cb
commit
5b9e3a8f1a
40
.components/carousel.tsx
Normal file
40
.components/carousel.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { getCollectionProducts } from 'lib/shopify';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { GridTileImage } from './grid/tile';
|
||||||
|
|
||||||
|
export async function Carousel() {
|
||||||
|
// Collections that start with `hidden-*` are hidden from the search page.
|
||||||
|
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||||
|
|
||||||
|
if (!products?.length) return null;
|
||||||
|
|
||||||
|
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
|
||||||
|
const carouselProducts = [...products, ...products, ...products];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=" w-full overflow-x-auto pb-6 pt-1">
|
||||||
|
<ul className="flex animate-carousel gap-4">
|
||||||
|
{carouselProducts.map((product, i) => (
|
||||||
|
<li
|
||||||
|
key={`${product.handle}${i}`}
|
||||||
|
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">
|
||||||
|
<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) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
83
.components/cart/actions.ts
Normal file
83
.components/cart/actions.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
export async function addItem(prevState: any, selectedVariantId: 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 (!selectedVariantId) {
|
||||||
|
return 'Missing product variant ID';
|
||||||
|
}
|
||||||
|
|
||||||
|
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, lineId: string) {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return 'Missing cart ID';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeFromCart(cartId, [lineId]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Error removing item from cart';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItemQuantity(
|
||||||
|
prevState: any,
|
||||||
|
payload: {
|
||||||
|
lineId: string;
|
||||||
|
variantId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return 'Missing cart ID';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lineId, variantId, quantity } = payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (quantity === 0) {
|
||||||
|
await removeFromCart(cartId, [lineId]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCart(cartId, [
|
||||||
|
{
|
||||||
|
id: lineId,
|
||||||
|
merchandiseId: variantId,
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Error updating item quantity';
|
||||||
|
}
|
||||||
|
}
|
92
.components/cart/add-to-cart.tsx
Normal file
92
.components/cart/add-to-cart.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
'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 { useSearchParams } from 'next/navigation';
|
||||||
|
import { useFormState, useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
|
function SubmitButton({
|
||||||
|
availableForSale,
|
||||||
|
selectedVariantId
|
||||||
|
}: {
|
||||||
|
availableForSale: boolean;
|
||||||
|
selectedVariantId: string | undefined;
|
||||||
|
}) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
const buttonClasses =
|
||||||
|
'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 aria-disabled className={clsx(buttonClasses, disabledClasses)}>
|
||||||
|
Out Of Stock
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedVariantId) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label="Please select an option"
|
||||||
|
aria-disabled
|
||||||
|
className={clsx(buttonClasses, disabledClasses)}
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 ml-4">
|
||||||
|
<PlusIcon className="h-5" />
|
||||||
|
</div>
|
||||||
|
Add To Cart
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||||
|
if (pending) e.preventDefault();
|
||||||
|
}}
|
||||||
|
aria-label="Add to cart"
|
||||||
|
aria-disabled={pending}
|
||||||
|
className={clsx(buttonClasses, {
|
||||||
|
'hover:opacity-90': true,
|
||||||
|
[disabledClasses]: pending
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 ml-4">
|
||||||
|
{pending ? <LoadingDots className="mb-3 bg-white" /> : <PlusIcon className="h-5" />}
|
||||||
|
</div>
|
||||||
|
Add To Cart
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddToCart({
|
||||||
|
variants,
|
||||||
|
availableForSale
|
||||||
|
}: {
|
||||||
|
variants: ProductVariant[];
|
||||||
|
availableForSale: boolean;
|
||||||
|
}) {
|
||||||
|
const [message, formAction] = useFormState(addItem, null);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
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 actionWithVariant = formAction.bind(null, selectedVariantId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={actionWithVariant}>
|
||||||
|
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
}
|
50
.components/cart/delete-item-button.tsx
Normal file
50
.components/cart/delete-item-button.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { removeItem } from 'components/cart/actions';
|
||||||
|
import LoadingDots from 'components/loading-dots';
|
||||||
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
import { useFormState, useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||||
|
if (pending) e.preventDefault();
|
||||||
|
}}
|
||||||
|
aria-label="Remove cart item"
|
||||||
|
aria-disabled={pending}
|
||||||
|
className={clsx(
|
||||||
|
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
|
||||||
|
{
|
||||||
|
'cursor-not-allowed px-0': pending
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<LoadingDots className="bg-white" />
|
||||||
|
) : (
|
||||||
|
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteItemButton({ item }: { item: CartItem }) {
|
||||||
|
const [message, formAction] = useFormState(removeItem, null);
|
||||||
|
const itemId = item.id;
|
||||||
|
const actionWithVariant = formAction.bind(null, itemId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={actionWithVariant}>
|
||||||
|
<SubmitButton />
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
57
.components/cart/edit-item-quantity-button.tsx
Normal file
57
.components/cart/edit-item-quantity-button.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { updateItemQuantity } from 'components/cart/actions';
|
||||||
|
import LoadingDots from 'components/loading-dots';
|
||||||
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
import { useFormState, useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
|
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||||
|
if (pending) e.preventDefault();
|
||||||
|
}}
|
||||||
|
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||||
|
aria-disabled={pending}
|
||||||
|
className={clsx(
|
||||||
|
'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': pending,
|
||||||
|
'ml-auto': type === 'minus'
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<LoadingDots className="bg-black dark:bg-white" />
|
||||||
|
) : type === 'plus' ? (
|
||||||
|
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
|
) : (
|
||||||
|
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
|
||||||
|
const [message, formAction] = useFormState(updateItemQuantity, null);
|
||||||
|
const payload = {
|
||||||
|
lineId: item.id,
|
||||||
|
variantId: item.merchandise.id,
|
||||||
|
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||||
|
};
|
||||||
|
const actionWithVariant = formAction.bind(null, payload);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={actionWithVariant}>
|
||||||
|
<SubmitButton type={type} />
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
14
.components/cart/index.tsx
Normal file
14
.components/cart/index.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { getCart } from 'lib/shopify';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import CartModal from './modal';
|
||||||
|
|
||||||
|
export default async function Cart() {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
let cart;
|
||||||
|
|
||||||
|
if (cartId) {
|
||||||
|
cart = await getCart(cartId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CartModal cart={cart} />;
|
||||||
|
}
|
191
.components/cart/modal.tsx
Normal file
191
.components/cart/modal.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
|
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||||
|
import Price from 'components/price';
|
||||||
|
import { DEFAULT_OPTION } from 'lib/constants';
|
||||||
|
import type { Cart } from 'lib/shopify/types';
|
||||||
|
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 { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||||
|
import OpenCart from './open-cart';
|
||||||
|
|
||||||
|
type MerchandiseSearchParams = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const quantityRef = useRef(cart?.totalQuantity);
|
||||||
|
const openCart = () => setIsOpen(true);
|
||||||
|
const closeCart = () => setIsOpen(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Open cart modal 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 (!isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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">My Cart</p>
|
||||||
|
|
||||||
|
<button aria-label="Close cart" onClick={closeCart}>
|
||||||
|
<CloseCart />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!cart || cart.lines.length === 0 ? (
|
||||||
|
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
||||||
|
<ShoppingCartIcon className="h-16" />
|
||||||
|
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||||
|
<ul className="flex-grow overflow-auto py-4">
|
||||||
|
{cart.lines.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 (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
||||||
|
>
|
||||||
|
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||||
|
<div className="absolute z-40 -mt-2 ml-[55px]">
|
||||||
|
<DeleteItemButton item={item} />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={merchandiseUrl}
|
||||||
|
onClick={closeCart}
|
||||||
|
className="z-30 flex flex-row space-x-4"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
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 className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<EditItemQuantityButton item={item} type="minus" />
|
||||||
|
<p className="w-6 text-center">
|
||||||
|
<span className="w-full text-sm">{item.quantity}</span>
|
||||||
|
</p>
|
||||||
|
<EditItemQuantityButton item={item} type="plus" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||||
|
<p>Taxes</p>
|
||||||
|
<Price
|
||||||
|
className="text-right text-base text-black dark:text-white"
|
||||||
|
amount={cart.cost.totalTaxAmount.amount}
|
||||||
|
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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 className="text-right">Calculated at checkout</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<Price
|
||||||
|
className="text-right text-base text-black dark:text-white"
|
||||||
|
amount={cart.cost.totalAmount.amount}
|
||||||
|
currencyCode={cart.cost.totalAmount.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={cart.checkoutUrl}
|
||||||
|
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Proceed to Checkout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
}
|
21
.components/grid/index.tsx
Normal file
21
.components/grid/index.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
function Grid(props: React.ComponentProps<'ul'>) {
|
||||||
|
return (
|
||||||
|
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridItem(props: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid.Item = GridItem;
|
||||||
|
|
||||||
|
export default Grid;
|
57
.components/grid/three-items.tsx
Normal file
57
.components/grid/three-items.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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,
|
||||||
|
priority
|
||||||
|
}: {
|
||||||
|
item: Product;
|
||||||
|
size: 'full' | 'half';
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||||
|
>
|
||||||
|
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
|
||||||
|
<GridTileImage
|
||||||
|
src={item.featuredImage.url}
|
||||||
|
fill
|
||||||
|
sizes={
|
||||||
|
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
|
||||||
|
}
|
||||||
|
priority={priority}
|
||||||
|
alt={item.title}
|
||||||
|
label={{
|
||||||
|
position: size === 'full' ? 'center' : 'bottom',
|
||||||
|
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({
|
||||||
|
collection: 'hidden-homepage-featured-items'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
||||||
|
|
||||||
|
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
|
||||||
|
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||||
|
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||||
|
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
50
.components/grid/tile.tsx
Normal file
50
.components/grid/tile.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Label from '../label';
|
||||||
|
|
||||||
|
export function GridTileImage({
|
||||||
|
isInteractive = true,
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
isInteractive?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
label?: {
|
||||||
|
title: string;
|
||||||
|
amount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
position?: 'bottom' | 'center';
|
||||||
|
};
|
||||||
|
} & React.ComponentProps<typeof Image>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
|
||||||
|
{
|
||||||
|
relative: label,
|
||||||
|
'border-2 border-blue-600': active,
|
||||||
|
'border-neutral-200 dark:border-neutral-800': !active
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.src ? (
|
||||||
|
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
|
||||||
|
<Image
|
||||||
|
className={clsx('relative h-full w-full object-contain', {
|
||||||
|
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{label ? (
|
||||||
|
<Label
|
||||||
|
title={label.title}
|
||||||
|
amount={label.amount}
|
||||||
|
currencyCode={label.currencyCode}
|
||||||
|
position={label.position}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
16
.components/icons/logo.tsx
Normal file
16
.components/icons/logo.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label={`${process.env.SITE_NAME} logo`}
|
||||||
|
viewBox="0 0 32 28"
|
||||||
|
{...props}
|
||||||
|
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)}
|
||||||
|
>
|
||||||
|
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
|
||||||
|
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
34
.components/label.tsx
Normal file
34
.components/label.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import Price from './price';
|
||||||
|
|
||||||
|
const Label = ({
|
||||||
|
title,
|
||||||
|
amount,
|
||||||
|
currencyCode,
|
||||||
|
position = 'bottom'
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
amount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
position?: 'bottom' | 'center';
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
|
||||||
|
'lg:px-20 lg:pb-[35%]': position === 'center'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
|
||||||
|
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
|
||||||
|
<Price
|
||||||
|
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||||
|
amount={amount}
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
currencyCodeClassName="hidden @[275px]/label:inline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Label;
|
@ -1,6 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
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;
|
||||||
@ -9,7 +11,7 @@ export default async function Footer() {
|
|||||||
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 menu = await getMenu('next-js-frontend-footer-menu');
|
||||||
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -33,7 +35,7 @@ export default async function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* <FooterMenu menu={menu} /> */}
|
<FooterMenu menu={menu} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<div className="md:ml-auto">
|
<div className="md:ml-auto">
|
||||||
<a
|
<a
|
@ -1,22 +1,23 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Cart from 'components/cart';
|
import Cart from 'components/cart';
|
||||||
import OpenCart from 'components/cart/open-cart';
|
import OpenCart from 'components/cart/open-cart';
|
||||||
import LogoSquare from 'components/logo-square';
|
import LogoSquare from 'components/logo-square';
|
||||||
|
import { getMenu } from 'lib/shopify';
|
||||||
import { Menu } from 'lib/shopify/types';
|
import { Menu } from 'lib/shopify/types';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
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;
|
||||||
|
|
||||||
export default function Navbar() {
|
export default async function Navbar() {
|
||||||
alert(123);
|
const menu = await getMenu('next-js-frontend-header-menu');
|
||||||
const 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">
|
||||||
<div className="block flex-none md:hidden">
|
<div className="block flex-none md:hidden">
|
||||||
<Suspense fallback={null}>{/* <MobileMenu menu={menu} /> */}</Suspense>
|
<Suspense fallback={null}>
|
||||||
sdfsd
|
<MobileMenu menu={menu} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
<div className="flex w-full md:w-1/3">
|
<div className="flex w-full md:w-1/3">
|
15
.components/loading-dots.tsx
Normal file
15
.components/loading-dots.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
|
||||||
|
|
||||||
|
const LoadingDots = ({ className }: { className: string }) => {
|
||||||
|
return (
|
||||||
|
<span className="mx-2 inline-flex items-center">
|
||||||
|
<span className={clsx(dots, className)} />
|
||||||
|
<span className={clsx(dots, 'animation-delay-[200ms]', className)} />
|
||||||
|
<span className={clsx(dots, 'animation-delay-[400ms]', className)} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingDots;
|
23
.components/logo-square.tsx
Normal file
23
.components/logo-square.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import LogoIcon from './icons/logo';
|
||||||
|
|
||||||
|
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black',
|
||||||
|
{
|
||||||
|
'h-[40px] w-[40px] rounded-xl': !size,
|
||||||
|
'h-[30px] w-[30px] rounded-lg': size === 'sm'
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LogoIcon
|
||||||
|
className={clsx({
|
||||||
|
'h-[16px] w-[16px]': !size,
|
||||||
|
'h-[10px] w-[10px]': size === 'sm'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
40
.components/opengraph-image.tsx
Normal file
40
.components/opengraph-image.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import LogoIcon from './icons/logo';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OpengraphImage(props?: Props): Promise<ImageResponse> {
|
||||||
|
const { title } = {
|
||||||
|
...{
|
||||||
|
title: process.env.SITE_NAME
|
||||||
|
},
|
||||||
|
...props
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
||||||
|
<div tw="flex flex-none items-center justify-center border border-neutral-700 h-[160px] w-[160px] rounded-3xl">
|
||||||
|
<LogoIcon width="64" height="58" fill="white" />
|
||||||
|
</div>
|
||||||
|
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
|
||||||
|
res.arrayBuffer()
|
||||||
|
),
|
||||||
|
style: 'normal',
|
||||||
|
weight: 700
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
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;
|
99
.components/product/gallery.tsx
Normal file
99
.components/product/gallery.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { GridTileImage } from 'components/grid/tile';
|
||||||
|
import { createUrl } from 'lib/utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const imageSearchParam = searchParams.get('image');
|
||||||
|
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
|
||||||
|
|
||||||
|
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
|
||||||
|
nextSearchParams.set('image', nextImageIndex.toString());
|
||||||
|
const nextUrl = createUrl(pathname, nextSearchParams);
|
||||||
|
|
||||||
|
const previousSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||||
|
previousSearchParams.set('image', previousImageIndex.toString());
|
||||||
|
const previousUrl = createUrl(pathname, previousSearchParams);
|
||||||
|
|
||||||
|
const buttonClassName =
|
||||||
|
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
|
||||||
|
{images[imageIndex] && (
|
||||||
|
<Image
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 66vw, 100vw"
|
||||||
|
alt={images[imageIndex]?.altText as string}
|
||||||
|
src={images[imageIndex]?.src as string}
|
||||||
|
priority={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<div className="absolute bottom-[15%] flex w-full justify-center">
|
||||||
|
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
|
||||||
|
<Link
|
||||||
|
aria-label="Previous product image"
|
||||||
|
href={previousUrl}
|
||||||
|
className={buttonClassName}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
|
||||||
|
<Link
|
||||||
|
aria-label="Next product image"
|
||||||
|
href={nextUrl}
|
||||||
|
className={buttonClassName}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="h-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
|
||||||
|
{images.map((image, index) => {
|
||||||
|
const isActive = index === imageIndex;
|
||||||
|
const imageSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
imageSearchParams.set('image', index.toString());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={image.src} className="h-20 w-20">
|
||||||
|
<Link
|
||||||
|
aria-label="Enlarge product image"
|
||||||
|
href={createUrl(pathname, imageSearchParams)}
|
||||||
|
scroll={false}
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<GridTileImage
|
||||||
|
alt={image.altText}
|
||||||
|
src={image.src}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
active={isActive}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
36
.components/product/product-description.tsx
Normal file
36
.components/product/product-description.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { AddToCart } from 'components/cart/add-to-cart';
|
||||||
|
import Price from 'components/price';
|
||||||
|
import Prose from 'components/prose';
|
||||||
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { VariantSelector } from './variant-selector';
|
||||||
|
|
||||||
|
export function ProductDescription({ product }: { product: Product }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
|
||||||
|
<Price
|
||||||
|
amount={product.priceRange.maxVariantPrice.amount}
|
||||||
|
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<VariantSelector options={product.options} variants={product.variants} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{product.descriptionHtml ? (
|
||||||
|
<Prose
|
||||||
|
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
|
||||||
|
html={product.descriptionHtml}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
106
.components/product/variant-selector.tsx
Normal file
106
.components/product/variant-selector.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||||
|
import { createUrl } from 'lib/utils';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
type Combination = {
|
||||||
|
id: string;
|
||||||
|
availableForSale: boolean;
|
||||||
|
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VariantSelector({
|
||||||
|
options,
|
||||||
|
variants
|
||||||
|
}: {
|
||||||
|
options: ProductOption[];
|
||||||
|
variants: ProductVariant[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const hasNoOptionsOrJustOneOption =
|
||||||
|
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||||
|
|
||||||
|
if (hasNoOptionsOrJustOneOption) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinations: Combination[] = variants.map((variant) => ({
|
||||||
|
id: variant.id,
|
||||||
|
availableForSale: variant.availableForSale,
|
||||||
|
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
|
||||||
|
...variant.selectedOptions.reduce(
|
||||||
|
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return options.map((option) => (
|
||||||
|
<dl className="mb-8" key={option.id}>
|
||||||
|
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||||
|
<dd className="flex flex-wrap gap-3">
|
||||||
|
{option.values.map((value) => {
|
||||||
|
const optionNameLowerCase = option.name.toLowerCase();
|
||||||
|
|
||||||
|
// Base option params on current params so we can preserve any other param state in the url.
|
||||||
|
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
// Update the option params using the current option to reflect how the url *would* change,
|
||||||
|
// if the option was clicked.
|
||||||
|
optionSearchParams.set(optionNameLowerCase, value);
|
||||||
|
const optionUrl = createUrl(pathname, optionSearchParams);
|
||||||
|
|
||||||
|
// In order to determine if an option is available for sale, we need to:
|
||||||
|
//
|
||||||
|
// 1. Filter out all other param state
|
||||||
|
// 2. Filter out invalid options
|
||||||
|
// 3. Check if the option combination is available for sale
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
aria-disabled={!isAvailableForSale}
|
||||||
|
disabled={!isAvailableForSale}
|
||||||
|
onClick={() => {
|
||||||
|
router.replace(optionUrl, { scroll: false });
|
||||||
|
}}
|
||||||
|
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||||
|
className={clsx(
|
||||||
|
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||||
|
{
|
||||||
|
'cursor-default ring-2 ring-blue-600': isActive,
|
||||||
|
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
|
||||||
|
!isActive && isAvailableForSale,
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
));
|
||||||
|
}
|
21
.components/prose.tsx
Normal file
21
.components/prose.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import type { FunctionComponent } from 'react';
|
||||||
|
|
||||||
|
interface TextProps {
|
||||||
|
html: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Prose: FunctionComponent<TextProps> = ({ html, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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-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
|
||||||
|
)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html as string }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Prose;
|
Loading…
x
Reference in New Issue
Block a user