clear out template files

This commit is contained in:
andr-ew 2023-06-21 13:41:43 -05:00
parent 3b650b0ea5
commit 3b696400ae
55 changed files with 10 additions and 2273 deletions

View File

@ -1,15 +0,0 @@
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="w-full bg-white dark:bg-black">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
<Suspense>{children}</Suspense>
</div>
</div>
<Footer />
</Suspense>
);
}

View File

@ -1,56 +0,0 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
export const runtime = 'edge';
export const revalidate = 43200; // 12 hours in seconds
export async function generateMetadata({
params
}: {
params: { page: string };
}): Promise<Metadata> {
const page = await getPage(params.page);
if (!page) return notFound();
return {
title: page.seo?.title || page.title,
description: page.seo?.description || page.bodySummary,
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(page.title)}`,
width: 1200,
height: 630
}
],
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: 'article'
}
};
}
export default async function Page({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
if (!page) return notFound();
return (
<>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(page.updatedAt))}.`}
</p>
</>
);
}

View File

@ -1,37 +0,0 @@
import { TAGS } from 'lib/constants';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge';
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
export async function POST(req: NextRequest): Promise<Response> {
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = headers().get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 200 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

View File

@ -1,10 +0,0 @@
'use client';
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<h2>Something went wrong.</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 200 KiB

View File

@ -1,9 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
img[loading='lazy'] {
clip-path: inset(0.6px);
}
}

7
app/layout.js Normal file
View File

@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html lang='en'>
<body>{children}</body>
</html>
);
}

View File

@ -1,44 +0,0 @@
import Navbar from 'components/layout/navbar';
import { Inter } from 'next/font/google';
import { ReactNode, Suspense } from 'react';
import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
export const metadata = {
title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`
},
robots: {
follow: true,
index: true
},
...(TWITTER_CREATOR &&
TWITTER_SITE && {
twitter: {
card: 'summary_large_image',
creator: TWITTER_CREATOR,
site: TWITTER_SITE
}
})
};
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
export default async function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="bg-white text-black selection:bg-teal-300 dark:bg-black dark:text-white dark:selection:bg-fuchsia-600 dark:selection:text-white">
<Navbar />
<Suspense>
<main>{children}</main>
</Suspense>
</body>
</html>
);
}

3
app/page.js Normal file
View File

@ -0,0 +1,3 @@
export default function Page() {
return <h1>Hello, Next.js!</h1>;
}

View File

@ -1,34 +0,0 @@
import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export const runtime = 'edge';
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(process.env.SITE_NAME || '')}`,
width: 1200,
height: 630
}
],
type: 'website'
}
};
export default async function HomePage() {
return (
<>
<ThreeItemGrid />
<Suspense>
<Carousel />
<Suspense>
<Footer />
</Suspense>
</Suspense>
</>
);
}

View File

@ -1,132 +0,0 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import Grid from 'components/grid';
import Footer from 'components/layout/footer';
import ProductGridItems from 'components/layout/product-grid-items';
import { AddToCart } from 'components/cart/add-to-cart';
import { Gallery } from 'components/product/gallery';
import { VariantSelector } from 'components/product/variant-selector';
import Prose from 'components/prose';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
export const runtime = 'edge';
export async function generateMetadata({
params
}: {
params: { handle: string };
}): Promise<Metadata> {
const product = await getProduct(params.handle);
if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {};
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
robots: {
index: hide,
follow: hide,
googleBot: {
index: hide,
follow: hide
}
},
openGraph: url
? {
images: [
{
url,
width,
height,
alt
}
]
}
: null
};
}
export default async function ProductPage({ params }: { params: { handle: string } }) {
const product = await getProduct(params.handle);
if (!product) return notFound();
const productJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.title,
description: product.description,
image: product.featuredImage.url,
offers: {
'@type': 'AggregateOffer',
availability: product.availableForSale
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount,
lowPrice: product.priceRange.minVariantPrice.amount
}
};
return (
<div>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd)
}}
/>
<div className="lg:grid lg:grid-cols-6">
<div className="lg:col-span-4">
<Gallery
title={product.title}
amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
images={product.images.map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
</div>
<div className="p-6 lg:col-span-2">
<VariantSelector options={product.options} variants={product.variants} />
{product.descriptionHtml ? (
<Prose className="mb-6 text-sm leading-tight" html={product.descriptionHtml} />
) : null}
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
</div>
</div>
<Suspense>
<RelatedProducts id={product.id} />
<Suspense>
<Footer />
</Suspense>
</Suspense>
</div>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
if (!relatedProducts.length) return null;
return (
<div className="px-4 py-8">
<div className="mb-4 text-3xl font-bold">Related Products</div>
<Grid className="grid-cols-2 lg:grid-cols-5">
<ProductGridItems products={relatedProducts} />
</Grid>
</div>
);
}

