Merge pull request #15 from medusajs/update-main

feat: update main
This commit is contained in:
Victor Gerbrands 2023-08-07 16:33:38 +02:00 committed by GitHub
commit ea9afa6b60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 2502 additions and 2927 deletions

View File

@ -1,6 +1,7 @@
TWITTER_CREATOR="@medusajs"
TWITTER_SITE="https://medusajs.com/"
SITE_NAME="Next.js Commerce by Medusa"
SITE_NAME="Medusa Store"
NEXT_PUBLIC_MEDUSA_BACKEND_API="http://localhost:9000"
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
MEDUSA_API_KEY=""
MEDUSA_API_KEY=""
MEDUSA_REVALIDATION_SECRET="supersecret123"

View File

@ -1,49 +0,0 @@
name: e2e
on:
schedule:
# Runs "at 09:00 and 15:00, Monday through Friday" (see https://crontab.guru)
- cron: '0 9,15 * * 1-5'
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Cancel running workflows
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Checkout repo
uses: actions/checkout@v3
- name: Set node version
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: Set pnpm version
uses: pnpm/action-setup@v2
with:
run_install: false
version: 7
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: pnpm install
- name: Get playwright version
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./node_modules/@playwright/test/package.json').version)")" >> $GITHUB_ENV
- name: Cache playwright
uses: actions/cache@v3
id: playwright-cache
with:
path: '~/.cache/ms-playwright'
key: playwright-cache-${{ env.PLAYWRIGHT_VERSION }}
- name: Install playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
- name: Install playwright browser dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps
- name: Run tests
run: pnpm test:e2e

2
.nvmrc
View File

@ -1 +1 @@
16
18

View File

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

View File

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

View File

@ -4,12 +4,11 @@ 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="w-full">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
<Suspense>{children}</Suspense>
</div>
</div>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
);

View File

@ -0,0 +1,13 @@
import OpengraphImage from 'components/opengraph-image';
// import { getPage } from 'lib/medusa';
export const runtime = 'edge';
export default async function Image({ params }: { params: { page: string } }) {
// Medusa doesn't support content pages right now
// const page = await getPage(params.page);
// const title = page.seo?.title || page.title;
const title = process.env.SITE_NAME || 'Medusa Store';
return await OpengraphImage({ title });
}

View File

@ -6,7 +6,7 @@ import { notFound } from 'next/navigation';
export const runtime = 'edge';
export const revalidate = parseInt(process.env.REVALIDATE_WINDOW ?? `${60 * 60 * 12}`); // 12 hours
export const revalidate = 43200; // 12 hours
export async function generateMetadata({
params
@ -23,13 +23,6 @@ export async function generateMetadata({
title: page.title,
description: '',
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(page.title)}`,
width: 1200,
height: 630
}
],
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: 'article'

Binary file not shown.

View File

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

View File

@ -0,0 +1,8 @@
import { revalidate } from 'lib/medusa';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge';
export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req);
}

View File

@ -2,9 +2,18 @@
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<h2>Something went wrong.</h2>
<button onClick={() => reset()}>Try again</button>
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12">
<h2 className="text-xl font-bold">Oh no!</h2>
<p className="my-2">
There was an issue with our storefront. This could be a temporary issue, please try your
action again.
</p>
<button
className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90"
onClick={() => reset()}
>
Try Again
</button>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -2,8 +2,20 @@
@tailwind components;
@tailwind utilities;
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
img[loading='lazy'] {
clip-path: inset(0.6px);
}
}
a,
input,
button {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
}

View File

@ -4,10 +4,14 @@ import { ReactNode, Suspense } from 'react';
import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export const metadata = {
metadataBase: new URL(baseUrl),
title: {
default: SITE_NAME,
default: SITE_NAME!,
template: `%s | ${SITE_NAME}`
},
robots: {
@ -33,8 +37,7 @@ const inter = 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">
{/* @ts-expect-error Server Component */}
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<Navbar />
<Suspense>
<main>{children}</main>

7
app/opengraph-image.tsx Normal file
View File

@ -0,0 +1,7 @@
import OpengraphImage from 'components/opengraph-image';
export const runtime = 'edge';
export default async function Image() {
return await OpengraphImage();
}

View File

@ -5,18 +5,11 @@ import { Suspense } from 'react';
export const runtime = 'edge';
export const revalidate = parseInt(process.env.REVALIDATE_WINDOW ?? `${60 * 60 * 12}`); // 12 hours
export const revalidate = 43200; // 12 hours
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Medusa.',
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(process.env.SITE_NAME || '')}`,
width: 1200,
height: 630
}
],
type: 'website'
}
};
@ -24,13 +17,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>

View File

@ -2,16 +2,14 @@ import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer';
import ProductGridItems from 'components/layout/product-grid-items';
import { AddToCart } from 'components/product/add-to-cart';
import { Gallery } from 'components/product/gallery';
import { VariantSelector } from 'components/product/variant-selector';
import Prose from 'components/prose';
import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct } from 'lib/medusa';
import { Image } from 'lib/medusa/types';
import Link from 'next/link';
export const runtime = 'edge';
@ -25,17 +23,17 @@ export async function generateMetadata({
if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {};
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.title,
description: product.description,
robots: {
index: hide,
follow: hide,
index: indexable,
follow: indexable,
googleBot: {
index: hide,
follow: hide
index: indexable,
follow: indexable
}
},
openGraph: url
@ -58,43 +56,53 @@ 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.maxVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount
}
};
return (
<div>
<div className="lg:grid lg:grid-cols-6">
{product.images && (
<div className="lg:col-span-4">
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd)
}}
/>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row">
<div className="h-full w-full basis-full lg:basis-4/6">
<Gallery
title={product.title}
amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
images={product.images.map((image: Image) => ({
images={product.images!.map((image: Image) => ({
src: image.url,
altText: image.altText ?? ''
altText: image.altText
}))}
/>
</div>
)}
<div className="p-6 lg:col-span-2">
{/* @ts-expect-error Server Component */}
<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 className="basis-full lg:basis-2/6">
<ProductDescription product={product} />
</div>
</div>
<Suspense>
<RelatedProducts id={product.id!} />
</Suspense>
</div>
<Suspense>
{/* @ts-expect-error Server Component */}
<RelatedProducts id={product.id} />
<Suspense>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
<Footer />
</Suspense>
</div>
</>
);
}
@ -105,11 +113,30 @@ async function RelatedProducts({ id }: { id: string }) {
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 className="py-8">
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
{relatedProducts.map((product) => (
<li
key={product.handle}
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
>
<Link className="relative h-full w-full" href={`/product/${product.handle}`}>
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
/>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

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

View File

@ -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,28 +20,27 @@ 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(params.collection, reverse, sortKey);
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">
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
)}

View File

@ -7,16 +7,15 @@ 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">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
<div className="order-first w-full flex-none md:max-w-[125px]">
<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">
<div className="order-none flex-none md:order-last md:w-[125px]">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
);

View File

@ -6,7 +6,9 @@ export default function Loading() {
{Array(12)
.fill(0)
.map((_, index) => {
return <Grid.Item key={index} className="animate-pulse bg-gray-100 dark:bg-gray-900" />;
return (
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" />
);
})}
</Grid>
);

View File

@ -24,7 +24,7 @@ export default async function SearchPage({
return (
<>
{searchValue ? (
<p>
<p className="mb-4">
{products.length === 0
? 'There are no products that match '
: `Showing ${products.length} ${resultsText} for `}
@ -32,7 +32,7 @@ export default async function SearchPage({
</p>
) : null}
{products.length > 0 ? (
<Grid className="grid-cols-2 lg:grid-cols-3">
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
) : null}

View File

@ -1,27 +1,41 @@
import { getCategories, getProducts } from 'lib/medusa';
import { MetadataRoute } from 'next';
type Route = {
url: string;
lastModified: string;
};
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 = ['', '/search'].map((route) => ({
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const routesMap = [''].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString()
}));
const collections = await getCategories();
const collectionsMap = collections.map((collection) => ({
const collectionsPromise = collections.map((collection) => ({
url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt
lastModified: collection.updatedAt.toString()
}));
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.toString()
}))
);
return [...routesMap, ...collectionsMap, ...productsMap];
let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise])).flat();
} catch (error) {
throw JSON.stringify(error, null, 2);
}
return [...routesMap, ...fetchedRoutes];
}

