mirror of
https://github.com/vercel/commerce.git
synced 2025-07-07 13:21:22 +00:00
Merge remote-tracking branch 'upstream/main' into update-main
This commit is contained in:
commit
2d75399870
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": true,
|
"source.fixAll": true,
|
||||||
|
@ -9,8 +9,8 @@ A Next.js 13 and App Router-ready ecommerce template, built with [Medusa](https:
|
|||||||
- Next.js App Router
|
- Next.js App Router
|
||||||
- Optimized for SEO using Next.js's Metadata
|
- Optimized for SEO using Next.js's Metadata
|
||||||
- React Server Components (RSCs) and Suspense
|
- React Server Components (RSCs) and Suspense
|
||||||
- Route Handlers for mutations
|
- Server Actions for mutations
|
||||||
- Edge runtime
|
- Edge Runtime
|
||||||
- New fetching and caching paradigms
|
- New fetching and caching paradigms
|
||||||
- Dynamic OG images
|
- Dynamic OG images
|
||||||
- Styling with Tailwind CSS
|
- Styling with Tailwind CSS
|
||||||
|
@ -9,7 +9,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<Suspense>{children}</Suspense>
|
<Suspense>{children}</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
11
app/[page]/opengraph-image.tsx
Normal file
11
app/[page]/opengraph-image.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
|
import { getPage } from 'lib/shopify';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export default async function Image({ params }: { params: { page: string } }) {
|
||||||
|
const page = await getPage(params.page);
|
||||||
|
const title = page.seo?.title || page.title;
|
||||||
|
|
||||||
|
return await OpengraphImage({ title });
|
||||||
|
}
|
Binary file not shown.
@ -1,67 +0,0 @@
|
|||||||
import { ImageResponse } from '@vercel/og';
|
|
||||||
import { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
|
|
||||||
const interRegular = fetch(new URL('./Inter-Regular.ttf', import.meta.url)).then((res) =>
|
|
||||||
res.arrayBuffer()
|
|
||||||
);
|
|
||||||
|
|
||||||
const interBold = fetch(new URL('./Inter-Bold.ttf', import.meta.url)).then((res) =>
|
|
||||||
res.arrayBuffer()
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest): Promise<Response | ImageResponse> {
|
|
||||||
try {
|
|
||||||
const [regularFont, boldFont] = await Promise.all([interRegular, interBold]);
|
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
|
|
||||||
const title = searchParams.has('title')
|
|
||||||
? searchParams.get('title')?.slice(0, 100)
|
|
||||||
: process.env.SITE_NAME;
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
|
||||||
<svg viewBox="0 0 32 32" width="140">
|
|
||||||
<rect width="100%" height="100%" rx="16" fill="white" />
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
fill="black"
|
|
||||||
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div tw="mt-12 text-6xl text-white font-bold">{title}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
fonts: [
|
|
||||||
{
|
|
||||||
name: 'Inter',
|
|
||||||
data: regularFont,
|
|
||||||
style: 'normal',
|
|
||||||
weight: 400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inter',
|
|
||||||
data: boldFont,
|
|
||||||
style: 'normal',
|
|
||||||
weight: 700
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error)) throw e;
|
|
||||||
|
|
||||||
console.log(e.message);
|
|
||||||
return new Response(`Failed to generate the image`, {
|
|
||||||
status: 500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
37
app/api/revalidate/route.ts
Normal file
37
app/api/revalidate/route.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { TAGS } from 'lib/constants';
|
||||||
|
import { revalidateTag } from 'next/cache';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// We always need to respond with a 200 status code to Shopify,
|
||||||
|
// otherwise it will continue to retry the request.
|
||||||
|
export async function POST(req: NextRequest): Promise<Response> {
|
||||||
|
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
|
||||||
|
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
|
||||||
|
const topic = headers().get('x-shopify-topic') || 'unknown';
|
||||||
|
const secret = req.nextUrl.searchParams.get('secret');
|
||||||
|
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
||||||
|
const isProductUpdate = productWebhooks.includes(topic);
|
||||||
|
|
||||||
|
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
|
||||||
|
console.error('Invalid revalidation secret.');
|
||||||
|
return NextResponse.json({ status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCollectionUpdate && !isProductUpdate) {
|
||||||
|
// We don't need to revalidate anything for any other topics.
|
||||||
|
return NextResponse.json({ status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCollectionUpdate) {
|
||||||
|
revalidateTag(TAGS.collections);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProductUpdate) {
|
||||||
|
revalidateTag(TAGS.products);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||||
|
}
|
@ -34,7 +34,6 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className={inter.variable}>
|
<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">
|
<body className="bg-white text-black selection:bg-teal-300 dark:bg-black dark:text-white dark:selection:bg-fuchsia-600 dark:selection:text-white">
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
7
app/opengraph-image.tsx
Normal file
7
app/opengraph-image.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
return await OpengraphImage();
|
||||||
|
}
|
@ -24,13 +24,10 @@ export const metadata = {
|
|||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<ThreeItemGrid />
|
<ThreeItemGrid />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<Carousel />
|
<Carousel />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
@ -5,7 +5,7 @@ import { Suspense } from 'react';
|
|||||||
import Grid from 'components/grid';
|
import Grid from 'components/grid';
|
||||||
import Footer from 'components/layout/footer';
|
import Footer from 'components/layout/footer';
|
||||||
import ProductGridItems from 'components/layout/product-grid-items';
|
import ProductGridItems from 'components/layout/product-grid-items';
|
||||||
import { AddToCart } from 'components/product/add-to-cart';
|
import { AddToCart } from 'components/cart/add-to-cart';
|
||||||
import { Gallery } from 'components/product/gallery';
|
import { Gallery } from 'components/product/gallery';
|
||||||
import { VariantSelector } from 'components/product/variant-selector';
|
import { VariantSelector } from 'components/product/variant-selector';
|
||||||
import Prose from 'components/prose';
|
import Prose from 'components/prose';
|
||||||
@ -58,8 +58,31 @@ export default async function ProductPage({ params }: { params: { handle: string
|
|||||||
|
|
||||||
if (!product) return notFound();
|
if (!product) return notFound();
|
||||||
|
|
||||||
|
const productJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Product',
|
||||||
|
name: product.title,
|
||||||
|
description: product.description,
|
||||||
|
image: product.featuredImage.url,
|
||||||
|
offers: {
|
||||||
|
'@type': 'AggregateOffer',
|
||||||
|
availability: product.availableForSale
|
||||||
|
? 'https://schema.org/InStock'
|
||||||
|
: 'https://schema.org/OutOfStock',
|
||||||
|
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
|
||||||
|
highPrice: product.priceRange.maxVariantPrice.amount,
|
||||||
|
lowPrice: product.priceRange.minVariantPrice.amount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(productJsonLd)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="lg:grid lg:grid-cols-6">
|
<div className="lg:grid lg:grid-cols-6">
|
||||||
{product.images && (
|
{product.images && (
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
@ -76,7 +99,6 @@ export default async function ProductPage({ params }: { params: { handle: string
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6 lg:col-span-2">
|
<div className="p-6 lg:col-span-2">
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<VariantSelector options={product.options} variants={product.variants} />
|
<VariantSelector options={product.options} variants={product.variants} />
|
||||||
|
|
||||||
{product.descriptionHtml ? (
|
{product.descriptionHtml ? (
|
||||||
@ -87,10 +109,8 @@ export default async function ProductPage({ params }: { params: { handle: string
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<RelatedProducts id={product.id} />
|
<RelatedProducts id={product.id} />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
11
app/search/[collection]/opengraph-image.tsx
Normal file
11
app/search/[collection]/opengraph-image.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
|
import { getCollection } from 'lib/shopify';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export default async function Image({ params }: { params: { collection: string } }) {
|
||||||
|
const collection = await getCollection(params.collection);
|
||||||
|
const title = collection?.seo?.title || collection?.title;
|
||||||
|
|
||||||
|
return await OpengraphImage({ title });
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
|
|||||||
|
|
||||||
import Grid from 'components/grid';
|
import Grid from 'components/grid';
|
||||||
import ProductGridItems from 'components/layout/product-grid-items';
|
import ProductGridItems from 'components/layout/product-grid-items';
|
||||||
|
import { defaultSort, sorting } from 'lib/constants';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
@ -19,21 +20,20 @@ export async function generateMetadata({
|
|||||||
return {
|
return {
|
||||||
title: collection.seo?.title || collection.title,
|
title: collection.seo?.title || collection.title,
|
||||||
description:
|
description:
|
||||||
collection.seo?.description || collection.description || `${collection.title} products`,
|
collection.seo?.description || collection.description || `${collection.title} products`
|
||||||
openGraph: {
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: `/api/og?title=${encodeURIComponent(collection.title)}`,
|
|
||||||
width: 1200,
|
|
||||||
height: 630
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CategoryPage({ params }: { params: { collection: string } }) {
|
export default async function CategoryPage({
|
||||||
const products = await getCategoryProducts(params.collection);
|
params,
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
params: { collection: string };
|
||||||
|
searchParams?: { [key: string]: string | string[] | undefined };
|
||||||
|
}) {
|
||||||
|
const { sort } = searchParams as { [key: string]: string };
|
||||||
|
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||||
|
const products = await getCategoryProducts({ collection: params.collection, sortKey, reverse });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
@ -16,7 +16,6 @@ export default function SearchLayout({ children }: { children: React.ReactNode }
|
|||||||
<FilterList list={sorting} title="Sort by" />
|
<FilterList list={sorting} title="Sort by" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
@ -6,7 +6,7 @@ const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
|||||||
: 'http://localhost:3000';
|
: 'http://localhost:3000';
|
||||||
|
|
||||||
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> {
|
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> {
|
||||||
const routesMap = ['', '/search'].map((route) => ({
|
const routesMap = [''].map((route) => ({
|
||||||
url: `${baseUrl}${route}`,
|
url: `${baseUrl}${route}`,
|
||||||
lastModified: new Date().toISOString()
|
lastModified: new Date().toISOString()
|
||||||
}));
|
}));
|
||||||
@ -17,11 +17,12 @@ export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.S
|
|||||||
lastModified: collection.updatedAt
|
lastModified: collection.updatedAt
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const products = await getProducts({});
|
const productsPromise = getProducts({}).then((products) =>
|
||||||
const productsMap = products.map((product) => ({
|
products.map((product) => ({
|
||||||
url: `${baseUrl}/product/${product.handle}`,
|
url: `${baseUrl}/product/${product.handle}`,
|
||||||
lastModified: product.updatedAt
|
lastModified: product.updatedAt
|
||||||
}));
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
return [...routesMap, ...collectionsMap, ...productsMap];
|
return [...routesMap, ...collectionsMap, ...productsMap];
|
||||||
}
|
}
|
||||||
|
57
components/cart/actions.ts
Normal file
57
components/cart/actions.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId || !variantId) {
|
||||||
|
return new Error('Missing cartId or variantId');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
||||||
|
} catch (e) {
|
||||||
|
return new Error('Error adding item', { cause: e });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return new Error('Missing cartId');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removeFromCart(cartId, [lineId]);
|
||||||
|
} catch (e) {
|
||||||
|
return new Error('Error removing item', { cause: e });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateItemQuantity = async ({
|
||||||
|
lineId,
|
||||||
|
variantId,
|
||||||
|
quantity
|
||||||
|
}: {
|
||||||
|
lineId: string;
|
||||||
|
variantId: string;
|
||||||
|
quantity: number;
|
||||||
|
}): Promise<Error | undefined> => {
|
||||||
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return new Error('Missing cartId');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateCart(cartId, [
|
||||||
|
{
|
||||||
|
id: lineId,
|
||||||
|
merchandiseId: variantId,
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
return new Error('Error updating item quantity', { cause: e });
|
||||||
|
}
|
||||||
|
};
|
@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { addItem } from 'components/cart/actions';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState, useTransition } from 'react';
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
import { useCookies } from 'react-cookie';
|
|
||||||
|
|
||||||
import LoadingDots from 'components/loading-dots';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import { addToCart } from 'lib/medusa';
|
import { ProductVariant } from 'lib/shopify/types';
|
||||||
import { ProductVariant } from 'lib/medusa/types';
|
|
||||||
|
|
||||||
export function AddToCart({
|
export function AddToCart({
|
||||||
variants,
|
variants,
|
||||||
@ -20,8 +19,6 @@ export function AddToCart({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
const [cookie] = useCookies(['cartId']);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const variant = variants.find((variant: ProductVariant) =>
|
const variant = variants.find((variant: ProductVariant) =>
|
||||||
@ -35,40 +32,33 @@ export function AddToCart({
|
|||||||
}
|
}
|
||||||
}, [searchParams, variants, setSelectedVariantId]);
|
}, [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 (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Add item to cart"
|
aria-label="Add item to cart"
|
||||||
disabled={isMutating}
|
disabled={isPending}
|
||||||
onClick={handleAdd}
|
onClick={() => {
|
||||||
|
if (!availableForSale) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const error = await addItem(selectedVariantId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
className={clsx(
|
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',
|
'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 opacity-60': !availableForSale,
|
||||||
'cursor-not-allowed': isMutating
|
'cursor-not-allowed': isPending
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||||
{isMutating ? <LoadingDots className="bg-white dark:bg-black" /> : null}
|
{isPending ? <LoadingDots className="bg-white dark:bg-black" /> : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -31,16 +31,27 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Remove cart item"
|
aria-label="Remove cart item"
|
||||||
onClick={handleRemove}
|
onClick={() => {
|
||||||
disabled={removing}
|
startTransition(async () => {
|
||||||
|
const error = await removeItem(item.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed px-0': removing
|
'cursor-not-allowed px-0': isPending
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{removing ? (
|
{isPending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-black dark: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" />
|
||||||
|
@ -51,12 +51,12 @@ export default function EditItemQuantityButton({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed': editing,
|
'cursor-not-allowed': isPending,
|
||||||
'ml-auto': type === 'minus'
|
'ml-auto': type === 'minus'
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{editing ? (
|
{isPending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-black dark:bg-white" />
|
||||||
) : type === 'plus' ? (
|
) : type === 'plus' ? (
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { createCart, getCart } from 'lib/medusa';
|
import { createCart, getCart } from 'lib/medusa';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import CartButton from './button';
|
import CartModal from './modal';
|
||||||
|
|
||||||
export default async function Cart() {
|
export default async function Cart() {
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
@ -19,5 +19,5 @@ export default async function Cart() {
|
|||||||
cartIdUpdated = true;
|
cartIdUpdated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CartButton cart={cart} cartIdUpdated={cartIdUpdated} />;
|
return <CartModal cart={cart} cartIdUpdated={cartIdUpdated} />;
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
import { Dialog } from '@headlessui/react';
|
'use client';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import CartIcon from 'components/icons/cart';
|
||||||
import CloseIcon from 'components/icons/close';
|
import CloseIcon from 'components/icons/close';
|
||||||
import ShoppingBagIcon from 'components/icons/shopping-bag';
|
import ShoppingBagIcon from 'components/icons/shopping-bag';
|
||||||
import Price from 'components/price';
|
import Price from 'components/price';
|
||||||
import { DEFAULT_OPTION } from 'lib/constants';
|
import { DEFAULT_OPTION } from 'lib/constants';
|
||||||
import type { Cart } from 'lib/medusa/types';
|
import type { Cart } from 'lib/medusa/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useCookies } from 'react-cookie';
|
||||||
import DeleteItemButton from './delete-item-button';
|
import DeleteItemButton from './delete-item-button';
|
||||||
import EditItemQuantityButton from './edit-item-quantity-button';
|
import EditItemQuantityButton from './edit-item-quantity-button';
|
||||||
|
|
||||||
@ -16,53 +20,70 @@ type MerchandiseSearchParams = {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CartModal({
|
export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdUpdated: boolean }) {
|
||||||
isOpen,
|
const [, setCookie] = useCookies(['cartId']);
|
||||||
onClose,
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
cart
|
const quantityRef = useRef(cart.totalQuantity);
|
||||||
}: {
|
const openCart = () => setIsOpen(true);
|
||||||
isOpen: boolean;
|
const closeCart = () => setIsOpen(false);
|
||||||
onClose: () => void;
|
|
||||||
cart: Cart;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{isOpen && (
|
|
||||||
<Dialog
|
|
||||||
as={motion.div}
|
|
||||||
initial="closed"
|
|
||||||
animate="open"
|
|
||||||
exit="closed"
|
|
||||||
key="dialog"
|
|
||||||
static
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
className="relative z-50"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
variants={{
|
|
||||||
open: { opacity: 1, backdropFilter: 'blur(0.5px)' },
|
|
||||||
closed: { opacity: 0, backdropFilter: 'blur(0px)' }
|
|
||||||
}}
|
|
||||||
className="fixed inset-0 bg-black/30"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 flex justify-end" data-testid="cart">
|
useEffect(() => {
|
||||||
<Dialog.Panel
|
if (cartIdUpdated) {
|
||||||
as={motion.div}
|
setCookie('cartId', cart.id, {
|
||||||
variants={{
|
path: '/',
|
||||||
open: { translateX: 0 },
|
sameSite: 'strict',
|
||||||
closed: { translateX: '100%' }
|
secure: process.env.NODE_ENV === 'production'
|
||||||
}}
|
});
|
||||||
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"
|
return;
|
||||||
>
|
}, [setCookie, cartIdUpdated, cart.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Open cart modal when when quantity changes.
|
||||||
|
if (cart.totalQuantity !== quantityRef.current) {
|
||||||
|
// But only if it's not already open (quantity also changes when editing items in cart).
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update the quantity reference
|
||||||
|
quantityRef.current = cart.totalQuantity;
|
||||||
|
}
|
||||||
|
}, [isOpen, cart.totalQuantity, quantityRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button aria-label="Open cart" onClick={openCart} data-testid="open-cart">
|
||||||
|
<CartIcon quantity={cart.totalQuantity} />
|
||||||
|
</button>
|
||||||
|
<Transition show={isOpen}>
|
||||||
|
<Dialog onClose={closeCart} className="relative z-50" data-testid="cart">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-all ease-in-out duration-300"
|
||||||
|
enterFrom="opacity-0 backdrop-blur-none"
|
||||||
|
enterTo="opacity-100 backdrop-blur-[.5px]"
|
||||||
|
leave="transition-all ease-in-out duration-200"
|
||||||
|
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||||
|
leaveTo="opacity-0 backdrop-blur-none"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||||
|
</Transition.Child>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-all ease-in-out duration-300"
|
||||||
|
enterFrom="translate-x-full"
|
||||||
|
enterTo="translate-x-0"
|
||||||
|
leave="transition-all ease-in-out duration-200"
|
||||||
|
leaveFrom="translate-x-0"
|
||||||
|
leaveTo="translate-x-full"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col bg-white p-6 text-black dark:bg-black dark:text-white md:w-3/5 lg:w-2/5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-lg font-bold">My Cart</p>
|
<p className="text-lg font-bold">My Cart</p>
|
||||||
<button
|
<button
|
||||||
aria-label="Close cart"
|
aria-label="Close cart"
|
||||||
onClick={onClose}
|
onClick={closeCart}
|
||||||
className="text-black transition-colors hover:text-gray-500 dark:text-gray-100"
|
className="text-black transition-colors hover:text-gray-500 dark:text-gray-100"
|
||||||
data-testid="close-cart"
|
data-testid="close-cart"
|
||||||
>
|
>
|
||||||
@ -98,7 +119,7 @@ export default function CartModal({
|
|||||||
<Link
|
<Link
|
||||||
className="flex flex-row space-x-4 py-4"
|
className="flex flex-row space-x-4 py-4"
|
||||||
href={merchandiseUrl}
|
href={merchandiseUrl}
|
||||||
onClick={onClose}
|
onClick={closeCart}
|
||||||
>
|
>
|
||||||
<div className="relative h-16 w-16 cursor-pointer overflow-hidden bg-white">
|
<div className="relative h-16 w-16 cursor-pointer overflow-hidden bg-white">
|
||||||
<Image
|
<Image
|
||||||
@ -180,9 +201,9 @@ export default function CartModal({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</div>
|
</Transition.Child>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
</Transition>
|
||||||
</AnimatePresence>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ export default async function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{menu.length ? (
|
{menu.length ? (
|
||||||
<ul className="hidden md:flex">
|
<ul className="hidden md:flex md:items-center">
|
||||||
{menu.map((item: Menu) => (
|
{menu.map((item: Menu) => (
|
||||||
<li key={item.title}>
|
<li key={item.title}>
|
||||||
<Link
|
<Link
|
||||||
@ -44,7 +44,6 @@ export default async function Navbar() {
|
|||||||
|
|
||||||
<div className="flex w-1/3 justify-end">
|
<div className="flex w-1/3 justify-end">
|
||||||
<Suspense fallback={<CartIcon className="h-6" />}>
|
<Suspense fallback={<CartIcon className="h-6" />}>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<Cart />
|
<Cart />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Dialog } from '@headlessui/react';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { Fragment, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import CloseIcon from 'components/icons/close';
|
import CloseIcon from 'components/icons/close';
|
||||||
import MenuIcon from 'components/icons/menu';
|
import MenuIcon from 'components/icons/menu';
|
||||||
@ -14,85 +13,90 @@ import Search from './search';
|
|||||||
export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [mobileMenuIsOpen, setMobileMenuIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const openMobileMenu = () => setIsOpen(true);
|
||||||
|
const closeMobileMenu = () => setIsOpen(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (window.innerWidth > 768) {
|
if (window.innerWidth > 768) {
|
||||||
setMobileMenuIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, [mobileMenuIsOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMobileMenuIsOpen(false);
|
setIsOpen(false);
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={openMobileMenu}
|
||||||
setMobileMenuIsOpen(!mobileMenuIsOpen);
|
|
||||||
}}
|
|
||||||
aria-label="Open mobile menu"
|
aria-label="Open mobile menu"
|
||||||
className="md:hidden"
|
className="md:hidden"
|
||||||
data-testid="open-mobile-menu"
|
data-testid="open-mobile-menu"
|
||||||
>
|
>
|
||||||
<MenuIcon className="h-6" />
|
<MenuIcon className="h-6" />
|
||||||
</button>
|
</button>
|
||||||
<Dialog
|
<Transition show={isOpen}>
|
||||||
open={mobileMenuIsOpen}
|
<Dialog onClose={closeMobileMenu} className="relative z-50">
|
||||||
onClose={() => {
|
<Transition.Child
|
||||||
setMobileMenuIsOpen(false);
|
as={Fragment}
|
||||||
}}
|
enter="transition-all ease-in-out duration-300"
|
||||||
className="relative z-50"
|
enterFrom="opacity-0 backdrop-blur-none"
|
||||||
>
|
enterTo="opacity-100 backdrop-blur-[.5px]"
|
||||||
<div className="fixed inset-0 flex justify-end" data-testid="mobile-menu">
|
leave="transition-all ease-in-out duration-200"
|
||||||
<Dialog.Panel
|
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||||
as={motion.div}
|
leaveTo="opacity-0 backdrop-blur-none"
|
||||||
variants={{
|
|
||||||
open: { opacity: 1 }
|
|
||||||
}}
|
|
||||||
className="flex w-full flex-col bg-white pb-6 dark:bg-black"
|
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||||
<button
|
</Transition.Child>
|
||||||
className="mb-4"
|
<Transition.Child
|
||||||
onClick={() => {
|
as={Fragment}
|
||||||
setMobileMenuIsOpen(false);
|
enter="transition-all ease-in-out duration-300"
|
||||||
}}
|
enterFrom="translate-x-[-100%]"
|
||||||
aria-label="Close mobile menu"
|
enterTo="translate-x-0"
|
||||||
data-testid="close-mobile-menu"
|
leave="transition-all ease-in-out duration-200"
|
||||||
>
|
leaveFrom="translate-x-0"
|
||||||
<CloseIcon className="h-6" />
|
leaveTo="translate-x-[-100%]"
|
||||||
</button>
|
>
|
||||||
|
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
|
||||||
|
<div className="p-4">
|
||||||
|
<button
|
||||||
|
className="mb-4"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
aria-label="Close mobile menu"
|
||||||
|
data-testid="close-mobile-menu"
|
||||||
|
>
|
||||||
|
<CloseIcon className="h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="mb-4 w-full">
|
<div className="mb-4 w-full">
|
||||||
<Search />
|
<Search />
|
||||||
|
</div>
|
||||||
|
{menu.length ? (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{menu.map((item: Menu) => (
|
||||||
|
<li key={item.title}>
|
||||||
|
<Link
|
||||||
|
href={item.path}
|
||||||
|
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-gray-500 dark:text-white"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{menu.length ? (
|
</Dialog.Panel>
|
||||||
<ul className="flex flex-col">
|
</Transition.Child>
|
||||||
{menu.map((item: Menu) => (
|
</Dialog>
|
||||||
<li key={item.title}>
|
</Transition>
|
||||||
<Link
|
|
||||||
href={item.path}
|
|
||||||
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-gray-500 dark:text-white"
|
|
||||||
onClick={() => {
|
|
||||||
setMobileMenuIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import SearchIcon from 'components/icons/search';
|
import SearchIcon from 'components/icons/search';
|
||||||
|
import { createUrl } from 'lib/utils';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -13,12 +14,15 @@ export default function Search() {
|
|||||||
|
|
||||||
const val = e.target as HTMLFormElement;
|
const val = e.target as HTMLFormElement;
|
||||||
const search = val.search as HTMLInputElement;
|
const search = val.search as HTMLInputElement;
|
||||||
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
if (search.value) {
|
if (search.value) {
|
||||||
router.push(`/search?q=${search.value}`);
|
newParams.set('q', search.value);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/search`);
|
newParams.delete('q');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.push(createUrl('/search', newParams));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -31,7 +31,6 @@ export default function Collections() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* @ts-expect-error Server Component */}
|
|
||||||
<CollectionList />
|
<CollectionList />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
@ -12,6 +12,9 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [active, setActive] = useState(pathname === item.path);
|
const [active, setActive] = useState(pathname === item.path);
|
||||||
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
newParams.delete('q');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(pathname === item.path);
|
setActive(pathname === item.path);
|
||||||
@ -20,7 +23,7 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
|
|||||||
return (
|
return (
|
||||||
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
|
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
|
||||||
<Link
|
<Link
|
||||||
href={createUrl(item.path, searchParams)}
|
href={createUrl(item.path, newParams)}
|
||||||
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
|
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
|
||||||
'text-gray-600 dark:text-gray-400': !active,
|
'text-gray-600 dark:text-gray-400': !active,
|
||||||
'font-semibold text-black dark:text-white': active
|
'font-semibold text-black dark:text-white': active
|
||||||
@ -36,6 +39,7 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [active, setActive] = useState(searchParams.get('sort') === item.slug);
|
const [active, setActive] = useState(searchParams.get('sort') === item.slug);
|
||||||
|
const q = searchParams.get('q');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(searchParams.get('sort') === item.slug);
|
setActive(searchParams.get('sort') === item.slug);
|
||||||
@ -43,7 +47,13 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
|
|||||||
|
|
||||||
const href =
|
const href =
|
||||||
item.slug && item.slug.length
|
item.slug && item.slug.length
|
||||||
? createUrl(pathname, new URLSearchParams({ sort: item.slug }))
|
? createUrl(
|
||||||
|
pathname,
|
||||||
|
new URLSearchParams({
|
||||||
|
...(q && { q }),
|
||||||
|
sort: item.slug
|
||||||
|
})
|
||||||
|
)
|
||||||
: pathname;
|
: pathname;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
45
components/opengraph-image.tsx
Normal file
45
components/opengraph-image.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { ImageResponse } from '@vercel/og';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OpengraphImage(props?: Props): Promise<ImageResponse> {
|
||||||
|
const { title } = {
|
||||||
|
...{
|
||||||
|
title: process.env.SITE_NAME
|
||||||
|
},
|
||||||
|
...props
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
||||||
|
<svg viewBox="0 0 32 32" width="140">
|
||||||
|
<rect width="100%" height="100%" rx="16" fill="white" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
fill="black"
|
||||||
|
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
|
||||||
|
res.arrayBuffer()
|
||||||
|
),
|
||||||
|
style: 'normal',
|
||||||
|
weight: 700
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
@ -20,6 +20,11 @@ export const sorting: SortFilterItem[] = [
|
|||||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const TAGS = {
|
||||||
|
collections: 'collections',
|
||||||
|
products: 'products'
|
||||||
|
};
|
||||||
|
|
||||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||||
export const DEFAULT_OPTION = 'Default Title';
|
export const DEFAULT_OPTION = 'Default Title';
|
||||||
|
|
@ -4,6 +4,9 @@ module.exports = {
|
|||||||
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
||||||
ignoreDuringBuilds: true
|
ignoreDuringBuilds: true
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
serverActions: true
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
23
package.json
23
package.json
@ -22,33 +22,32 @@
|
|||||||
"*": "prettier --write --ignore-unknown"
|
"*": "prettier --write --ignore-unknown"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.14",
|
"@headlessui/react": "^1.7.15",
|
||||||
"@vercel/og": "^0.5.4",
|
"@vercel/og": "^0.5.6",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"framer-motion": "^10.12.8",
|
|
||||||
"is-empty-iterable": "^3.0.0",
|
"is-empty-iterable": "^3.0.0",
|
||||||
"next": "13.4.1",
|
"next": "13.4.6",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-cookie": "^4.1.1",
|
"react-cookie": "^4.1.1",
|
||||||
"react-dom": "18.2.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.33.0",
|
"@playwright/test": "^1.34.3",
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.2.5",
|
||||||
"@types/react": "18.2.6",
|
"@types/react": "18.2.8",
|
||||||
"@types/react-dom": "18.2.4",
|
"@types/react-dom": "18.2.4",
|
||||||
"@vercel/git-hooks": "^1.0.0",
|
"@vercel/git-hooks": "^1.0.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.42.0",
|
||||||
"eslint-config-next": "^13.4.1",
|
"eslint-config-next": "^13.4.4",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-unicorn": "^47.0.0",
|
"eslint-plugin-unicorn": "^47.0.0",
|
||||||
"lint-staged": "^13.2.2",
|
"lint-staged": "^13.2.2",
|
||||||
"postcss": "^8.4.23",
|
"postcss": "^8.4.24",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"prettier-plugin-tailwindcss": "^0.2.8",
|
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||||
"tailwindcss": "^3.3.2",
|
"tailwindcss": "^3.3.2",
|
||||||
"typescript": "5.0.4"
|
"typescript": "5.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1482
pnpm-lock.yaml
generated
1482
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user