View File

@ -1,15 +0,0 @@
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export default function robots() {
return {
rules: [
{
userAgent: '*'
}
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl
};
}

View File

@ -1,49 +0,0 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
export const runtime = 'edge';
export async function generateMetadata({
params
}: {
params: { collection: string };
}): Promise<Metadata> {
const collection = await getCollection(params.collection);
if (!collection) return notFound();
return {
title: collection.seo?.title || collection.title,
description:
collection.seo?.description || collection.description || `${collection.title} products`
};
}
export default async function CategoryPage({
params,
searchParams
}: {
params: { collection: string };
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
return (
<section>
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
<Grid className="grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
)}
</section>
);
}

View File

@ -1,22 +0,0 @@
import Footer from 'components/layout/footer';
import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants';
import { Suspense } from 'react';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
<div className="order-first flex-none md:w-1/6">
<Collections />
</div>
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
<div className="order-none md:order-last md:w-1/6 md:flex-none">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
<Footer />
</Suspense>
);
}

View File

@ -1,13 +0,0 @@
import Grid from 'components/grid';
export default function Loading() {
return (
<Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12)
.fill(0)
.map((_, index) => {
return <Grid.Item key={index} className="animate-pulse bg-gray-100 dark:bg-gray-900" />;
})}
</Grid>
);
}

View File

@ -1,41 +0,0 @@
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
export const runtime = 'edge';
export const metadata = {
title: 'Search',
description: 'Search for products in the store.'
};
export default async function SearchPage({
searchParams
}: {
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getProducts({ sortKey, reverse, query: searchValue });
const resultsText = products.length > 1 ? 'results' : 'result';
return (
<>
{searchValue ? (
<p>
{products.length === 0
? 'There are no products that match '
: `Showing ${products.length} ${resultsText} for `}
<span className="font-bold">&quot;{searchValue}&quot;</span>
</p>
) : null}
{products.length > 0 ? (
<Grid className="grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
) : null}
</>
);
}

View File

@ -1,40 +0,0 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> {
const routesMap = [''].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString()
}));
const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({
url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt
}))
);
const productsPromise = getProducts({}).then((products) =>
products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt
}))
);
const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({
url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt
}))
);
const fetchedRoutes = (
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
).flat();
return [...routesMap, ...fetchedRoutes];
}

View File

@ -1,39 +0,0 @@
import { getCollectionProducts } from 'lib/shopify';
import Image from 'next/image';
import Link from 'next/link';
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;
return (
<div className="relative w-full overflow-hidden bg-black dark:bg-white">
<div className="flex animate-carousel">
{[...products, ...products].map((product, i) => (
<Link
key={`${product.handle}${i}`}
href={`/product/${product.handle}`}
className="relative h-[30vh] w-1/2 flex-none md:w-1/3"
>
{product.featuredImage ? (
<Image
alt={product.title}
className="h-full object-contain"
fill
sizes="33vw"
src={product.featuredImage.url}
/>
) : null}
<div className="absolute inset-y-0 right-0 flex items-center justify-center">
<div className="inline-flex bg-white p-4 text-xl font-semibold text-black dark:bg-black dark:text-white">
{product.title}
</div>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@ -1,57 +0,0 @@
'use server';
import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
import { cookies } from 'next/headers';
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId || !variantId) {
return new Error('Missing cartId or variantId');
}
try {
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
} catch (e) {
return new Error('Error adding item', { cause: e });
}
};
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return new Error('Missing cartId');
}
try {
await removeFromCart(cartId, [lineId]);
} catch (e) {
return new Error('Error removing item', { cause: e });
}
};
export const updateItemQuantity = async ({
lineId,
variantId,
quantity
}: {
lineId: string;
variantId: string;
quantity: number;
}): Promise<Error | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return new Error('Missing cartId');
}
try {
await updateCart(cartId, [
{
id: lineId,
merchandiseId: variantId,
quantity
}
]);
} catch (e) {
return new Error('Error updating item quantity', { cause: e });
}
};

View File