View File

@ -1,6 +1,6 @@
import { getCategoryProducts } from 'lib/medusa';
import Image from 'next/image';
import Link from 'next/link';
import { GridTileImage } from './grid/tile';
export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page.
@ -8,32 +8,33 @@ export async function Carousel() {
if (!products?.length) return null;
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
const carouselProducts = [...products, ...products, ...products];
return (
<div className="relative w-full overflow-hidden bg-black dark:bg-white">
<div className="flex animate-carousel">
{[...products, ...products].map((product, i) => (
<Link
<div className=" w-full overflow-x-auto pb-6 pt-1">
<ul className="flex animate-carousel gap-4">
{carouselProducts.map((product, i) => (
<li
key={`${product.handle}${i}`}
href={`/product/${product.handle}`}
className="relative h-[30vh] w-1/2 flex-none md:w-1/3"
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
>
{product.featuredImage ? (
<Image
alt={product.title}
className="h-full object-contain"
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
<GridTileImage
alt={product.featuredImage?.altText}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
fill
sizes="33vw"
src={product.featuredImage.url}
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
/>
) : 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>
</Link>
</li>
))}
</div>
</ul>
</div>
);
}

View File

@ -0,0 +1,66 @@
'use server';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/medusa';
import { cookies } from 'next/headers';
export const addItem = async (variantId: string | undefined): Promise<String | undefined> => {
let cartId = cookies().get('cartId')?.value;
let cart;
if (cartId) {
cart = await getCart(cartId);
}
if (!cartId || !cart) {
cart = await createCart();
cartId = cart.id!;
cookies().set('cartId', cartId);
}
if (!variantId) {
return 'Missing product variant ID';
}
try {
await addToCart(cartId, { variantId, quantity: 1 });
} catch (e) {
return 'Error adding item to cart';
}
};
export const removeItem = async (lineId: string): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try {
await removeFromCart(cartId, lineId);
} catch (e) {
return 'Error removing item from cart';
}
};
export const updateItemQuantity = async ({
lineId,
variantId,
quantity
}: {
lineId: string;
variantId: string;
quantity: number;
}): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try {
await updateCart(cartId, {
lineItemId: lineId,
quantity
});
} catch (e) {
return 'Error updating item quantity';
}
};

View File

@ -0,0 +1,68 @@
'use client';
import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/medusa/types';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
export function AddToCart({
variants,
availableForSale
}: {
variants: ProductVariant[];
availableForSale: boolean;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
)
);
const selectedVariantId = variant?.id || defaultVariantId;
const title = !availableForSale
? 'Out of stock'
: !selectedVariantId
? 'Please select options'
: undefined;
return (
<button
aria-label="Add item to cart"
disabled={isPending || !availableForSale || !selectedVariantId}
title={title}
onClick={() => {
// Safeguard in case someone messes with `disabled` in devtools.
if (!availableForSale || !selectedVariantId) return;
startTransition(async () => {
const error = await addItem(selectedVariantId);
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
}}
className={clsx(
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
{
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
'cursor-not-allowed': isPending
}
)}
>
<div className="absolute left-0 ml-4">
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
</div>
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
</button>
);
}

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/medusa/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

@ -0,0 +1,10 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export default function CloseCart({ className }: { className?: string }) {
return (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110 ', className)} />
</div>
);
}

View File

@ -1,49 +1,43 @@
import CloseIcon from 'components/icons/close';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react';
import { useCookies } from 'react-cookie';
import clsx from 'clsx';
import { removeFromCart } from 'lib/medusa';
import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/medusa/types';
import { useTransition } from 'react';
export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter();
const [removing, setRemoving] = useState(false);
const [cookie] = useCookies(['cartId']);
const [isPending, startTransition] = useTransition();
async function handleRemove() {
const cartId = cookie.cartId;
if (!cartId) return;
setRemoving(true);
await removeFromCart(cartId, item.id);
setRemoving(false);
startTransition(() => {
router.refresh();
});
}
return (
<button
aria-label="Remove cart item"
onClick={handleRemove}
disabled={removing}
onClick={() => {
startTransition(async () => {
const error = await removeItem(item.id);
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
}}
disabled={isPending}
className={clsx(
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
{
'cursor-not-allowed px-0': removing
'cursor-not-allowed px-0': isPending
}
)}
>
{removing ? (
<LoadingDots className="bg-black dark:bg-white" />
{isPending ? (
<LoadingDots className="bg-white" />
) : (
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
)}
</button>
);

View File

@ -1,13 +1,11 @@
import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react';
import { useCookies } from 'react-cookie';
import { useTransition } from 'react';
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import MinusIcon from 'components/icons/minus';
import PlusIcon from 'components/icons/plus';
import { removeFromCart, updateCart } from 'lib/medusa';
import { removeItem, updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/medusa/types';
import LoadingDots from '../loading-dots';
export default function EditItemQuantityButton({
item,
@ -17,51 +15,45 @@ export default function EditItemQuantityButton({
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [cookie] = useCookies(['cartId']);
const [isPending, startTransition] = useTransition();
async function handleEdit() {
const cartId = cookie.cartId;
if (!cartId) return;
setEditing(true);
const method = type === 'minus' && item.quantity - 1 === 0 ? 'remove' : 'update';
method === 'update' &&
(await updateCart(cartId, {
lineItemId: item.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
}));
method === 'remove' && (await removeFromCart(cartId, item.id));
setEditing(false);
startTransition(() => {
router.refresh();
});
}
return (
<button
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
onClick={handleEdit}
disabled={editing}
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) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
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',
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
{
'cursor-not-allowed': editing,
'cursor-not-allowed': isPending,
'ml-auto': type === 'minus'
}
)}
>
{editing ? (
{isPending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
<PlusIcon className="h-4 w-4" />
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : (
<MinusIcon className="h-4 w-4" />
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
)}
</button>
);

View File

@ -1,10 +1,9 @@
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;
let cartIdUpdated = false;
let cart;
if (cartId) {
@ -16,8 +15,7 @@ export default async function Cart() {
// and re-fetch the cart.
if (!cartId || !cart) {
cart = await createCart();
cartIdUpdated = true;
}
return <CartButton cart={cart} cartIdUpdated={cartIdUpdated} />;
return <CartModal cart={cart} />;
}

View File

@ -1,84 +1,86 @@
import { Dialog } from '@headlessui/react';
import { AnimatePresence, motion } from 'framer-motion';
import Image from 'next/image';
import Link from 'next/link';
'use client';
import CloseIcon from 'components/icons/close';
import ShoppingBagIcon from 'components/icons/shopping-bag';
import { Dialog, Transition } from '@headlessui/react';
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/medusa/types';
import { createUrl } from 'lib/utils';
import Image from 'next/image';
import Link from 'next/link';
import { Fragment, useEffect, useRef, useState } from 'react';
import CloseCart from './close-cart';
import DeleteItemButton from './delete-item-button';
import EditItemQuantityButton from './edit-item-quantity-button';
import OpenCart from './open-cart';
type MerchandiseSearchParams = {
[key: string]: string;
};
export default function CartModal({
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 }: { cart: Cart | undefined }) {
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(() => {
// Open cart modal when quantity changes.
if (cart?.totalQuantity !== quantityRef.current) {
// But only if it's not already open (quantity also changes when editing items in cart).
if (!isOpen) {
setIsOpen(true);
}
// Always update the quantity reference
quantityRef.current = cart?.totalQuantity;
}
}, [isOpen, cart?.totalQuantity, quantityRef]);
return (
<>
<button aria-label="Open cart" onClick={openCart}>
<OpenCart quantity={cart?.totalQuantity} />
</button>
<Transition show={isOpen}>
<Dialog onClose={closeCart} className="relative z-50">
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="opacity-0 backdrop-blur-none"
enterTo="opacity-100 backdrop-blur-[.5px]"
leave="transition-all ease-in-out duration-200"
leaveFrom="opacity-100 backdrop-blur-[.5px]"
leaveTo="opacity-0 backdrop-blur-none"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-all ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl dark:border-neutral-700 dark:bg-black/80 dark:text-white md:w-[390px]">
<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" />
<p className="text-lg font-semibold">My Cart</p>
<button aria-label="Close cart" onClick={closeCart}>
<CloseCart />
</button>
</div>
{cart.lines?.length === 0 ? (
{!cart || cart.lines.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingBagIcon className="h-16" />
<ShoppingCartIcon 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">
) : (
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="flex-grow overflow-auto py-4">
{cart.lines.map((item, i) => {
const merchandiseSearchParams = {} as MerchandiseSearchParams;
@ -94,78 +96,79 @@ export default function CartModal({
);
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}
<li
key={i}
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
>
<div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -mt-2 ml-[55px]">
<DeleteItemButton item={item} />
</div>
<Link
href={merchandiseUrl}
onClick={closeCart}
className="z-30 flex flex-row space-x-4"
>
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Image
className="h-full w-full object-cover"
width={64}
height={64}
alt={
item.merchandise.product.featuredImage.altText ||
item.merchandise.product.title
}
src={item.merchandise.product.featuredImage.url}
/>
</div>
<div className="flex flex-1 flex-col text-base">
<span className="leading-tight">
{item.merchandise.product.title}
</span>
{item.merchandise.title !== DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
) : null}
</div>
</Link>
<div className="flex h-16 flex-col justify-between">
<Price
className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode}
/>
</div>
<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}
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton item={item} type="minus" />
<p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span>
</p>
) : null}
<EditItemQuantityButton item={item} type="plus" />
</div>
</div>
<Price
className="flex flex-col justify-between space-y-2 text-sm"
amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode}
/>
</Link>
<div className="flex h-9 flex-row">
<DeleteItemButton item={item} />
<p className="ml-2 flex w-full items-center justify-center border dark:border-gray-700">
<span className="w-full px-2">{item.quantity}</span>
</p>
<EditItemQuantityButton item={item} type="minus" />
<EditItemQuantityButton item={item} type="plus" />
</div>
</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">
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
<p>Taxes</p>
<Price
className="text-right"
className="text-right text-base text-black dark:text-white"
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">
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Shipping</p>
<p className="text-right">Calculated at checkout</p>
</div>
<div className="mb-2 flex items-center justify-between font-bold">
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Total</p>
<Price
className="text-right"
className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode}
/>
@ -173,16 +176,16 @@ export default function CartModal({
</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"
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
>
<span>Proceed to Checkout</span>
Proceed to Checkout
</a>
</div>
) : null}
)}
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
)}
</AnimatePresence>
</Transition>
</>
);
}

View File

@ -0,0 +1,24 @@
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export default function OpenCart({
className,
quantity
}: {
className?: string;
quantity?: number;
}) {
return (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<ShoppingCartIcon
className={clsx('h-4 transition-all ease-in-out hover:scale-110 ', className)}
/>
{quantity ? (
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
{quantity}
</div>
) : null}
</div>
);
}

View File

@ -2,7 +2,7 @@ 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)}>
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
{props.children}
</ul>
);
@ -10,17 +10,12 @@ function Grid(props: React.ComponentProps<'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
)}
>
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
{props.children}
</li>
);
}
Grid.Item = GridItem;
export default Grid;

View File

@ -6,25 +6,27 @@ import Link from 'next/link';
function ThreeItemGridItem({
item,
size,
background
priority
}: {
item: Product;
size: 'full' | 'half';
background: 'white' | 'pink' | 'purple' | 'black';
priority?: boolean;
}) {
return (
<div
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'}
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
>
<Link className="block h-full" href={`/product/${item.handle}`}>
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
<GridTileImage
src={item.featuredImage.url}
width={size === 'full' ? 1080 : 540}
height={size === 'full' ? 1080 : 540}
priority={true}
background={background}
fill
sizes={
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
}
priority={priority}
alt={item.title}
labels={{
label={{
position: size === 'full' ? 'center' : 'bottom',
title: item.title as string,
amount: item.priceRange.maxVariantPrice.amount,
currencyCode: item.priceRange.maxVariantPrice.currencyCode
@ -44,10 +46,10 @@ export async function ThreeItemGrid() {
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 className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} />
</section>
);
}

View File

@ -1,69 +1,49 @@
import clsx from 'clsx';
import Image from 'next/image';
import Price from 'components/price';
import Label from '../label';
export function GridTileImage({
isInteractive = true,
background,
active,
labels,
label,
...props
}: {
isInteractive?: boolean;
background?: 'white' | 'pink' | 'purple' | 'black' | 'purple-dark' | 'blue' | 'cyan' | 'gray';
active?: boolean;
labels?: {
label?: {
title: string;
amount: string;
currencyCode: string;
isSmall?: boolean;
position?: 'bottom' | 'center';
};
} & 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
})}
className={clsx(
'flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
{
relative: label,
'border-2 border-blue-600': active,
'border-neutral-200 dark:border-neutral-800': !active
}
)}
>
{active !== undefined && active ? (
<span className="absolute h-full w-full bg-white opacity-25"></span>
) : null}
{props.src ? (
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
<Image
className={clsx('relative h-full w-full object-contain', {
'transition duration-300 ease-in-out 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>
{label ? (
<Label
title={label.title}
amount={label.amount}
currencyCode={label.currencyCode}
position={label.position}
/>
) : null}
</div>
);

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,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 +1,16 @@
export default function LogoIcon({ className }: { className?: string }) {
import clsx from 'clsx';
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label={`${process.env.SITE_NAME} logo`}
viewBox="0 0 32 32"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
viewBox="0 0 32 28"
{...props}
className={clsx('h-4 w-4 fill-black dark:fill-white', props.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"
/>
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
</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>
);
}

34
components/label.tsx Normal file
View File

@ -0,0 +1,34 @@
import clsx from 'clsx';
import Price from './price';
const Label = ({
title,
amount,
currencyCode,
position = 'bottom'
}: {
title: string;
amount: string;
currencyCode: string;
position?: 'bottom' | 'center';
}) => {
return (
<div
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
'lg:px-20 lg:pb-[35%]': position === 'center'
})}
>
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
<Price
className="flex-none rounded-full bg-blue-600 p-2 text-white"
amount={amount}
currencyCode={currencyCode}
currencyCodeClassName="hidden @[275px]/label:inline"
/>
</div>
</div>
);
};
export default Label;

View File

@ -0,0 +1,46 @@
'use client';
import clsx from 'clsx';
import { Menu } from 'lib/medusa/types';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
const FooterMenuItem = ({ item }: { item: Menu }) => {
const pathname = usePathname();
const [active, setActive] = useState(pathname === item.path);
useEffect(() => {
setActive(pathname === item.path);
}, [pathname, item.path]);
return (
<li>
<Link
href={item.path}
className={clsx(
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm',
{
'text-black dark:text-neutral-300': active
}
)}
>
{item.title}
</Link>
</li>
);
};
export default function FooterMenu({ menu }: { menu: Menu[] }) {
if (!menu.length) return null;
return (
<nav>
<ul>
{menu.map((item: Menu) => {
return <FooterMenuItem key={item.title} item={item} />;
})}
</ul>
</nav>
);
}

View File

@ -1,58 +1,65 @@
import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo';
import MedusaIcon from 'components/icons/medusa';
import FooterMenu from 'components/layout/footer-menu';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/medusa';
import { Menu } from 'lib/medusa/types';
import { Suspense } from 'react';
const { SITE_NAME } = process.env;
const { COMPANY_NAME, SITE_NAME } = process.env;
export default async function Footer() {
const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
const menu = await getMenu('next-js-frontend-footer-menu');
const copyrightName = COMPANY_NAME || SITE_NAME || '';
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/medusajs/vercel-commerce">
<GitHubIcon className="h-6" />
</a>
</div>
<footer className="text-sm text-neutral-500 dark:text-neutral-400">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
<div>
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
<LogoSquare size="sm" />
<span className="uppercase">{SITE_NAME}</span>
</Link>
</div>
<div className="flex flex-col items-center justify-between space-y-4 pb-10 pt-6 text-sm md:flex-row">
<Suspense
fallback={
<div className="flex h-[188px] w-[200px] flex-col gap-2">
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
</div>
}
>
<FooterMenu menu={menu} />
</Suspense>
<div className="md:ml-auto">
<a
className="flex h-8 flex-none items-center justify-center rounded-md border border-neutral-200 bg-white text-xs text-black dark:border-neutral-700 dark:bg-black dark:text-white"
aria-label="Deploy on Vercel"
href="https://vercel.com/templates/next.js/nextjs-commerce"
>
<span className="px-3"></span>
<hr className="h-full border-r border-neutral-200 dark:border-neutral-700" />
<span className="px-3">Deploy</span>
</a>
</div>
</div>
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 xl:px-0">
<p>
&copy; {copyrightDate} {SITE_NAME}. All rights reserved.
&copy; {copyrightDate} {copyrightName}
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} 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>
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
<p>Designed in California</p>
<p className="md:ml-auto">
Crafted by{' '}
<a
rel="noopener noreferrer"
href="https://medusajs.com"
@ -62,7 +69,7 @@ export default async function Footer() {
>
<MedusaIcon className="ml-3 inline-block h-6" />
</a>
</div>
</p>
</div>
</div>
</footer>

View File

@ -1,52 +1,57 @@
import Link from 'next/link';
import Cart from 'components/cart';
import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo';
import OpenCart from 'components/cart/open-cart';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/medusa';
import { Menu } from 'lib/medusa/types';
import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
import Search from './search';
const { SITE_NAME } = process.env;
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">
<nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none 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" />
<div className="flex w-full items-center">
<div className="flex w-full md:w-1/3">
<Link
href="/"
aria-label="Go back home"
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
>
<LogoSquare />
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
{SITE_NAME}
</div>
</Link>
{menu.length ? (
<ul className="hidden gap-6 text-sm md:flex md:items-center">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
<div className="hidden justify-center md:flex md:w-1/3">
<Search />
</div>
<div className="flex justify-end md:w-1/3">
<Suspense fallback={<OpenCart />}>
<Cart />
</Suspense>
</div>
{menu.length ? (
<ul className="hidden md:flex">
{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" />}>
{/* @ts-expect-error Server Component */}
<Cart />
</Suspense>
</div>
</nav>
);

View File

@ -1,98 +1,98 @@
'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';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { Menu } from 'lib/medusa/types';
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"
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white md:hidden"
>
<MenuIcon className="h-6" />
<Bars3Icon className="h-4" />
</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 flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"
onClick={closeMobileMenu}
aria-label="Close mobile menu"
>
<XMarkIcon 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);
}}
<div className="mb-4 w-full">
<Search />
</div>
{menu.length ? (
<ul className="flex w-full flex-col">
{menu.map((item: Menu) => (
<li
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
key={item.title}
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
</Dialog.Panel>
</div>
</Dialog>
<Link href={item.path} onClick={closeMobileMenu}>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
</>
);
}

View File

@ -1,41 +1,49 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import SearchIcon from 'components/icons/search';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { createUrl } from 'lib/utils';
export default function Search() {
const router = useRouter();
const searchParams = useSearchParams();
const [searchValue, setSearchValue] = useState('');
useEffect(() => {
setSearchValue(searchParams?.get('q') || '');
}, [searchParams, setSearchValue]);
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) {
router.push(`/search?q=${search.value}`);
newParams.set('q', search.value);
} else {
router.push(`/search`);
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"
>
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<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"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
/>
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<SearchIcon className="h-5" />
<MagnifyingGlassIcon className="h-4" />
</div>
</form>
);

View File

@ -8,18 +8,17 @@ export default function ProductGridItems({ products }: { products: Product[] })
<>
{products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn">
<Link className="h-full w-full" href={`/product/${product.handle}`}>
<Link className="relative inline-block h-full w-full" href={`/product/${product.handle}`}>
<GridTileImage
alt={product.title}
labels={{
isSmall: true,
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
width={600}
height={600}
fill
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
</Link>
</Grid.Item>

View File

@ -10,14 +10,14 @@ async function CollectionList() {
}
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';
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
const items = 'bg-neutral-400 dark:bg-neutral-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="col-span-2 hidden h-[400px] w-full flex-none py-4 lg:block">
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, items)} />
@ -31,7 +31,6 @@ export default function Collections() {
</div>
}
>
{/* @ts-expect-error Server Component */}
<CollectionList />
</Suspense>
);

View File

@ -3,7 +3,7 @@
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import Caret from 'components/icons/caret-right';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import type { ListItem } from '.';
import { FilterItem } from './item';
@ -45,7 +45,7 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
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" />
<ChevronDownIcon className="h-4" />
</div>
{openSelect && (
<div

View File

@ -7,21 +7,19 @@ 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}
<nav>
{title ? <h3 className="hidden text-xs text-neutral-500 md:block">{title}</h3> : null}
<ul className="hidden md:block">
<FilterItemList list={list} />
</ul>

View File

@ -5,29 +5,30 @@ 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 active = pathname === item.path;
const newParams = new URLSearchParams(searchParams.toString());
const DynamicTag = active ? 'p' : Link;
useEffect(() => {
setActive(pathname === item.path);
}, [pathname, item.path]);
newParams.delete('q');
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
href={createUrl(item.path, searchParams)}
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
})}
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
<DynamicTag
href={createUrl(item.path, newParams)}
className={clsx(
'w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100',
{
'underline underline-offset-4': active
}
)}
>
{item.title}
</Link>
</DynamicTag>
</li>
);
}
@ -35,29 +36,28 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState(searchParams.get('sort') === item.slug);
useEffect(() => {
setActive(searchParams.get('sort') === item.slug);
}, [searchParams, item.slug]);
const href =
item.slug && item.slug.length
? createUrl(pathname, new URLSearchParams({ sort: item.slug }))
: pathname;
const active = searchParams.get('sort') === item.slug;
const q = searchParams.get('q');
const href = createUrl(
pathname,
new URLSearchParams({
...(q && { q }),
...(item.slug && item.slug.length && { sort: item.slug })
})
);
const DynamicTag = active ? 'p' : Link;
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
prefetch={false}
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}>
<DynamicTag
prefetch={!active ? false : undefined}
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
className={clsx('w-full hover:underline hover:underline-offset-4', {
'underline underline-offset-4': active
})}
>
{item.title}
</Link>
</DynamicTag>
</li>
);
}

View File

@ -0,0 +1,23 @@
import clsx from 'clsx';
import LogoIcon from './icons/logo';
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
return (
<div
className={clsx(
'flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black',
{
'h-[40px] w-[40px] rounded-xl': !size,
'h-[30px] w-[30px] rounded-lg': size === 'sm'
}
)}
>
<LogoIcon
className={clsx({
'h-[16px] w-[16px]': !size,
'h-[10px] w-[10px]': size === 'sm'
})}
/>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { ImageResponse } from 'next/server';
import LogoIcon from './icons/logo';
export type Props = {
title?: string;
};
export default async function OpengraphImage(props?: Props): Promise<ImageResponse> {
const { title } = {
...{
title: process.env.SITE_NAME
},
...props
};
return new ImageResponse(
(
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
<div tw="flex flex-none items-center justify-center border border-neutral-700 h-[160px] w-[160px] rounded-3xl">
<LogoIcon width="64" height="58" fill="white" />
</div>
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
res.arrayBuffer()
),
style: 'normal',
weight: 700
}
]
}
);
}

View File

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

View File

@ -1,74 +0,0 @@
'use client';
import clsx from 'clsx';
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';
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();
const [adding, setAdding] = useState(false);
const [cookie] = useCookies(['cartId']);
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]);
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}
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
}
)}
>
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
{isMutating ? <LoadingDots className="bg-white dark:bg-black" /> : null}
</button>
);
}

