mirror of
https://github.com/vercel/commerce.git
synced 2025-05-15 14:06:59 +00:00
Merge remote-tracking branch 'upstream/main' into update-main
This commit is contained in:
commit
2d75399870
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,5 +1,5 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true,
|
||||
|
@ -9,8 +9,8 @@ A Next.js 13 and App Router-ready ecommerce template, built with [Medusa](https:
|
||||
- Next.js App Router
|
||||
- Optimized for SEO using Next.js's Metadata
|
||||
- React Server Components (RSCs) and Suspense
|
||||
- Route Handlers for mutations
|
||||
- Edge runtime
|
||||
- Server Actions for mutations
|
||||
- Edge Runtime
|
||||
- New fetching and caching paradigms
|
||||
- Dynamic OG images
|
||||
- Styling with Tailwind CSS
|
||||
|
@ -9,7 +9,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Suspense>{children}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
|
11
app/[page]/opengraph-image.tsx
Normal file
11
app/[page]/opengraph-image.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getPage } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
const title = page.seo?.title || page.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
Binary file not shown.
@ -1,67 +0,0 @@
|
||||
import { ImageResponse } from '@vercel/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const interRegular = fetch(new URL('./Inter-Regular.ttf', import.meta.url)).then((res) =>
|
||||
res.arrayBuffer()
|
||||
);
|
||||
|
||||
const interBold = fetch(new URL('./Inter-Bold.ttf', import.meta.url)).then((res) =>
|
||||
res.arrayBuffer()
|
||||
);
|
||||
|
||||
export async function GET(req: NextRequest): Promise<Response | ImageResponse> {
|
||||
try {
|
||||
const [regularFont, boldFont] = await Promise.all([interRegular, interBold]);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
const title = searchParams.has('title')
|
||||
? searchParams.get('title')?.slice(0, 100)
|
||||
: process.env.SITE_NAME;
|
||||
|
||||
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>
|
||||
|
||||
<div tw="mt-12 text-6xl text-white font-bold">{title}</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: regularFont,
|
||||
style: 'normal',
|
||||
weight: 400
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: boldFont,
|
||||
style: 'normal',
|
||||
weight: 700
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) throw e;
|
||||
|
||||
console.log(e.message);
|
||||
return new Response(`Failed to generate the image`, {
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
}
|
37
app/api/revalidate/route.ts
Normal file
37
app/api/revalidate/route.ts
Normal file
@ -0,0 +1,37 @@
|
||||
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() });
|
||||
}
|
@ -34,7 +34,6 @@ 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">
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<Navbar />
|
||||
<Suspense>
|
||||
<main>{children}</main>
|
||||
|
7
app/opengraph-image.tsx
Normal file
7
app/opengraph-image.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image() {
|
||||
return await OpengraphImage();
|
||||
}
|
@ -24,13 +24,10 @@ export const metadata = {
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<ThreeItemGrid />
|
||||
<Suspense>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<Carousel />
|
||||
<Suspense>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
|
@ -5,7 +5,7 @@ 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/product/add-to-cart';
|
||||
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';
|
||||
@ -58,8 +58,31 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
|
||||
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">
|
||||
{product.images && (
|
||||
<div className="lg:col-span-4">
|
||||
@ -76,7 +99,6 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
)}
|
||||
|
||||
<div className="p-6 lg:col-span-2">
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<VariantSelector options={product.options} variants={product.variants} />
|
||||
|
||||
{product.descriptionHtml ? (
|
||||
@ -87,10 +109,8 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
</div>
|
||||
</div>
|
||||
<Suspense>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<RelatedProducts id={product.id} />
|
||||
<Suspense>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
|
11
app/search/[collection]/opengraph-image.tsx
Normal file
11
app/search/[collection]/opengraph-image.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getCollection } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { collection: string } }) {
|
||||
const collection = await getCollection(params.collection);
|
||||
const title = collection?.seo?.title || collection?.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@ -19,21 +20,20 @@ export async function generateMetadata({
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`,
|
||||
openGraph: {
|
||||
images: [
|
||||
{
|
||||
url: `/api/og?title=${encodeURIComponent(collection.title)}`,
|
||||
width: 1200,
|
||||
height: 630
|
||||
}
|
||||
]
|
||||
}
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: { params: { collection: string } }) {
|
||||
const products = await getCategoryProducts(params.collection);
|
||||
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 getCategoryProducts({ collection: params.collection, sortKey, reverse });
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
@ -16,7 +16,6 @@ export default function SearchLayout({ children }: { children: React.ReactNode }
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
|
@ -6,7 +6,7 @@ const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> {
|
||||
const routesMap = ['', '/search'].map((route) => ({
|
||||
const routesMap = [''].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date().toISOString()
|
||||
}));
|
||||
@ -17,11 +17,12 @@ export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.S
|
||||
lastModified: collection.updatedAt
|
||||
}));
|
||||
|
||||
const products = await getProducts({});
|
||||
const productsMap = products.map((product) => ({
|
||||
url: `${baseUrl}/product/${product.handle}`,
|
||||
lastModified: product.updatedAt
|
||||
}));
|
||||
const productsPromise = getProducts({}).then((products) =>
|
||||
products.map((product) => ({
|
||||
url: `${baseUrl}/product/${product.handle}`,
|
||||
lastModified: product.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
return [...routesMap, ...collectionsMap, ...productsMap];
|
||||
}
|
||||
|
57
components/cart/actions.ts
Normal file
57
components/cart/actions.ts
Normal file
@ -0,0 +1,57 @@
|
||||
'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 });
|
||||
}
|
||||
};
|
@ -1,13 +1,12 @@
|
||||
'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 { useCookies } from 'react-cookie';
|
||||
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { addToCart } from 'lib/medusa';
|
||||
import { ProductVariant } from 'lib/medusa/types';
|
||||
import { ProductVariant } from 'lib/shopify/types';
|
||||
|
||||
export function AddToCart({
|
||||
variants,
|
||||
@ -20,8 +19,6 @@ export function AddToCart({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [cookie] = useCookies(['cartId']);
|
||||
|
||||
useEffect(() => {
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
@ -35,40 +32,33 @@ export function AddToCart({
|
||||
}
|
||||
}, [searchParams, variants, setSelectedVariantId]);
|
||||
|
||||
const isMutating = adding || isPending;
|
||||
|
||||
async function handleAdd() {
|
||||
if (!availableForSale || !variants[0]) return;
|
||||
|
||||
setAdding(true);
|
||||
|
||||
await addToCart(cookie.cartId, {
|
||||
variantId: selectedVariantId || variants[0].id,
|
||||
quantity: 1
|
||||
});
|
||||
|
||||
setAdding(false);
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Add item to cart"
|
||||
disabled={isMutating}
|
||||
onClick={handleAdd}
|
||||
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': isMutating
|
||||
'cursor-not-allowed': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||
{isMutating ? <LoadingDots className="bg-white dark:bg-black" /> : null}
|
||||
{isPending ? <LoadingDots className="bg-white dark:bg-black" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
@ -31,16 +31,27 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Remove cart item"
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
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': removing
|
||||
'cursor-not-allowed px-0': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
{removing ? (
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : (
|
||||
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
|
||||
|
@ -51,12 +51,12 @@ export default function EditItemQuantityButton({
|
||||
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': editing,
|
||||
'cursor-not-allowed': isPending,
|
||||
'ml-auto': type === 'minus'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{editing ? (
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : type === 'plus' ? (
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { createCart, getCart } from 'lib/medusa';
|
||||
import { cookies } from 'next/headers';
|
||||
import CartButton from './button';
|
||||
import CartModal from './modal';
|
||||
|
||||
export default async function Cart() {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
@ -19,5 +19,5 @@ export default async function Cart() {
|
||||
cartIdUpdated = true;
|
||||
}
|
||||
|
||||
return <CartButton cart={cart} cartIdUpdated={cartIdUpdated} />;
|
||||
return <CartModal cart={cart} cartIdUpdated={cartIdUpdated} />;
|
||||
}
|
||||
|
@ -1,14 +1,18 @@
|
||||
import { Dialog } from '@headlessui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
'use client';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import CartIcon from 'components/icons/cart';
|
||||
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/medusa/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useCookies } from 'react-cookie';
|
||||
import DeleteItemButton from './delete-item-button';
|
||||
import EditItemQuantityButton from './edit-item-quantity-button';
|
||||
|
||||
@ -16,53 +20,70 @@ 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"
|
||||
/>
|
||||
export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdUpdated: boolean }) {
|
||||
const [, setCookie] = useCookies(['cartId']);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const quantityRef = useRef(cart.totalQuantity);
|
||||
const openCart = () => setIsOpen(true);
|
||||
const closeCart = () => setIsOpen(false);
|
||||
|
||||
<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"
|
||||
>
|
||||
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 (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
// Always update the quantity reference
|
||||
quantityRef.current = cart.totalQuantity;
|
||||
}
|
||||
}, [isOpen, cart.totalQuantity, quantityRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Open cart" onClick={openCart} data-testid="open-cart">
|
||||
<CartIcon quantity={cart.totalQuantity} />
|
||||
</button>
|
||||
<Transition show={isOpen}>
|
||||
<Dialog onClose={closeCart} className="relative z-50" data-testid="cart">
|
||||
<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 bg-white p-6 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}
|
||||
onClick={closeCart}
|
||||
className="text-black transition-colors hover:text-gray-500 dark:text-gray-100"
|
||||
data-testid="close-cart"
|
||||
>
|
||||
@ -98,7 +119,7 @@ export default function CartModal({
|
||||
<Link
|
||||
className="flex flex-row space-x-4 py-4"
|
||||
href={merchandiseUrl}
|
||||
onClick={onClose}
|
||||
onClick={closeCart}
|
||||
>
|
||||
<div className="relative h-16 w-16 cursor-pointer overflow-hidden bg-white">
|
||||
<Image
|
||||
@ -180,9 +201,9 @@ export default function CartModal({
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ export default async function Navbar() {
|
||||
</Link>
|
||||
</div>
|
||||
{menu.length ? (
|
||||
<ul className="hidden md:flex">
|
||||
<ul className="hidden md:flex md:items-center">
|
||||
{menu.map((item: Menu) => (
|
||||
<li key={item.title}>
|
||||
<Link
|
||||
@ -44,7 +44,6 @@ export default async function Navbar() {
|
||||
|
||||
<div className="flex w-1/3 justify-end">
|
||||
<Suspense fallback={<CartIcon className="h-6" />}>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<Cart />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog } from '@headlessui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
import CloseIcon from 'components/icons/close';
|
||||
import MenuIcon from 'components/icons/menu';
|
||||
@ -14,85 +13,90 @@ import Search from './search';
|
||||
export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [mobileMenuIsOpen, setMobileMenuIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const openMobileMenu = () => setIsOpen(true);
|
||||
const closeMobileMenu = () => setIsOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth > 768) {
|
||||
setMobileMenuIsOpen(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [mobileMenuIsOpen]);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileMenuIsOpen(false);
|
||||
setIsOpen(false);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileMenuIsOpen(!mobileMenuIsOpen);
|
||||
}}
|
||||
onClick={openMobileMenu}
|
||||
aria-label="Open mobile menu"
|
||||
className="md:hidden"
|
||||
data-testid="open-mobile-menu"
|
||||
>
|
||||
<MenuIcon className="h-6" />
|
||||
</button>
|
||||
<Dialog
|
||||
open={mobileMenuIsOpen}
|
||||
onClose={() => {
|
||||
setMobileMenuIsOpen(false);
|
||||
}}
|
||||
className="relative z-50"
|
||||
>
|
||||
<div className="fixed inset-0 flex justify-end" data-testid="mobile-menu">
|
||||
<Dialog.Panel
|
||||
as={motion.div}
|
||||
variants={{
|
||||
open: { opacity: 1 }
|
||||
}}
|
||||
className="flex w-full flex-col bg-white pb-6 dark:bg-black"
|
||||
<Transition show={isOpen}>
|
||||
<Dialog onClose={closeMobileMenu} 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="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="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-[-100%]"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-all ease-in-out duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-[-100%]"
|
||||
>
|
||||
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
|
||||
<div className="p-4">
|
||||
<button
|
||||
className="mb-4"
|
||||
onClick={closeMobileMenu}
|
||||
aria-label="Close mobile menu"
|
||||
data-testid="close-mobile-menu"
|
||||
>
|
||||
<CloseIcon className="h-6" />
|
||||
</button>
|
||||
|
||||
<div className="mb-4 w-full">
|
||||
<Search />
|
||||
<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={closeMobileMenu}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</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>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import SearchIcon from 'components/icons/search';
|
||||
import { createUrl } from 'lib/utils';
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
@ -13,12 +14,15 @@ export default function Search() {
|
||||
|
||||
const val = e.target as HTMLFormElement;
|
||||
const search = val.search as HTMLInputElement;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (search.value) {
|
||||
router.push(`/search?q=${search.value}`);
|
||||
newParams.set('q', search.value);
|
||||
} else {
|
||||
router.push(`/search`);
|
||||
newParams.delete('q');
|
||||
}
|
||||
|
||||
router.push(createUrl('/search', newParams));
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -31,7 +31,6 @@ export default function Collections() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<CollectionList />
|
||||
</Suspense>
|
||||
);
|
||||
|
@ -12,6 +12,9 @@ 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);
|
||||
@ -20,7 +23,7 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||
return (
|
||||
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
|
||||
<Link
|
||||
href={createUrl(item.path, searchParams)}
|
||||
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
|
||||
@ -36,6 +39,7 @@ 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);
|
||||
@ -43,7 +47,13 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||
|
||||
const href =
|
||||
item.slug && item.slug.length
|
||||
? createUrl(pathname, new URLSearchParams({ sort: item.slug }))
|
||||
? createUrl(
|
||||
pathname,
|
||||
new URLSearchParams({
|
||||
...(q && { q }),
|
||||
sort: item.slug
|
||||
})
|
||||
)
|
||||
: pathname;
|
||||
|
||||
return (
|
||||
|
45
components/opengraph-image.tsx
Normal file
45
components/opengraph-image.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
@ -20,6 +20,11 @@ export const sorting: SortFilterItem[] = [
|
||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
||||
];
|
||||
|
||||
export const TAGS = {
|
||||
collections: 'collections',
|
||||
products: 'products'
|
||||
};
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
|
@ -4,6 +4,9 @@ module.exports = {
|
||||
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
experimental: {
|
||||
serverActions: true
|
||||
},
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
|
23
package.json
23
package.json
@ -22,33 +22,32 @@
|
||||
"*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.14",
|
||||
"@vercel/og": "^0.5.4",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@vercel/og": "^0.5.6",
|
||||
"clsx": "^1.2.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"is-empty-iterable": "^3.0.0",
|
||||
"next": "13.4.1",
|
||||
"next": "13.4.6",
|
||||
"react": "18.2.0",
|
||||
"react-cookie": "^4.1.1",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.33.0",
|
||||
"@playwright/test": "^1.34.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/node": "20.2.5",
|
||||
"@types/react": "18.2.8",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@vercel/git-hooks": "^1.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-next": "^13.4.1",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-next": "^13.4.4",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-unicorn": "^47.0.0",
|
||||
"lint-staged": "^13.2.2",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss": "^8.4.24",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.2.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "5.0.4"
|
||||
"typescript": "5.1.3"
|
||||
}
|
||||
}
|
||||
|
1482
pnpm-lock.yaml
generated
1482
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user