@ -1,64 +0,0 @@
'use client';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
export function AddToCart({
variants,
availableForSale
}: {
variants: ProductVariant[];
availableForSale: boolean;
}) {
const [selectedVariantId, setSelectedVariantId] = useState(variants[0]?.id);
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
useEffect(() => {
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
)
);
if (variant) {
setSelectedVariantId(variant.id);
}
}, [searchParams, variants, setSelectedVariantId]);
return (
<button
aria-label="Add item to cart"
disabled={isPending}
onClick={() => {
if (!availableForSale) return;
startTransition(async () => {
const error = await addItem(selectedVariantId);
if (error) {
alert(error);
return;
}
router.refresh();
});
}}
className={clsx(
'flex w-full items-center justify-center bg-black p-4 text-sm uppercase tracking-wide text-white opacity-90 hover:opacity-100 dark:bg-white dark:text-black',
{
'cursor-not-allowed opacity-60': !availableForSale,
'cursor-not-allowed': isPending
}
)}
>
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
{isPending ? <LoadingDots className="bg-white dark:bg-black" /> : null}
</button>
);
}

View File

@ -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>
</>
);
}

View File

@ -1,44 +0,0 @@
import CloseIcon from 'components/icons/close';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
import clsx from 'clsx';
import type { CartItem } from 'lib/shopify/types';
import { useTransition } from 'react';
import { removeItem } from 'components/cart/actions';
export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<button
aria-label="Remove cart item"
onClick={() => {
startTransition(async () => {
const error = await removeItem(item.id);
if (error) {
alert(error);
return;
}
router.refresh();
});
}}
disabled={isPending}
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',
{
'cursor-not-allowed px-0': isPending
}
)}
>
{isPending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : (
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
)}
</button>
);
}

View File

@ -1,61 +0,0 @@
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
import clsx from 'clsx';
import { removeItem, updateItemQuantity } from 'components/cart/actions';
import MinusIcon from 'components/icons/minus';
import PlusIcon from 'components/icons/plus';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types';
export default function EditItemQuantityButton({
item,
type
}: {
item: CartItem;
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<button
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
onClick={() => {
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) {
alert(error);
return;
}
router.refresh();
});
}}
disabled={isPending}
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',
{
'cursor-not-allowed': isPending,
'ml-auto': type === 'minus'
}
)}
>
{isPending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
<PlusIcon className="h-4 w-4" />
) : (
<MinusIcon className="h-4 w-4" />
)}
</button>
);
}

View File

@ -1,23 +0,0 @@
import { createCart, getCart } from 'lib/shopify';
import { cookies } from 'next/headers';
import CartButton from './button';
export default async function Cart() {
const cartId = cookies().get('cartId')?.value;
let cartIdUpdated = false;
let cart;
if (cartId) {
cart = await getCart(cartId);
}
// If the `cartId` from the cookie is not set or the cart is empty
// (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} />;
}

View File

@ -1,187 +0,0 @@
import { Dialog } from '@headlessui/react';
import { AnimatePresence, motion } from 'framer-motion';
import Image from 'next/image';
import Link from 'next/link';
import CloseIcon from 'components/icons/close';
import ShoppingBagIcon from 'components/icons/shopping-bag';
import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import DeleteItemButton from './delete-item-button';
import EditItemQuantityButton from './edit-item-quantity-button';
type MerchandiseSearchParams = {
[key: string]: string;
};
export default function CartModal({
isOpen,
onClose,
cart
}: {
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">
<Dialog.Panel
as={motion.div}
variants={{
open: { translateX: 0 },
closed: { translateX: '100%' }
}}
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"
>
<div className="flex items-center justify-between">
<p className="text-lg font-bold">My Cart</p>
<button
aria-label="Close cart"
onClick={onClose}
className="text-black transition-colors hover:text-gray-500 dark:text-gray-100"
data-testid="close-cart"
>
<CloseIcon className="h-7" />
</button>
</div>
{cart.lines.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingBagIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
</div>
) : null}
{cart.lines.length !== 0 ? (
<div className="flex h-full flex-col justify-between overflow-hidden">
<ul className="flex-grow overflow-auto p-6">
{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} data-testid="cart-item">
<Link
className="flex flex-row space-x-4 py-4"
href={merchandiseUrl}
onClick={onClose}
>
<div className="relative h-16 w-16 cursor-pointer overflow-hidden bg-white">
<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="font-semibold">
{item.merchandise.product.title}
</span>
{item.merchandise.title !== DEFAULT_OPTION ? (
<p className="text-sm" data-testid="cart-product-variant">
{item.merchandise.title}
</p>
) : null}
</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>
</li>
);
})}
</ul>
<div className="border-t border-gray-200 pt-2 text-sm text-black dark:text-white">
<div className="mb-2 flex items-center justify-between">
<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>
<Price
className="text-right"
amount={cart.cost.totalTaxAmount.amount}
currencyCode={cart.cost.totalTaxAmount.currencyCode}
/>
</div>
<div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2">
<p>Shipping</p>
<p className="text-right">Calculated at checkout</p>
</div>
<div className="mb-2 flex items-center justify-between font-bold">
<p>Total</p>
<Price
className="text-right"
amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode}
/>
</div>
</div>
<a
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"
>
<span>Proceed to Checkout</span>
</a>
</div>
) : null}
</Dialog.Panel>
</div>
</Dialog>
)}
</AnimatePresence>
);
}