View File

@ -1,99 +1,99 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { GridTileImage } from 'components/grid/tile';
import ArrowLeftIcon from 'components/icons/arrow-left';
import { createUrl } from 'lib/utils';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
export function Gallery({
title,
amount,
currencyCode,
images
}: {
title: string;
amount: string;
currencyCode: string;
images: { src: string; altText: string }[];
}) {
const [currentImage, setCurrentImage] = useState(0);
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const imageSearchParam = searchParams.get('image');
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
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 nextSearchParams = new URLSearchParams(searchParams.toString());
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
nextSearchParams.set('image', nextImageIndex.toString());
const nextUrl = createUrl(pathname, nextSearchParams);
const previousSearchParams = new URLSearchParams(searchParams.toString());
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
previousSearchParams.set('image', previousImageIndex.toString());
const previousUrl = createUrl(pathname, previousSearchParams);
const buttonClassName =
'px-9 cursor-pointer ease-in-and-out duration-200 transition-bg bg-[#7928ca] hover:bg-violetDark';
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
return (
<div className="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}
<>
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
{images[imageIndex] && (
<Image
className="h-full w-full object-contain"
fill
sizes="(min-width: 1024px) 66vw, 100vw"
alt={images[imageIndex]?.altText as string}
src={images[imageIndex]?.src as string}
priority={true}
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 className="absolute bottom-[15%] flex w-full justify-center">
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
<Link
aria-label="Previous product image"
href={previousUrl}
className={buttonClassName}
scroll={false}
>
<ArrowLeftIcon className="h-5" />
</Link>
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
<Link
aria-label="Next product image"
href={nextUrl}
className={buttonClassName}
scroll={false}
>
<ArrowRightIcon className="h-5" />
</Link>
</div>
</div>
) : null}
</div>
{images.length > 1 ? (
<div className="flex">
<ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
{images.map((image, index) => {
const isActive = index === currentImage;
const isActive = index === imageIndex;
const imageSearchParams = new URLSearchParams(searchParams.toString());
imageSearchParams.set('image', index.toString());
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>
<li key={image.src} className="h-auto w-20">
<Link
aria-label="Enlarge product image"
href={createUrl(pathname, imageSearchParams)}
scroll={false}
className="h-full w-full"
>
<GridTileImage
alt={image.altText}
src={image.src}
width={80}
height={80}
active={isActive}
/>
</Link>
</li>
);
})}
</div>
</ul>
) : null}
</div>
</>
);
}