View File

@ -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;

View File

@ -1,55 +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({
collection: '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>
);
}

View File

@ -1,70 +0,0 @@
import clsx from 'clsx';
import Image from 'next/image';
import Price from 'components/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>
);
}

View File

@ -1,14 +0,0 @@
export default function ArrowLeftIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M19 12H5" />
<path d="M12 19L5 12L12 5" />
</svg>
);
}

View File

@ -1,17 +0,0 @@
export default function CaretRightIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M9 18l6-6-6-6" />
</svg>
);
}

View File

@ -1,26 +0,0 @@
import clsx from 'clsx';
import ShoppingBagIcon from './shopping-bag';
export default function CartIcon({
className,
quantity
}: {
className?: string;
quantity?: number;
}) {
return (
<div className="relative">
<ShoppingBagIcon
className={clsx(
'h-6 transition-all ease-in-out hover:scale-110 hover:text-gray-500 dark:hover:text-gray-300',
className
)}
/>
{quantity ? (
<div className="absolute bottom-0 left-0 -mb-3 -ml-3 flex h-5 w-5 items-center justify-center rounded-full border-2 border-white bg-black text-xs text-white dark:border-black dark:bg-white dark:text-black">
{quantity}
</div>
) : null}
</div>
);
}

View File

@ -1,17 +0,0 @@
export default function CloseIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
);
}

View File

@ -1,13 +0,0 @@
export default function GitHubIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M12 0C5.37 0 0 5.50583 0 12.3035C0 17.7478 3.435 22.3463 8.205 23.9765C8.805 24.0842 9.03 23.715 9.03 23.3921C9.03 23.0999 9.015 22.131 9.015 21.1005C6 21.6696 5.22 20.347 4.98 19.6549C4.845 19.3012 4.26 18.2092 3.75 17.917C3.33 17.6863 2.73 17.1173 3.735 17.1019C4.68 17.0865 5.355 17.9939 5.58 18.363C6.66 20.2239 8.385 19.701 9.075 19.3781C9.18 18.5783 9.495 18.04 9.84 17.7325C7.17 17.4249 4.38 16.3637 4.38 11.6576C4.38 10.3196 4.845 9.21227 5.61 8.35102C5.49 8.04343 5.07 6.78232 5.73 5.09058C5.73 5.09058 6.735 4.76762 9.03 6.3517C9.99 6.07487 11.01 5.93645 12.03 5.93645C13.05 5.93645 14.07 6.07487 15.03 6.3517C17.325 4.75224 18.33 5.09058 18.33 5.09058C18.99 6.78232 18.57 8.04343 18.45 8.35102C19.215 9.21227 19.68 10.3042 19.68 11.6576C19.68 16.3791 16.875 17.4249 14.205 17.7325C14.64 18.1169 15.015 18.8552 15.015 20.0086C15.015 21.6542 15 22.9768 15 23.3921C15 23.715 15.225 24.0995 15.825 23.9765C18.2072 23.1519 20.2773 21.5822 21.7438 19.4882C23.2103 17.3942 23.9994 14.8814 24 12.3035C24 5.50583 18.63 0 12 0Z" />
</svg>
);
}

View File

@ -1,20 +0,0 @@
export default function LogoIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label={`${process.env.SITE_NAME} logo`}
viewBox="0 0 32 32"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<rect width="100%" height="100%" rx="16" className="fill-black dark:fill-white" />
<path
className=" fill-white dark:fill-black"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
/>
</svg>
);
}

View File

@ -1,16 +0,0 @@
export default function MenuIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M4 6h16M4 12h16m-7 6h7" />
</svg>
);
}

View File

@ -1,16 +0,0 @@
export default function MinusIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M5 12H19" />
</svg>
);
}

View File

@ -1,17 +0,0 @@
export default function PlusIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M12 5V19" />
<path d="M5 12H19" />
</svg>
);
}

View File

@ -1,11 +0,0 @@
export default function SearchIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
/>
</svg>
);
}

View File

@ -1,19 +0,0 @@
export default function ShoppingBagIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 22"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M4 1L1 5V19C1 19.5304 1.21071 20.0391 1.58579 20.4142C1.96086 20.7893 2.46957 21 3 21H17C17.5304 21 18.0391 20.7893 18.4142 20.4142C18.7893 20.0391 19 19.5304 19 19V5L16 1H4Z" />
<path d="M1 5H19" />
<path d="M14 9C14 10.0609 13.5786 11.0783 12.8284 11.8284C12.0783 12.5786 11.0609 13 10 13C8.93913 13 7.92172 12.5786 7.17157 11.8284C6.42143 11.0783 6 10.0609 6 9" />
</svg>
);
}

View File

@ -1,20 +0,0 @@
export default function VercelIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label="Vercel.com Logo"
viewBox="0 0 89 20"
fill="currentColor"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M11.5625 0L23.125 20H0L11.5625 0Z" />
<path d="M49.875 10.625C49.875 7.40625 47.5 5.15625 44.0937 5.15625C40.6875 5.15625 38.3125 7.40625 38.3125 10.625C38.3125 13.7812 40.875 16.0937 44.4062 16.0937C46.3438 16.0937 48.0938 15.375 49.2188 14.0625L47.0938 12.8437C46.4375 13.5 45.4688 13.9062 44.4062 13.9062C42.8438 13.9062 41.5 13.0625 41.0312 11.7812L40.9375 11.5625H49.7812C49.8438 11.25 49.875 10.9375 49.875 10.625ZM40.9062 9.6875L40.9688 9.5C41.375 8.15625 42.5625 7.34375 44.0625 7.34375C45.5938 7.34375 46.75 8.15625 47.1562 9.5L47.2188 9.6875H40.9062Z" />
<path d="M83.5313 10.625C83.5313 7.40625 81.1563 5.15625 77.75 5.15625C74.3438 5.15625 71.9688 7.40625 71.9688 10.625C71.9688 13.7812 74.5313 16.0937 78.0625 16.0937C80 16.0937 81.75 15.375 82.875 14.0625L80.75 12.8437C80.0938 13.5 79.125 13.9062 78.0625 13.9062C76.5 13.9062 75.1563 13.0625 74.6875 11.7812L74.5938 11.5625H83.4375C83.5 11.25 83.5313 10.9375 83.5313 10.625ZM74.5625 9.6875L74.625 9.5C75.0313 8.15625 76.2188 7.34375 77.7188 7.34375C79.25 7.34375 80.4063 8.15625 80.8125 9.5L80.875 9.6875H74.5625Z" />
<path d="M68.5313 8.84374L70.6563 7.62499C69.6563 6.06249 67.875 5.18749 65.7188 5.18749C62.3125 5.18749 59.9375 7.43749 59.9375 10.6562C59.9375 13.875 62.3125 16.125 65.7188 16.125C67.875 16.125 69.6563 15.25 70.6563 13.6875L68.5313 12.4687C67.9688 13.4062 66.9688 13.9375 65.7188 13.9375C63.75 13.9375 62.4375 12.625 62.4375 10.6562C62.4375 8.68749 63.75 7.37499 65.7188 7.37499C66.9375 7.37499 67.9688 7.90624 68.5313 8.84374Z" />
<path d="M88.2188 1.75H85.7188V15.8125H88.2188V1.75Z" />
<path d="M40.1563 1.75H37.2813L31.7813 11.25L26.2813 1.75H23.375L31.7813 16.25L40.1563 1.75Z" />
<path d="M57.8438 8.0625C58.125 8.0625 58.4062 8.09375 58.6875 8.15625V5.5C56.5625 5.5625 54.5625 6.75 54.5625 8.21875V5.5H52.0625V15.8125H54.5625V11.3437C54.5625 9.40625 55.9062 8.0625 57.8438 8.0625Z" />
</svg>
);
}

View File