View File

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

View File

@ -4,18 +4,12 @@ import clsx from 'clsx';
import { ProductOption, ProductVariant } from 'lib/medusa/types';
import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
type ParamsMap = {
[key: string]: string; // ie. { color: 'Red', size: 'Large', ... }
};
type OptimizedVariant = {
type Combination = {
id: string;
availableForSale: boolean;
params: URLSearchParams;
[key: string]: string | boolean | URLSearchParams; // ie. { color: 'Red', size: 'Large', ... }
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
};
export function VariantSelector({
@ -26,8 +20,7 @@ export function VariantSelector({
variants: ProductVariant[];
}) {
const pathname = usePathname();
const currentParams = useSearchParams();
const router = useRouter();
const searchParams = useSearchParams();
const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1);
@ -35,99 +28,77 @@ export function VariantSelector({
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))
const combinations: Combination[] = variants.map((variant) => ({
id: variant.id,
availableForSale: variant.availableForSale,
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
...variant.selectedOptions.reduce(
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
{}
)
);
// 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);
useEffect(() => {
if (currentUrl !== selectedVariantUrl) {
router.replace(selectedVariantUrl);
}
}, [currentUrl, router, 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);
const optionNameLowerCase = option.name.toLowerCase();
// Update the params using the current option to reflect how the url would change.
optionParams.set(option.name.toLowerCase(), value);
// Base option params on current params so we can preserve any other param state in the url.
const optionSearchParams = new URLSearchParams(searchParams.toString());
const optionUrl = createUrl(pathname, optionParams);
// Update the option params using the current option to reflect how the url *would* change,
// if the option was clicked.
optionSearchParams.set(optionNameLowerCase, value);
const optionUrl = createUrl(pathname, optionSearchParams);
// The option is active if it in the url params.
const isActive = selectedVariantParams.get(option.name.toLowerCase()) === value;
// In order to determine if an option is available for sale, we need to:
//
// 1. Filter out all other param state
// 2. Filter out invalid options
// 3. Check if the option combination is available for sale
//
// This is the "magic" that will cross check possible variant combinations and preemptively
// disable combinations that are not available. For example, if the color gray is only available in size medium,
// then all other sizes should be disabled.
const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
options.find(
(option) => option.name.toLowerCase() === key && option.values.includes(value)
)
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale
)
);
// The option is 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;
// The option is active if it's in the url params.
const isActive = searchParams.get(optionNameLowerCase) === value;
// You can't disable a link, so we need to render something that isn't clickable.
const DynamicTag = isAvailableForSale ? Link : 'p';
const dynamicProps = {
...(isAvailableForSale && { scroll: false })
};
return (
<DynamicTag
key={value}
aria-disabled={!isAvailableForSale}
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',
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
{
'cursor-default ring-2 ring-black dark:ring-white': isActive,
'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':
'cursor-default ring-2 ring-blue-600': isActive,
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
!isActive && isAvailableForSale,
'relative z-10 cursor-not-allowed overflow-hidden bg-gray-100 ring-1 ring-gray-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-gray-300 before:transition-transform dark:bg-gray-900 dark:ring-gray-700 before:dark:bg-gray-700':
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
!isAvailableForSale
}
)}
data-testid={isActive ? 'selected-variant' : 'variant'}
{...dynamicProps}
>
{value}
</DynamicTag>

View File

@ -10,7 +10,7 @@ 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',
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
className
)}
dangerouslySetInnerHTML={{ __html: html as string }}

View File

@ -1,73 +0,0 @@
import { test, expect } from '@playwright/test';
const regex = (text: string) => new RegExp(text, 'gim');
test('should be able to open and close cart', async ({ page }) => {
let cart;
await page.goto('/');
await page.getByTestId('open-cart').click();
cart = await page.getByTestId('cart');
await expect(cart).toBeVisible();
await expect(cart).toHaveText(regex('your cart is empty'));
await page.getByTestId('close-cart').click();
cart = await page.getByTestId('cart');
await expect(cart).toBeHidden();
});
test('should be able to add item to cart, without selecting a variant, assuming first variant', async ({
page
}) => {
await page.goto('/');
await page.getByTestId('homepage-products').locator('a').first().click();
const productName = await page.getByTestId('product-name').first().innerText();
const firstVariant = await page.getByTestId('variant').first().innerText();
await page.getByRole('button', { name: regex('add to cart') }).click();
const cart = await page.getByTestId('cart');
await expect(cart).toBeVisible();
const cartItems = await page.getByTestId('cart-item').all();
let isItemInCart = false;
for (const item of cartItems) {
const cartProductName = await item.getByTestId('cart-product-name').innerText();
const cartProductVariant = await item.getByTestId('cart-product-variant').innerText();
if (cartProductName === productName && cartProductVariant === firstVariant) {
isItemInCart = true;
break;
}
}
await expect(isItemInCart).toBe(true);
});
test('should be able to add item to cart by selecting a variant', async ({ page }) => {
await page.goto('/');
await page.getByTestId('homepage-products').locator('a').first().click();
const selectedProductName = await page.getByTestId('product-name').first().innerText();
const secondVariant = await page.getByTestId('variant').nth(1);
await secondVariant.click();
const selectedProductVariant = await page.getByTestId('selected-variant').innerText();
await page.getByRole('button', { name: regex('add to cart') }).click();
const cart = await page.getByTestId('cart');
await expect(cart).toBeVisible();
const cartItem = await page.getByTestId('cart-item').first();
const cartItemProductName = await cartItem.getByTestId('cart-product-name').innerText();
const cartItemProductVariant = await cartItem.getByTestId('cart-product-variant').innerText();
await expect(cartItemProductName).toBe(selectedProductName);
await expect(cartItemProductVariant).toBe(selectedProductVariant);
});

View File

@ -1,16 +0,0 @@
import { test, expect } from '@playwright/test';
test.use({ viewport: { width: 600, height: 900 } });
test('should be able to open and close mobile menu', async ({ page }) => {
let mobileMenu;
await page.goto('/');
await page.getByTestId('open-mobile-menu').click();
mobileMenu = await page.getByTestId('mobile-menu');
await expect(mobileMenu).toBeVisible();
await page.getByTestId('close-mobile-menu').click();
mobileMenu = await page.getByTestId('mobile-menu');
await expect(mobileMenu).toBeHidden();
});

View File

@ -20,6 +20,11 @@ export const sorting: SortFilterItem[] = [
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
];
export const TAGS = {
categories: 'categories',
products: 'products'
};
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
export const DEFAULT_OPTION = 'Default Title';

View File

@ -1,11 +1,17 @@
import { isMedusaError } from 'lib/type-guards';
import { TAGS } from 'lib/constants';
import { mapOptionIds } from 'lib/utils';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { calculateVariantAmount, computeAmount, convertToDecimal } from './helpers';
import {
Cart,
CartItem,
Image,
MedusaCart,
MedusaImage,
MedusaLineItem,
MedusaProduct,
MedusaProductCollection,
@ -21,23 +27,32 @@ import {
const ENDPOINT = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_API ?? 'http://localhost:9000';
const MEDUSA_API_KEY = process.env.MEDUSA_API_KEY ?? '';
const REVALIDATE_WINDOW = parseInt(process.env.REVALIDATE_WINDOW ?? `${60 * 15}`); // 15 minutes
export default async function medusaRequest(
method: string,
path = '',
payload?: Record<string, unknown> | undefined
) {
export default async function medusaRequest({
cache = 'force-cache',
method,
path,
payload,
tags
}: {
cache?: RequestCache;
method: string;
path: string;
payload?: Record<string, unknown> | undefined;
tags?: string[];
}) {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'x-publishable-key': MEDUSA_API_KEY
}
},
cache,
...(tags && { next: { tags } })
};
if (!path.includes('/carts')) {
options.next = { revalidate: REVALIDATE_WINDOW };
if (path.includes('/carts')) {
options.cache = 'no-cache';
}
if (payload) {
@ -132,7 +147,8 @@ const reshapeLineItem = (lineItem: MedusaLineItem): CartItem => {
},
availableForSale: true,
variants: [lineItem.variant && reshapeProductVariant(lineItem.variant)],
handle: lineItem.variant?.product?.handle ?? ''
handle: lineItem.variant?.product?.handle ?? '',
options: [] as ProductOption[]
};
const selectedOptions =
@ -167,6 +183,17 @@ const reshapeLineItem = (lineItem: MedusaLineItem): CartItem => {
};
};
const reshapeImages = (images?: MedusaImage[], productTitle?: string): Image[] => {
if (!images) return [];
return images.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)![1];
return {
...image,
altText: `${productTitle} - ${filename}`
};
});
};
const reshapeProduct = (product: MedusaProduct): Product => {
const variant = product.variants?.[0];
@ -183,25 +210,29 @@ const reshapeProduct = (product: MedusaProduct): Product => {
currencyCode: product.variants?.[0]?.prices?.[0]?.currency_code.toUpperCase() ?? ''
}
};
const updatedAt = product.updated_at;
const createdAt = product.created_at;
const tags = product.tags?.map((tag) => tag.value) || [];
const descriptionHtml = product.description ?? '';
const featuredImageFilename = product.thumbnail?.match(/.*\/(.*)\..*/)![1];
const featuredImage = {
url: product.thumbnail ?? '',
altText: product.title ?? ''
altText: product.thumbnail ? `${product.title} - ${featuredImageFilename}` : ''
};
const availableForSale = product.variants?.[0]?.purchasable || true;
const images = reshapeImages(product.images, product.title);
const variants = product.variants.map((variant) =>
reshapeProductVariant(variant, product.options)
);
let options;
let options = [] as ProductOption[];
product.options && (options = product.options.map((option) => reshapeProductOption(option)));
return {
...product,
images,
featuredImage,
priceRange,
updatedAt,
@ -272,7 +303,7 @@ const reshapeCategory = (category: ProductCategory): ProductCollection => {
};
export async function createCart(): Promise<Cart> {
const res = await medusaRequest('POST', '/carts', {});
const res = await medusaRequest({ method: 'POST', path: '/carts' });
return reshapeCart(res.body.cart);
}
@ -280,15 +311,24 @@ export async function addToCart(
cartId: string,
lineItem: { variantId: string; quantity: number }
): Promise<Cart> {
const res = await medusaRequest('POST', `/carts/${cartId}/line-items`, {
variant_id: lineItem?.variantId,
quantity: lineItem?.quantity
const res = await medusaRequest({
method: 'POST',
path: `/carts/${cartId}/line-items`,
payload: {
variant_id: lineItem?.variantId,
quantity: lineItem?.quantity
},
tags: ['cart']
});
return reshapeCart(res.body.cart);
}
export async function removeFromCart(cartId: string, lineItemId: string): Promise<Cart> {
const res = await medusaRequest('DELETE', `/carts/${cartId}/line-items/${lineItemId}`);
const res = await medusaRequest({
method: 'DELETE',
path: `/carts/${cartId}/line-items/${lineItemId}`,
tags: ['cart']
});
return reshapeCart(res.body.cart);
}
@ -296,14 +336,19 @@ export async function updateCart(
cartId: string,
{ lineItemId, quantity }: { lineItemId: string; quantity: number }
): Promise<Cart> {
const res = await medusaRequest('POST', `/carts/${cartId}/line-items/${lineItemId}`, {
quantity
const res = await medusaRequest({
method: 'POST',
path: `/carts/${cartId}/line-items/${lineItemId}`,
payload: {
quantity
},
tags: ['cart']
});
return reshapeCart(res.body.cart);
}
export async function getCart(cartId: string): Promise<Cart | null> {
const res = await medusaRequest('GET', `/carts/${cartId}`);
const res = await medusaRequest({ method: 'GET', path: `/carts/${cartId}`, tags: ['cart'] });
const cart = res.body.cart;
if (!cart) {
@ -314,7 +359,11 @@ export async function getCart(cartId: string): Promise<Cart | null> {
}
export async function getCategories(): Promise<ProductCollection[]> {
const res = await medusaRequest('GET', '/product-categories');
const res = await medusaRequest({
method: 'GET',
path: '/product-categories',
tags: ['categories']
});
// Reshape categories and hide categories starting with 'hidden'
const categories = res.body.product_categories
@ -325,12 +374,24 @@ export async function getCategories(): Promise<ProductCollection[]> {
}
export async function getCategory(handle: string): Promise<ProductCollection | undefined> {
const res = await medusaRequest('GET', `/product-categories?handle=${handle}&expand=products`);
const res = await medusaRequest({
method: 'GET',
path: `/product-categories?handle=${handle}&expand=products`,
tags: ['categories', 'products']
});
return res.body.product_categories[0];
}
export async function getCategoryProducts(handle: string): Promise<Product[]> {
const res = await medusaRequest('GET', `/product-categories?handle=${handle}`);
export async function getCategoryProducts(
handle: string,
reverse?: boolean,
sortKey?: string
): Promise<Product[]> {
const res = await medusaRequest({
method: 'GET',
path: `/product-categories?handle=${handle}`,
tags: ['categories']
});
if (!res) {
return [];
@ -338,32 +399,56 @@ export async function getCategoryProducts(handle: string): Promise<Product[]> {
const category = res.body.product_categories[0];
const category_products = await medusaRequest('GET', `/products?category_id[]=${category.id}`);
const category_products = await getProducts({ reverse, sortKey, categoryId: category.id });
const products: Product[] = category_products.body.products.map((product: MedusaProduct) =>
reshapeProduct(product)
);
return products;
return category_products;
}
export async function getProduct(handle: string): Promise<Product> {
const res = await medusaRequest('GET', `/products?handle=${handle}&limit=1`);
const res = await medusaRequest({
method: 'GET',
path: `/products?handle=${handle}&limit=1`,
tags: ['products']
});
const product = res.body.products[0];
return reshapeProduct(product);
}
export async function getProducts({
query = '',
query,
reverse,
sortKey
sortKey,
categoryId
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
categoryId?: string;
}): Promise<Product[]> {
const res = await medusaRequest('GET', `/products?q=${query}&limit=20`);
let products: Product[] = res.body.products.map((product: MedusaProduct) =>
let res;
if (query) {
res = await medusaRequest({
method: 'GET',
path: `/products?q=${query}&limit=100`,
tags: ['products']
});
} else if (categoryId) {
res = await medusaRequest({
method: 'GET',
path: `/products?category_id[]=${categoryId}&limit=100`,
tags: ['products']
});
} else {
res = await medusaRequest({ method: 'GET', path: `/products?limit=100`, tags: ['products'] });
}
if (!res) {
console.error("Couldn't fetch products");
return [];
}
let products: Product[] = res?.body.products.map((product: MedusaProduct) =>
reshapeProduct(product)
);
@ -401,3 +486,35 @@ export async function getMenu(menu: string): Promise<any[]> {
return [];
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Medusa,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['categories/create', 'categories/delete', 'categories/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = headers().get('x-medusa-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.MEDUSA_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.categories);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

View File

@ -68,8 +68,9 @@ export type Product = Partial<Omit<MedusaProduct, 'tags' | 'options' | 'variants
descriptionHtml: string;
tags: Array<string>;
availableForSale: boolean;
options?: Array<ProductOption>;
options: Array<ProductOption>;
variants: Array<ProductVariant>;
images?: Array<Image>;
};
export type FeaturedImage = {
@ -98,7 +99,7 @@ export type MedusaImage = {
};
export type Image = MedusaImage & {
altText?: string;
altText: string;
};
export type ShippingProfile = {

View File

@ -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: [

View File

@ -1,7 +1,8 @@
{
"private": true,
"packageManager": "pnpm@8.2.0",
"engines": {
"node": ">=16",
"node": ">=18",
"pnpm": ">=7"
},
"scripts": {
@ -12,8 +13,7 @@
"lint-staged": "lint-staged",
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm lint && pnpm prettier:check",
"test:e2e": "playwright test"
"test": "pnpm lint && pnpm prettier:check"
},
"git": {
"pre-commit": "lint-staged"
@ -22,33 +22,30 @@
"*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@headlessui/react": "^1.7.14",
"@vercel/og": "^0.5.4",
"clsx": "^1.2.1",
"framer-motion": "^10.12.8",
"is-empty-iterable": "^3.0.0",
"next": "13.4.1",
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"clsx": "^2.0.0",
"next": "13.4.13-canary.15",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-dom": "18.2.0"
},
"devDependencies": {
"@playwright/test": "^1.33.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.9",
"@types/node": "20.1.0",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"@types/node": "20.4.4",
"@types/react": "18.2.16",
"@types/react-dom": "18.2.7",
"@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.40.0",
"eslint-config-next": "^13.4.1",
"eslint": "^8.45.0",
"eslint-config-next": "^13.4.12",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-unicorn": "^47.0.0",
"lint-staged": "^13.2.2",
"postcss": "^8.4.23",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.2.8",
"tailwindcss": "^3.3.2",
"typescript": "5.0.4"
"eslint-plugin-unicorn": "^48.0.0",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"prettier": "3.0.1",
"prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"
}
}

View File

@ -1,45 +0,0 @@
import { PlaywrightTestConfig, devices } from '@playwright/test';
import path from 'path';
const baseURL = `http://localhost:${process.env.PORT || 3000}`;
const config: PlaywrightTestConfig = {
testDir: path.join(__dirname, 'e2e'),
retries: 2,
outputDir: '.playwright',
webServer: {
command: 'pnpm build && pnpm start',
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI
},
use: {
baseURL,
trace: 'retry-with-trace'
},
projects: [
{
name: 'Desktop Chrome',
use: {
...devices['Desktop Chrome']
}
},
{
name: 'Desktop Safari',
use: {
...devices['Desktop Safari']
}
},
{
name: 'Mobile Chrome',
use: {
...devices['Pixel 5']
}
},
{
name: 'Mobile Safari',
use: devices['iPhone 12']
}
]
};
export default config;

1943
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,64 +1,54 @@
import { neutral } from 'tailwindcss/colors';
import plugin from 'tailwindcss/plugin';
const plugin = require('tailwindcss/plugin');
/** @type {import('tailwindcss').Config} */
export const content = [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./icons/**/*.{js,ts,jsx,tsx}',
'./app/**/*.{js,ts,jsx,tsx}'
];
export const theme = {
extend: {
fontFamily: {
sans: ['var(--font-inter)']
},
colors: {
gray: neutral,
hotPink: '#FF1966',
dark: '#111111',
light: '#FAFAFA',
violetDark: '#4c2889'
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 }
module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)']
},
marquee: {
'0%': { transform: 'translateX(0%)' },
'100%': { transform: 'translateX(-100%)' }
},
blink: {
'0%': { opacity: 0.2 },
'20%': { opacity: 1 },
'100% ': { opacity: 0.2 }
}
},
animation: {
fadeIn: 'fadeIn .3s ease-in-out',
carousel: 'marquee 60s linear infinite',
blink: 'blink 1.4s both infinite'
}
}
};
export const future = {
hoverOnlyWhenSupported: true
};
export const plugins = [
require('@tailwindcss/typography'),
plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'animation-delay': (value) => {
return {
'animation-delay': value
};
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 }
},
marquee: {
'0%': { transform: 'translateX(0%)' },
'100%': { transform: 'translateX(-100%)' }
},
blink: {
'0%': { opacity: 0.2 },
'20%': { opacity: 1 },
'100% ': { opacity: 0.2 }
}
},
{
values: theme('transitionDelay')
animation: {
fadeIn: 'fadeIn .3s ease-in-out',
carousel: 'marquee 60s linear infinite',
blink: 'blink 1.4s both infinite'
}
);
})
];
}
},
future: {
hoverOnlyWhenSupported: true
},
plugins: [
require('@tailwindcss/container-queries'),
require('@tailwindcss/typography'),
plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'animation-delay': (value) => {
return {
'animation-delay': value
};
}
},
{
values: theme('transitionDelay')
}
);
})
]
};

734
yarn.lock

File diff suppressed because it is too large Load Diff