@ -1,70 +0,0 @@
import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo';
import VercelIcon from 'components/icons/vercel';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
const { SITE_NAME } = process.env;
export default async function Footer() {
const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const menu = await getMenu('next-js-frontend-footer-menu');
return (
<footer className="border-t border-gray-700 bg-white text-black dark:bg-black dark:text-white">
<div className="mx-auto w-full max-w-7xl px-6">
<div className="grid grid-cols-1 gap-8 border-b border-gray-700 py-12 transition-colors duration-150 lg:grid-cols-12">
<div className="col-span-1 lg:col-span-3">
<a className="flex flex-initial items-center font-bold md:mr-24" href="/">
<span className="mr-2">
<LogoIcon className="h-8" />
</span>
<span>{SITE_NAME}</span>
</a>
</div>
{menu.length ? (
<nav className="col-span-1 lg:col-span-7">
<ul className="grid md:grid-flow-col md:grid-cols-3 md:grid-rows-4">
{menu.map((item: Menu) => (
<li key={item.title} className="py-3 md:py-0 md:pb-4">
<Link
href={item.path}
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
>
{item.title}
</Link>
</li>
))}
</ul>
</nav>
) : null}
<div className="col-span-1 text-black dark:text-white lg:col-span-2">
<a aria-label="Github Repository" href="https://github.com/vercel/commerce">
<GitHubIcon className="h-6" />
</a>
</div>
</div>
<div className="flex flex-col items-center justify-between space-y-4 pb-10 pt-6 text-sm md:flex-row">
<p>
&copy; {copyrightDate} {SITE_NAME}. All rights reserved.
</p>
<div className="flex items-center text-sm text-white dark:text-black">
<span className="text-black dark:text-white">Created by</span>
<a
rel="noopener noreferrer"
href="https://vercel.com"
aria-label="Vercel.com Link"
target="_blank"
className="text-black dark:text-white"
>
<VercelIcon className="ml-3 inline-block h-6" />
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@ -1,52 +0,0 @@
import Link from 'next/link';
import { Suspense } from 'react';
import Cart from 'components/cart';
import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import MobileMenu from './mobile-menu';
import Search from './search';
export default async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu');
return (
<nav className="relative flex items-center justify-between bg-white p-4 dark:bg-black lg:px-6">
<div className="block w-1/3 md:hidden">
<MobileMenu menu={menu} />
</div>
<div className="flex justify-self-center md:w-1/3 md:justify-self-start">
<div className="md:mr-4">
<Link href="/" aria-label="Go back home">
<LogoIcon className="h-8 transition-transform hover:scale-110" />
</Link>
</div>
{menu.length ? (
<ul className="hidden md:flex md:items-center">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
className="rounded-lg px-2 py-1 text-gray-800 hover:text-gray-500 dark:text-gray-200 dark:hover:text-gray-400"
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
<div className="hidden w-1/3 md:block">
<Search />
</div>
<div className="flex w-1/3 justify-end">
<Suspense fallback={<CartIcon className="h-6" />}>
<Cart />
</Suspense>
</div>
</nav>
);
}

View File

@ -1,118 +0,0 @@
'use client';
import { Dialog } from '@headlessui/react';
import { AnimatePresence, motion } from 'framer-motion';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import CloseIcon from 'components/icons/close';
import MenuIcon from 'components/icons/menu';
import { Menu } from 'lib/shopify/types';
import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [mobileMenuIsOpen, setMobileMenuIsOpen] = useState(false);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 768) {
setMobileMenuIsOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [mobileMenuIsOpen]);
useEffect(() => {
setMobileMenuIsOpen(false);
}, [pathname, searchParams]);
return (
<>
<button
onClick={() => {
setMobileMenuIsOpen(!mobileMenuIsOpen);
}}
aria-label="Open mobile menu"
className="md:hidden"
data-testid="open-mobile-menu"
>
<MenuIcon className="h-6" />
</button>
<AnimatePresence initial={false}>
{mobileMenuIsOpen && (
<Dialog
as={motion.div}
initial="closed"
animate="open"
exit="closed"
key="dialog"
static
open={mobileMenuIsOpen}
onClose={() => {
setMobileMenuIsOpen(false);
}}
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="mobile-menu">
<Dialog.Panel
as={motion.div}
variants={{
open: { translateX: 0 },
closed: { translateX: '-100%' }
}}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
className="flex w-full flex-col bg-white pb-6 dark:bg-black"
>
<div className="p-4">
<button
className="mb-4"
onClick={() => {
setMobileMenuIsOpen(false);
}}
aria-label="Close mobile menu"
data-testid="close-mobile-menu"
>
<CloseIcon className="h-6" />
</button>
<div className="mb-4 w-full">
<Search />
</div>
{menu.length ? (
<ul className="flex flex-col">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-gray-500 dark:text-white"
onClick={() => {
setMobileMenuIsOpen(false);
}}
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
</Dialog.Panel>
</div>
</Dialog>
)}
</AnimatePresence>
</>
);
}

View File

@ -1,46 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import SearchIcon from 'components/icons/search';
import { createUrl } from 'lib/utils';
export default function Search() {
const router = useRouter();
const searchParams = useSearchParams();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const val = e.target as HTMLFormElement;
const search = val.search as HTMLInputElement;
const newParams = new URLSearchParams(searchParams.toString());
if (search.value) {
newParams.set('q', search.value);
} else {
newParams.delete('q');
}
router.push(createUrl('/search', newParams));
}
return (
<form
onSubmit={onSubmit}
className="relative m-0 flex w-full items-center border border-gray-200 bg-transparent p-0 dark:border-gray-500"
>
<input
type="text"
name="search"
placeholder="Search for products..."
autoComplete="off"
defaultValue={searchParams?.get('q') || ''}
className="w-full px-4 py-2 text-black dark:bg-black dark:text-gray-100"
/>
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<SearchIcon className="h-5" />
</div>
</form>
);
}

View File

@ -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>
))}
</>
);
}

View File

@ -1,37 +0,0 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { getCollections } from 'lib/shopify';
import FilterList from './filter';
async function CollectionList() {
const collections = await getCollections();
return <FilterList list={collections} title="Collections" />;
}
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
const activeAndTitles = 'bg-gray-800 dark:bg-gray-300';
const items = 'bg-gray-400 dark:bg-gray-700';
export default function Collections() {
return (
<Suspense
fallback={
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 pl-10 lg:block">
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
</div>
}
>
<CollectionList />
</Suspense>
);
}

View File

@ -1,64 +0,0 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import Caret from 'components/icons/caret-right';
import type { ListItem } from '.';
import { FilterItem } from './item';
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState('');
const [openSelect, setOpenSelect] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpenSelect(false);
}
};
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
useEffect(() => {
list.forEach((listItem: ListItem) => {
if (
('path' in listItem && pathname === listItem.path) ||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
) {
setActive(listItem.title);
}
});
}, [pathname, list, searchParams]);
return (
<div className="relative" ref={ref}>
<div
onClick={() => {
setOpenSelect(!openSelect);
}}
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
>
<div>{active}</div>
<Caret className="h-4 rotate-90" />
</div>
{openSelect && (
<div
onClick={() => {
setOpenSelect(false);
}}
className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
>
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</div>
)}
</div>
);
}

View File

@ -1,34 +0,0 @@
import { SortFilterItem } from 'lib/constants';
import FilterItemDropdown from './dropdown';
import { FilterItem } from './item';
export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = { title: string; path: string };
function FilterItemList({ list }: { list: ListItem[] }) {
return (
<div className="hidden md:block">
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</div>
);
}
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
return (
<>
<nav className="col-span-2 w-full flex-none px-6 py-2 md:py-4 md:pl-10">
{title ? (
<h3 className="hidden font-semibold text-black dark:text-white md:block">{title}</h3>
) : null}
<ul className="hidden md:block">
<FilterItemList list={list} />
</ul>
<ul className="md:hidden">
<FilterItemDropdown list={list} />
</ul>
</nav>
</>
);
}

View File

@ -1,77 +0,0 @@
'use client';
import clsx from 'clsx';
import { SortFilterItem } from 'lib/constants';
import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import type { ListItem, PathFilterItem } from '.';
function PathFilterItem({ item }: { item: PathFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState(pathname === item.path);
const newParams = new URLSearchParams(searchParams.toString());
newParams.delete('q');
useEffect(() => {
setActive(pathname === item.path);
}, [pathname, item.path]);
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
href={createUrl(item.path, newParams)}
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
'text-gray-600 dark:text-gray-400': !active,
'font-semibold text-black dark:text-white': active
})}
>
{item.title}
</Link>
</li>
);
}
function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState(searchParams.get('sort') === item.slug);
const q = searchParams.get('q');
useEffect(() => {
setActive(searchParams.get('sort') === item.slug);
}, [searchParams, item.slug]);
const href =
item.slug && item.slug.length
? createUrl(
pathname,
new URLSearchParams({
...(q && { q }),
sort: item.slug
})
)
: pathname;
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
prefetch={false}
href={href}
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
'text-gray-600 dark:text-gray-400': !active,
'font-semibold text-black dark:text-white': active
})}
>
{item.title}
</Link>
</li>
);
}
export function FilterItem({ item }: { item: ListItem }) {
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
}

View File

@ -1,15 +0,0 @@
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;

View File

@ -1,45 +0,0 @@
import { ImageResponse } from '@vercel/og';
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">
<svg viewBox="0 0 32 32" width="140">
<rect width="100%" height="100%" rx="16" fill="white" />
<path
fillRule="evenodd"
clipRule="evenodd"
fill="black"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
/>
</svg>
<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
}
]
}
);
}

View File

@ -1,18 +0,0 @@
const Price = ({
amount,
currencyCode = 'USD',
...props
}: {
amount: string;
currencyCode: string;
} & React.ComponentProps<'p'>) => (
<p suppressHydrationWarning={true} {...props}>
{`${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
currencyDisplay: 'narrowSymbol'
}).format(parseFloat(amount))} ${currencyCode}`}
</p>
);
export default Price;

View File

@ -1,99 +0,0 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import { GridTileImage } from 'components/grid/tile';
import ArrowLeftIcon from 'components/icons/arrow-left';
export function Gallery({
title,
amount,
currencyCode,
images
}: {
title: string;
amount: string;
currencyCode: string;
images: { src: string; altText: string }[];
}) {
const [currentImage, setCurrentImage] = useState(0);
function handleNavigate(direction: 'next' | 'previous') {
if (direction === 'next') {
setCurrentImage(currentImage + 1 < images.length ? currentImage + 1 : 0);
} else {
setCurrentImage(currentImage === 0 ? images.length - 1 : currentImage - 1);
}
}
const buttonClassName =
'px-9 cursor-pointer ease-in-and-out duration-200 transition-bg bg-[#7928ca] hover:bg-violetDark';
return (
<div className="h-full">
<div className="relative h-full max-h-[600px] overflow-hidden">
{images[currentImage] && (
<GridTileImage
src={images[currentImage]?.src as string}
alt={images[currentImage]?.altText as string}
width={600}
height={600}
isInteractive={false}
priority={true}
background="purple"
labels={{
title,
amount,
currencyCode
}}
/>
)}
{images.length > 1 ? (
<div className="absolute bottom-10 right-10 flex h-12 flex-row border border-white text-white shadow-xl dark:border-black dark:text-black">
<button
aria-label="Previous product image"
className={clsx(buttonClassName, 'border-r border-white dark:border-black')}
onClick={() => handleNavigate('previous')}
>
<ArrowLeftIcon className="h-6" />
</button>
<button
aria-label="Next product image"
className={clsx(buttonClassName)}
onClick={() => handleNavigate('next')}
>
<ArrowLeftIcon className="h-6 rotate-180" />
</button>
</div>
) : null}
</div>
{images.length > 1 ? (
<div className="flex">
{images.map((image, index) => {
const isActive = index === currentImage;
return (
<button
aria-label="Enlarge product image"
key={image.src}
className="h-full w-1/4"
onClick={() => setCurrentImage(index)}
>
<GridTileImage
alt={image?.altText}
src={image.src}
width={600}
height={600}
background="purple-dark"
active={isActive}
/>
</button>
);
})}
</div>
) : null}
</div>
);
}

View File

@ -1,135 +0,0 @@
'use client';
import clsx from 'clsx';
import { ProductOption, ProductVariant } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type ParamsMap = {
[key: string]: string; // ie. { color: 'Red', size: 'Large', ... }
};
type OptimizedVariant = {
id: string;
availableForSale: boolean;
params: URLSearchParams;
[key: string]: string | boolean | URLSearchParams; // ie. { color: 'Red', size: 'Large', ... }
};
export function VariantSelector({
options,
variants
}: {
options: ProductOption[];
variants: ProductVariant[];
}) {
const pathname = usePathname();
const currentParams = useSearchParams();
const router = useRouter();
const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1);
if (hasNoOptionsOrJustOneOption) {
return null;
}
// Discard any unexpected options or values from url and create params map.
const paramsMap: ParamsMap = Object.fromEntries(
Array.from(currentParams.entries()).filter(([key, value]) =>
options.find((option) => option.name.toLowerCase() === key && option.values.includes(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) => (
<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) => {
// Base option params on selected variant params.
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);
// The option is active if it in the url params.
const isActive = selectedVariantParams.get(option.name.toLowerCase()) === value;
// The option is available for sale if it fully matches the variant in the option's url params.
// 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
// disable combinations that are not possible.
const isAvailableForSale = optimizedVariants.find((a) =>
Array.from(optionParams.entries()).every(([key, value]) => a[key] === value)
)?.availableForSale;
const DynamicTag = isAvailableForSale ? Link : 'p';
return (
<DynamicTag
key={value}
href={optionUrl}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
className={clsx(
'flex h-12 min-w-[48px] items-center justify-center rounded-full px-2 text-sm',
{
'cursor-default ring-2 ring-black dark:ring-white': 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':
!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':
!isAvailableForSale
}
)}
data-testid={isActive ? 'selected-variant' : 'variant'}
>
{value}
</DynamicTag>
);
})}
</dd>
</dl>
));
}

View File

@ -1,21 +0,0 @@
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-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',
className
)}
dangerouslySetInnerHTML={{ __html: html as string }}
/>
);
};
export default Prose;