mirror of
https://github.com/vercel/commerce.git
synced 2025-07-09 14:21:21 +00:00
update format
This commit is contained in:
parent
fa1306916c
commit
763c40600c
@ -1,4 +1,4 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import Footer from "components/layout/footer";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
@ -1,5 +1,5 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import OpengraphImage from "components/opengraph-image";
|
||||
import { getPage } from "lib/shopify";
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
|
@ -1,8 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Prose from 'components/prose';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Prose from "components/prose";
|
||||
import { getPage } from "lib/shopify";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ page: string }>;
|
||||
@ -18,12 +18,14 @@ export async function generateMetadata(props: {
|
||||
openGraph: {
|
||||
publishedTime: page.createdAt,
|
||||
modifiedTime: page.updatedAt,
|
||||
type: 'article'
|
||||
}
|
||||
type: "article",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page(props: { params: Promise<{ page: string }> }) {
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ page: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = await getPage(params.page);
|
||||
|
||||
@ -34,11 +36,14 @@ export default async function Page(props: { params: Promise<{ page: string }> })
|
||||
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
|
||||
<Prose className="mb-8" html={page.body} />
|
||||
<p className="text-sm italic">
|
||||
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(page.updatedAt))}.`}
|
||||
{`This document was last updated on ${new Intl.DateTimeFormat(
|
||||
undefined,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
).format(new Date(page.updatedAt))}.`}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { revalidate } from 'lib/shopify';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { revalidate } from "lib/shopify";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return revalidate(req);
|
||||
|
@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
export default function Error({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 dark:border-neutral-800 dark:bg-black">
|
||||
<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.
|
||||
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"
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "@tailwindcss/container-queries";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
|
||||
img[loading='lazy'] {
|
||||
img[loading="lazy"] {
|
||||
clip-path: inset(0.6px);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { CartProvider } from 'components/cart/cart-context';
|
||||
import { Navbar } from 'components/layout/navbar';
|
||||
import { WelcomeToast } from 'components/welcome-toast';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { getCart } from 'lib/shopify';
|
||||
import { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import './globals.css';
|
||||
import { baseUrl } from 'lib/utils';
|
||||
import { CartProvider } from "components/cart/cart-context";
|
||||
import { Navbar } from "components/layout/navbar";
|
||||
import { WelcomeToast } from "components/welcome-toast";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { getCart } from "lib/shopify";
|
||||
import { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
import { baseUrl } from "lib/utils";
|
||||
|
||||
const { SITE_NAME } = process.env;
|
||||
|
||||
@ -14,16 +14,16 @@ export const metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: {
|
||||
default: SITE_NAME!,
|
||||
template: `%s | ${SITE_NAME}`
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
robots: {
|
||||
follow: true,
|
||||
index: true
|
||||
}
|
||||
index: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import OpengraphImage from "components/opengraph-image";
|
||||
|
||||
export default async function Image() {
|
||||
return await OpengraphImage();
|
||||
|
12
app/page.tsx
12
app/page.tsx
@ -1,13 +1,13 @@
|
||||
import { Carousel } from 'components/carousel';
|
||||
import { ThreeItemGrid } from 'components/grid/three-items';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Carousel } from "components/carousel";
|
||||
import { ThreeItemGrid } from "components/grid/three-items";
|
||||
import Footer from "components/layout/footer";
|
||||
|
||||
export const metadata = {
|
||||
description:
|
||||
'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
|
||||
"High-performance ecommerce store built with Next.js, Vercel, and Shopify.",
|
||||
openGraph: {
|
||||
type: 'website'
|
||||
}
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
|
@ -1,16 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Gallery } from 'components/product/gallery';
|
||||
import { ProductProvider } from 'components/product/product-context';
|
||||
import { ProductDescription } from 'components/product/product-description';
|
||||
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||
import { getProduct, getProductRecommendations } from 'lib/shopify';
|
||||
import { Image } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
import { GridTileImage } from "components/grid/tile";
|
||||
import Footer from "components/layout/footer";
|
||||
import { Gallery } from "components/product/gallery";
|
||||
import { ProductProvider } from "components/product/product-context";
|
||||
import { ProductDescription } from "components/product/product-description";
|
||||
import { HIDDEN_PRODUCT_TAG } from "lib/constants";
|
||||
import { getProduct, getProductRecommendations } from "lib/shopify";
|
||||
import { Image } from "lib/shopify/types";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ handle: string }>;
|
||||
@ -31,8 +31,8 @@ export async function generateMetadata(props: {
|
||||
follow: indexable,
|
||||
googleBot: {
|
||||
index: indexable,
|
||||
follow: indexable
|
||||
}
|
||||
follow: indexable,
|
||||
},
|
||||
},
|
||||
openGraph: url
|
||||
? {
|
||||
@ -41,35 +41,37 @@ export async function generateMetadata(props: {
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
alt
|
||||
}
|
||||
]
|
||||
alt,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
|
||||
export default async function ProductPage(props: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
const productJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
name: product.title,
|
||||
description: product.description,
|
||||
image: product.featuredImage.url,
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
"@type": "AggregateOffer",
|
||||
availability: product.availableForSale
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/OutOfStock',
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
|
||||
highPrice: product.priceRange.maxVariantPrice.amount,
|
||||
lowPrice: product.priceRange.minVariantPrice.amount
|
||||
}
|
||||
lowPrice: product.priceRange.minVariantPrice.amount,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
@ -77,7 +79,7 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
__html: JSON.stringify(productJsonLd),
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-(--breakpoint-2xl) px-4">
|
||||
@ -91,7 +93,7 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
|
||||
<Gallery
|
||||
images={product.images.slice(0, 5).map((image: Image) => ({
|
||||
src: image.url,
|
||||
altText: image.altText
|
||||
altText: image.altText,
|
||||
}))}
|
||||
/>
|
||||
</Suspense>
|
||||
@ -134,7 +136,7 @@ async function RelatedProducts({ id }: { id: string }) {
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode,
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { baseUrl } from 'lib/utils';
|
||||
import { baseUrl } from "lib/utils";
|
||||
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*'
|
||||
}
|
||||
userAgent: "*",
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
host: baseUrl
|
||||
host: baseUrl,
|
||||
};
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getCollection } from 'lib/shopify';
|
||||
import OpengraphImage from "components/opengraph-image";
|
||||
import { getCollection } from "lib/shopify";
|
||||
|
||||
export default async function Image({
|
||||
params
|
||||
params,
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}) {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopify';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getCollection, getCollectionProducts } from "lib/shopify";
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
import Grid from "components/grid";
|
||||
import ProductGridItems from "components/layout/product-grid-items";
|
||||
import { defaultSort, sorting } from "lib/constants";
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ collection: string }>;
|
||||
@ -17,7 +17,9 @@ export async function generateMetadata(props: {
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
collection.seo?.description ||
|
||||
collection.description ||
|
||||
`${collection.title} products`,
|
||||
};
|
||||
}
|
||||
|
||||
@ -28,8 +30,13 @@ export default async function CategoryPage(props: {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { sort } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||
const { sortKey, reverse } =
|
||||
sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({
|
||||
collection: params.collection,
|
||||
sortKey,
|
||||
reverse,
|
||||
});
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
@ -1,10 +1,14 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Fragment } from 'react';
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Ensure children are re-rendered when the search query changes
|
||||
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
|
||||
export default function ChildrenWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
|
||||
return <Fragment key={searchParams.get("q")}>{children}</Fragment>;
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
import ChildrenWrapper from './children-wrapper';
|
||||
import { Suspense } from 'react';
|
||||
import Footer from "components/layout/footer";
|
||||
import Collections from "components/layout/search/collections";
|
||||
import FilterList from "components/layout/search/filter";
|
||||
import { sorting } from "lib/constants";
|
||||
import ChildrenWrapper from "./children-wrapper";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default function SearchLayout({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import Grid from 'components/grid';
|
||||
import Grid from "components/grid";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
@ -9,7 +9,10 @@ export default function Loading() {
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" />
|
||||
<Grid.Item
|
||||
key={index}
|
||||
className="animate-pulse bg-neutral-100 dark:bg-neutral-800"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
@ -1,11 +1,11 @@
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
import { getProducts } from 'lib/shopify';
|
||||
import Grid from "components/grid";
|
||||
import ProductGridItems from "components/layout/product-grid-items";
|
||||
import { defaultSort, sorting } from "lib/constants";
|
||||
import { getProducts } from "lib/shopify";
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search',
|
||||
description: 'Search for products in the store.'
|
||||
title: "Search",
|
||||
description: "Search for products in the store.",
|
||||
};
|
||||
|
||||
export default async function SearchPage(props: {
|
||||
@ -13,17 +13,18 @@ export default async function SearchPage(props: {
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const { sortKey, reverse } =
|
||||
sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
const products = await getProducts({ sortKey, reverse, query: searchValue });
|
||||
const resultsText = products.length > 1 ? 'results' : 'result';
|
||||
const resultsText = products.length > 1 ? "results" : "result";
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchValue ? (
|
||||
<p className="mb-4">
|
||||
{products.length === 0
|
||||
? 'There are no products that match '
|
||||
? "There are no products that match "
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
|
@ -1,41 +1,41 @@
|
||||
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
||||
import { baseUrl, validateEnvironmentVariables } from 'lib/utils';
|
||||
import { MetadataRoute } from 'next';
|
||||
import { getCollections, getPages, getProducts } from "lib/shopify";
|
||||
import { baseUrl, validateEnvironmentVariables } from "lib/utils";
|
||||
import { MetadataRoute } from "next";
|
||||
|
||||
type Route = {
|
||||
url: string;
|
||||
lastModified: string;
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
validateEnvironmentVariables();
|
||||
|
||||
const routesMap = [''].map((route) => ({
|
||||
const routesMap = [""].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date().toISOString()
|
||||
lastModified: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const collectionsPromise = getCollections().then((collections) =>
|
||||
collections.map((collection) => ({
|
||||
url: `${baseUrl}${collection.path}`,
|
||||
lastModified: collection.updatedAt
|
||||
}))
|
||||
lastModified: collection.updatedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
const productsPromise = getProducts({}).then((products) =>
|
||||
products.map((product) => ({
|
||||
url: `${baseUrl}/product/${product.handle}`,
|
||||
lastModified: product.updatedAt
|
||||
}))
|
||||
lastModified: product.updatedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
const pagesPromise = getPages().then((pages) =>
|
||||
pages.map((page) => ({
|
||||
url: `${baseUrl}/${page.handle}`,
|
||||
lastModified: page.updatedAt
|
||||
}))
|
||||
lastModified: page.updatedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
let fetchedRoutes: Route[] = [];
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import Link from 'next/link';
|
||||
import { GridTileImage } from './grid/tile';
|
||||
import { getCollectionProducts } from "lib/shopify";
|
||||
import Link from "next/link";
|
||||
import { GridTileImage } from "./grid/tile";
|
||||
|
||||
export async function Carousel() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||
const products = await getCollectionProducts({
|
||||
collection: "hidden-homepage-carousel",
|
||||
});
|
||||
|
||||
if (!products?.length) return null;
|
||||
|
||||
@ -19,13 +21,16 @@ export async function Carousel() {
|
||||
key={`${product.handle}${i}`}
|
||||
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||
>
|
||||
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
||||
<Link
|
||||
href={`/product/${product.handle}`}
|
||||
className="relative h-full w-full"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode,
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
|
@ -1,30 +1,30 @@
|
||||
'use server';
|
||||
"use server";
|
||||
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { TAGS } from "lib/constants";
|
||||
import {
|
||||
addToCart,
|
||||
createCart,
|
||||
getCart,
|
||||
removeFromCart,
|
||||
updateCart
|
||||
} from 'lib/shopify';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
updateCart,
|
||||
} from "lib/shopify";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function addItem(
|
||||
prevState: any,
|
||||
selectedVariantId: string | undefined
|
||||
selectedVariantId: string | undefined,
|
||||
) {
|
||||
if (!selectedVariantId) {
|
||||
return 'Error adding item to cart';
|
||||
return "Error adding item to cart";
|
||||
}
|
||||
|
||||
try {
|
||||
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
return 'Error adding item to cart';
|
||||
return "Error adding item to cart";
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,21 +33,21 @@ export async function removeItem(prevState: any, merchandiseId: string) {
|
||||
const cart = await getCart();
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
return "Error fetching cart";
|
||||
}
|
||||
|
||||
const lineItem = cart.lines.find(
|
||||
(line) => line.merchandise.id === merchandiseId
|
||||
(line) => line.merchandise.id === merchandiseId,
|
||||
);
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
await removeFromCart([lineItem.id]);
|
||||
revalidateTag(TAGS.cart);
|
||||
} else {
|
||||
return 'Item not found in cart';
|
||||
return "Item not found in cart";
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Error removing item from cart';
|
||||
return "Error removing item from cart";
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ export async function updateItemQuantity(
|
||||
payload: {
|
||||
merchandiseId: string;
|
||||
quantity: number;
|
||||
}
|
||||
},
|
||||
) {
|
||||
const { merchandiseId, quantity } = payload;
|
||||
|
||||
@ -64,11 +64,11 @@ export async function updateItemQuantity(
|
||||
const cart = await getCart();
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
return "Error fetching cart";
|
||||
}
|
||||
|
||||
const lineItem = cart.lines.find(
|
||||
(line) => line.merchandise.id === merchandiseId
|
||||
(line) => line.merchandise.id === merchandiseId,
|
||||
);
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
@ -79,8 +79,8 @@ export async function updateItemQuantity(
|
||||
{
|
||||
id: lineItem.id,
|
||||
merchandiseId,
|
||||
quantity
|
||||
}
|
||||
quantity,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (quantity > 0) {
|
||||
@ -91,7 +91,7 @@ export async function updateItemQuantity(
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return 'Error updating item quantity';
|
||||
return "Error updating item quantity";
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,5 +102,5 @@ export async function redirectToCheckout() {
|
||||
|
||||
export async function createCartAndSetCookie() {
|
||||
let cart = await createCart();
|
||||
(await cookies()).set('cartId', cart.id!);
|
||||
(await cookies()).set("cartId", cart.id!);
|
||||
}
|
||||
|
@ -1,23 +1,23 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import { useProduct } from 'components/product/product-context';
|
||||
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||
import { useActionState } from 'react';
|
||||
import { useCart } from './cart-context';
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { addItem } from "components/cart/actions";
|
||||
import { useProduct } from "components/product/product-context";
|
||||
import { Product, ProductVariant } from "lib/shopify/types";
|
||||
import { useActionState } from "react";
|
||||
import { useCart } from "./cart-context";
|
||||
|
||||
function SubmitButton({
|
||||
availableForSale,
|
||||
selectedVariantId
|
||||
selectedVariantId,
|
||||
}: {
|
||||
availableForSale: boolean;
|
||||
selectedVariantId: string | undefined;
|
||||
}) {
|
||||
const buttonClasses =
|
||||
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
|
||||
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
|
||||
"relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white";
|
||||
const disabledClasses = "cursor-not-allowed opacity-60 hover:opacity-60";
|
||||
|
||||
if (!availableForSale) {
|
||||
return (
|
||||
@ -46,7 +46,7 @@ function SubmitButton({
|
||||
<button
|
||||
aria-label="Add to cart"
|
||||
className={clsx(buttonClasses, {
|
||||
'hover:opacity-90': true
|
||||
"hover:opacity-90": true,
|
||||
})}
|
||||
>
|
||||
<div className="absolute left-0 ml-4">
|
||||
@ -65,14 +65,14 @@ export function AddToCart({ product }: { product: Product }) {
|
||||
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === state[option.name.toLowerCase()]
|
||||
)
|
||||
(option) => option.value === state[option.name.toLowerCase()],
|
||||
),
|
||||
);
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const addItemAction = formAction.bind(null, selectedVariantId);
|
||||
const finalVariant = variants.find(
|
||||
(variant) => variant.id === selectedVariantId
|
||||
(variant) => variant.id === selectedVariantId,
|
||||
)!;
|
||||
|
||||
return (
|
||||
|
@ -1,28 +1,28 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
Cart,
|
||||
CartItem,
|
||||
Product,
|
||||
ProductVariant
|
||||
} from 'lib/shopify/types';
|
||||
ProductVariant,
|
||||
} from "lib/shopify/types";
|
||||
import React, {
|
||||
createContext,
|
||||
use,
|
||||
useContext,
|
||||
useMemo,
|
||||
useOptimistic
|
||||
} from 'react';
|
||||
useOptimistic,
|
||||
} from "react";
|
||||
|
||||
type UpdateType = 'plus' | 'minus' | 'delete';
|
||||
type UpdateType = "plus" | "minus" | "delete";
|
||||
|
||||
type CartAction =
|
||||
| {
|
||||
type: 'UPDATE_ITEM';
|
||||
type: "UPDATE_ITEM";
|
||||
payload: { merchandiseId: string; updateType: UpdateType };
|
||||
}
|
||||
| {
|
||||
type: 'ADD_ITEM';
|
||||
type: "ADD_ITEM";
|
||||
payload: { variant: ProductVariant; product: Product };
|
||||
};
|
||||
|
||||
@ -38,18 +38,18 @@ function calculateItemCost(quantity: number, price: string): string {
|
||||
|
||||
function updateCartItem(
|
||||
item: CartItem,
|
||||
updateType: UpdateType
|
||||
updateType: UpdateType,
|
||||
): CartItem | null {
|
||||
if (updateType === 'delete') return null;
|
||||
if (updateType === "delete") return null;
|
||||
|
||||
const newQuantity =
|
||||
updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
|
||||
updateType === "plus" ? item.quantity + 1 : item.quantity - 1;
|
||||
if (newQuantity === 0) return null;
|
||||
|
||||
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
|
||||
const newTotalAmount = calculateItemCost(
|
||||
newQuantity,
|
||||
singleItemAmount.toString()
|
||||
singleItemAmount.toString(),
|
||||
);
|
||||
|
||||
return {
|
||||
@ -59,16 +59,16 @@ function updateCartItem(
|
||||
...item.cost,
|
||||
totalAmount: {
|
||||
...item.cost.totalAmount,
|
||||
amount: newTotalAmount
|
||||
}
|
||||
}
|
||||
amount: newTotalAmount,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOrUpdateCartItem(
|
||||
existingItem: CartItem | undefined,
|
||||
variant: ProductVariant,
|
||||
product: Product
|
||||
product: Product,
|
||||
): CartItem {
|
||||
const quantity = existingItem ? existingItem.quantity + 1 : 1;
|
||||
const totalAmount = calculateItemCost(quantity, variant.price.amount);
|
||||
@ -79,8 +79,8 @@ function createOrUpdateCartItem(
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: totalAmount,
|
||||
currencyCode: variant.price.currencyCode
|
||||
}
|
||||
currencyCode: variant.price.currencyCode,
|
||||
},
|
||||
},
|
||||
merchandise: {
|
||||
id: variant.id,
|
||||
@ -90,43 +90,43 @@ function createOrUpdateCartItem(
|
||||
id: product.id,
|
||||
handle: product.handle,
|
||||
title: product.title,
|
||||
featuredImage: product.featuredImage
|
||||
}
|
||||
}
|
||||
featuredImage: product.featuredImage,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function updateCartTotals(
|
||||
lines: CartItem[]
|
||||
): Pick<Cart, 'totalQuantity' | 'cost'> {
|
||||
lines: CartItem[],
|
||||
): Pick<Cart, "totalQuantity" | "cost"> {
|
||||
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = lines.reduce(
|
||||
(sum, item) => sum + Number(item.cost.totalAmount.amount),
|
||||
0
|
||||
0,
|
||||
);
|
||||
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
|
||||
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? "USD";
|
||||
|
||||
return {
|
||||
totalQuantity,
|
||||
cost: {
|
||||
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalTaxAmount: { amount: '0', currencyCode }
|
||||
}
|
||||
totalTaxAmount: { amount: "0", currencyCode },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyCart(): Cart {
|
||||
return {
|
||||
id: undefined,
|
||||
checkoutUrl: '',
|
||||
checkoutUrl: "",
|
||||
totalQuantity: 0,
|
||||
lines: [],
|
||||
cost: {
|
||||
subtotalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
|
||||
}
|
||||
subtotalAmount: { amount: "0", currencyCode: "USD" },
|
||||
totalAmount: { amount: "0", currencyCode: "USD" },
|
||||
totalTaxAmount: { amount: "0", currencyCode: "USD" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -134,13 +134,13 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
const currentCart = state || createEmptyCart();
|
||||
|
||||
switch (action.type) {
|
||||
case 'UPDATE_ITEM': {
|
||||
case "UPDATE_ITEM": {
|
||||
const { merchandiseId, updateType } = action.payload;
|
||||
const updatedLines = currentCart.lines
|
||||
.map((item) =>
|
||||
item.merchandise.id === merchandiseId
|
||||
? updateCartItem(item, updateType)
|
||||
: item
|
||||
: item,
|
||||
)
|
||||
.filter(Boolean) as CartItem[];
|
||||
|
||||
@ -151,38 +151,38 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
totalQuantity: 0,
|
||||
cost: {
|
||||
...currentCart.cost,
|
||||
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
|
||||
}
|
||||
totalAmount: { ...currentCart.cost.totalAmount, amount: "0" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...currentCart,
|
||||
...updateCartTotals(updatedLines),
|
||||
lines: updatedLines
|
||||
lines: updatedLines,
|
||||
};
|
||||
}
|
||||
case 'ADD_ITEM': {
|
||||
case "ADD_ITEM": {
|
||||
const { variant, product } = action.payload;
|
||||
const existingItem = currentCart.lines.find(
|
||||
(item) => item.merchandise.id === variant.id
|
||||
(item) => item.merchandise.id === variant.id,
|
||||
);
|
||||
const updatedItem = createOrUpdateCartItem(
|
||||
existingItem,
|
||||
variant,
|
||||
product
|
||||
product,
|
||||
);
|
||||
|
||||
const updatedLines = existingItem
|
||||
? currentCart.lines.map((item) =>
|
||||
item.merchandise.id === variant.id ? updatedItem : item
|
||||
item.merchandise.id === variant.id ? updatedItem : item,
|
||||
)
|
||||
: [...currentCart.lines, updatedItem];
|
||||
|
||||
return {
|
||||
...currentCart,
|
||||
...updateCartTotals(updatedLines),
|
||||
lines: updatedLines
|
||||
lines: updatedLines,
|
||||
};
|
||||
}
|
||||
default:
|
||||
@ -192,7 +192,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
|
||||
export function CartProvider({
|
||||
children,
|
||||
cartPromise
|
||||
cartPromise,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cartPromise: Promise<Cart | undefined>;
|
||||
@ -207,32 +207,32 @@ export function CartProvider({
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCart must be used within a CartProvider');
|
||||
throw new Error("useCart must be used within a CartProvider");
|
||||
}
|
||||
|
||||
const initialCart = use(context.cartPromise);
|
||||
const [optimisticCart, updateOptimisticCart] = useOptimistic(
|
||||
initialCart,
|
||||
cartReducer
|
||||
cartReducer,
|
||||
);
|
||||
|
||||
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
|
||||
updateOptimisticCart({
|
||||
type: 'UPDATE_ITEM',
|
||||
payload: { merchandiseId, updateType }
|
||||
type: "UPDATE_ITEM",
|
||||
payload: { merchandiseId, updateType },
|
||||
});
|
||||
};
|
||||
|
||||
const addCartItem = (variant: ProductVariant, product: Product) => {
|
||||
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
|
||||
updateOptimisticCart({ type: "ADD_ITEM", payload: { variant, product } });
|
||||
};
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
cart: optimisticCart,
|
||||
updateCartItem,
|
||||
addCartItem
|
||||
addCartItem,
|
||||
}),
|
||||
[optimisticCart]
|
||||
[optimisticCart],
|
||||
);
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useActionState } from 'react';
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { removeItem } from "components/cart/actions";
|
||||
import type { CartItem } from "lib/shopify/types";
|
||||
import { useActionState } from "react";
|
||||
|
||||
export function DeleteItemButton({
|
||||
item,
|
||||
optimisticUpdate
|
||||
optimisticUpdate,
|
||||
}: {
|
||||
item: CartItem;
|
||||
optimisticUpdate: any;
|
||||
@ -19,7 +19,7 @@ export function DeleteItemButton({
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
optimisticUpdate(merchandiseId, 'delete');
|
||||
optimisticUpdate(merchandiseId, "delete");
|
||||
removeItemAction();
|
||||
}}
|
||||
>
|
||||
|
@ -1,26 +1,26 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { updateItemQuantity } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useActionState } from 'react';
|
||||
import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { updateItemQuantity } from "components/cart/actions";
|
||||
import type { CartItem } from "lib/shopify/types";
|
||||
import { useActionState } from "react";
|
||||
|
||||
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||
function SubmitButton({ type }: { type: "plus" | "minus" }) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={
|
||||
type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'
|
||||
type === "plus" ? "Increase item quantity" : "Reduce item quantity"
|
||||
}
|
||||
className={clsx(
|
||||
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
||||
"ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80",
|
||||
{
|
||||
'ml-auto': type === 'minus'
|
||||
}
|
||||
"ml-auto": type === "minus",
|
||||
},
|
||||
)}
|
||||
>
|
||||
{type === 'plus' ? (
|
||||
{type === "plus" ? (
|
||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
) : (
|
||||
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
@ -32,16 +32,16 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||
export function EditItemQuantityButton({
|
||||
item,
|
||||
type,
|
||||
optimisticUpdate
|
||||
optimisticUpdate,
|
||||
}: {
|
||||
item: CartItem;
|
||||
type: 'plus' | 'minus';
|
||||
type: "plus" | "minus";
|
||||
optimisticUpdate: any;
|
||||
}) {
|
||||
const [message, formAction] = useActionState(updateItemQuantity, null);
|
||||
const payload = {
|
||||
merchandiseId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
quantity: type === "plus" ? item.quantity + 1 : item.quantity - 1,
|
||||
};
|
||||
const updateItemQuantityAction = formAction.bind(null, payload);
|
||||
|
||||
|
@ -1,21 +1,21 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ShoppingCartIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import Price from 'components/price';
|
||||
import { DEFAULT_OPTION } from 'lib/constants';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import { createCartAndSetCookie, redirectToCheckout } from './actions';
|
||||
import { useCart } from './cart-context';
|
||||
import { DeleteItemButton } from './delete-item-button';
|
||||
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||
import OpenCart from './open-cart';
|
||||
import clsx from "clsx";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { ShoppingCartIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import LoadingDots from "components/loading-dots";
|
||||
import Price from "components/price";
|
||||
import { DEFAULT_OPTION } from "lib/constants";
|
||||
import { createUrl } from "lib/utils";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { createCartAndSetCookie, redirectToCheckout } from "./actions";
|
||||
import { useCart } from "./cart-context";
|
||||
import { DeleteItemButton } from "./delete-item-button";
|
||||
import { EditItemQuantityButton } from "./edit-item-quantity-button";
|
||||
import OpenCart from "./open-cart";
|
||||
|
||||
type MerchandiseSearchParams = {
|
||||
[key: string]: string;
|
||||
@ -95,8 +95,8 @@ export default function CartModal() {
|
||||
{cart.lines
|
||||
.sort((a, b) =>
|
||||
a.merchandise.product.title.localeCompare(
|
||||
b.merchandise.product.title
|
||||
)
|
||||
b.merchandise.product.title,
|
||||
),
|
||||
)
|
||||
.map((item, i) => {
|
||||
const merchandiseSearchParams =
|
||||
@ -108,12 +108,12 @@ export default function CartModal() {
|
||||
merchandiseSearchParams[name.toLowerCase()] =
|
||||
value;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const merchandiseUrl = createUrl(
|
||||
`/product/${item.merchandise.product.handle}`,
|
||||
new URLSearchParams(merchandiseSearchParams)
|
||||
new URLSearchParams(merchandiseSearchParams),
|
||||
);
|
||||
|
||||
return (
|
||||
@ -233,8 +233,8 @@ function CloseCart({ className }: { className?: string }) {
|
||||
<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
|
||||
"h-6 transition-all ease-in-out hover:scale-110",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@ -250,7 +250,7 @@ function CheckoutButton() {
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
|
||||
{pending ? <LoadingDots className="bg-white" /> : "Proceed to Checkout"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { ShoppingCartIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function OpenCart({
|
||||
className,
|
||||
quantity
|
||||
quantity,
|
||||
}: {
|
||||
className?: string;
|
||||
quantity?: number;
|
||||
@ -11,7 +11,10 @@ export default function OpenCart({
|
||||
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)}
|
||||
className={clsx(
|
||||
"h-4 transition-all ease-in-out hover:scale-110",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
{quantity ? (
|
||||
|
@ -1,16 +1,22 @@
|
||||
import clsx from 'clsx';
|
||||
import clsx from "clsx";
|
||||
|
||||
function Grid(props: React.ComponentProps<'ul'>) {
|
||||
function Grid(props: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
|
||||
<ul
|
||||
{...props}
|
||||
className={clsx("grid grid-flow-row gap-4", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function GridItem(props: React.ComponentProps<'li'>) {
|
||||
function GridItem(props: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
|
||||
<li
|
||||
{...props}
|
||||
className={clsx("aspect-square transition-opacity", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
|
@ -1,20 +1,24 @@
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import type { Product } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
import { GridTileImage } from "components/grid/tile";
|
||||
import { getCollectionProducts } from "lib/shopify";
|
||||
import type { Product } from "lib/shopify/types";
|
||||
import Link from "next/link";
|
||||
|
||||
function ThreeItemGridItem({
|
||||
item,
|
||||
size,
|
||||
priority
|
||||
priority,
|
||||
}: {
|
||||
item: Product;
|
||||
size: 'full' | 'half';
|
||||
size: "full" | "half";
|
||||
priority?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||
className={
|
||||
size === "full"
|
||||
? "md:col-span-4 md:row-span-2"
|
||||
: "md:col-span-2 md:row-span-1"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="relative block aspect-square h-full w-full"
|
||||
@ -25,15 +29,17 @@ function ThreeItemGridItem({
|
||||
src={item.featuredImage.url}
|
||||
fill
|
||||
sizes={
|
||||
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
|
||||
size === "full"
|
||||
? "(min-width: 768px) 66vw, 100vw"
|
||||
: "(min-width: 768px) 33vw, 100vw"
|
||||
}
|
||||
priority={priority}
|
||||
alt={item.title}
|
||||
label={{
|
||||
position: size === 'full' ? 'center' : 'bottom',
|
||||
position: size === "full" ? "center" : "bottom",
|
||||
title: item.title as string,
|
||||
amount: item.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: item.priceRange.maxVariantPrice.currencyCode
|
||||
currencyCode: item.priceRange.maxVariantPrice.currencyCode,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
@ -44,7 +50,7 @@ function ThreeItemGridItem({
|
||||
export async function ThreeItemGrid() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const homepageItems = await getCollectionProducts({
|
||||
collection: 'hidden-homepage-featured-items'
|
||||
collection: "hidden-homepage-featured-items",
|
||||
});
|
||||
|
||||
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import Label from '../label';
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import Label from "../label";
|
||||
|
||||
export function GridTileImage({
|
||||
isInteractive = true,
|
||||
@ -14,24 +14,25 @@ export function GridTileImage({
|
||||
title: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
position?: 'bottom' | 'center';
|
||||
position?: "bottom" | "center";
|
||||
};
|
||||
} & React.ComponentProps<typeof Image>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
|
||||
"group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black",
|
||||
{
|
||||
relative: label,
|
||||
'border-2 border-blue-600': active,
|
||||
'border-neutral-200 dark:border-neutral-800': !active
|
||||
}
|
||||
"border-2 border-blue-600": active,
|
||||
"border-neutral-200 dark:border-neutral-800": !active,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{props.src ? (
|
||||
<Image
|
||||
className={clsx('relative h-full w-full object-contain', {
|
||||
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
|
||||
className={clsx("relative h-full w-full object-contain", {
|
||||
"transition duration-300 ease-in-out group-hover:scale-105":
|
||||
isInteractive,
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -1,13 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
|
||||
export default function LogoIcon(props: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label={`${process.env.SITE_NAME} logo`}
|
||||
viewBox="0 0 32 28"
|
||||
{...props}
|
||||
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)}
|
||||
className={clsx("h-4 w-4 fill-black dark:fill-white", props.className)}
|
||||
>
|
||||
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
|
||||
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
|
||||
|
@ -1,25 +1,30 @@
|
||||
import clsx from 'clsx';
|
||||
import Price from './price';
|
||||
import clsx from "clsx";
|
||||
import Price from "./price";
|
||||
|
||||
const Label = ({
|
||||
title,
|
||||
amount,
|
||||
currencyCode,
|
||||
position = 'bottom'
|
||||
position = "bottom",
|
||||
}: {
|
||||
title: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
position?: 'bottom' | 'center';
|
||||
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'
|
||||
})}
|
||||
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 grow pl-2 leading-none tracking-tight">{title}</h3>
|
||||
<h3 className="mr-4 line-clamp-2 grow pl-2 leading-none tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
<Price
|
||||
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||
amount={amount}
|
||||
|
@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import clsx from "clsx";
|
||||
import { Menu } from "lib/shopify/types";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function FooterMenuItem({ item }: { item: Menu }) {
|
||||
const pathname = usePathname();
|
||||
@ -19,10 +19,10 @@ export function FooterMenuItem({ item }: { item: Menu }) {
|
||||
<Link
|
||||
href={item.path}
|
||||
className={clsx(
|
||||
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline md:inline-block md:text-sm dark:hover:text-neutral-300',
|
||||
"block p-2 text-lg underline-offset-4 hover:text-black hover:underline md:inline-block md:text-sm dark:hover:text-neutral-300",
|
||||
{
|
||||
'text-black dark:text-neutral-300': active
|
||||
}
|
||||
"text-black dark:text-neutral-300": active,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
|
@ -1,24 +1,28 @@
|
||||
import Link from 'next/link';
|
||||
import Link from "next/link";
|
||||
|
||||
import FooterMenu from 'components/layout/footer-menu';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { Suspense } from 'react';
|
||||
import FooterMenu from "components/layout/footer-menu";
|
||||
import LogoSquare from "components/logo-square";
|
||||
import { getMenu } from "lib/shopify";
|
||||
import { Suspense } from "react";
|
||||
|
||||
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-sm bg-neutral-200 dark:bg-neutral-700';
|
||||
const menu = await getMenu('next-js-frontend-footer-menu');
|
||||
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : "");
|
||||
const skeleton =
|
||||
"w-full h-6 animate-pulse rounded-sm 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="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 md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
|
||||
<div>
|
||||
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/">
|
||||
<Link
|
||||
className="flex items-center gap-2 text-black md:pt-1 dark:text-white"
|
||||
href="/"
|
||||
>
|
||||
<LogoSquare size="sm" />
|
||||
<span className="uppercase">{SITE_NAME}</span>
|
||||
</Link>
|
||||
@ -53,7 +57,10 @@ export default async function Footer() {
|
||||
<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 min-[1320px]:px-0">
|
||||
<p>
|
||||
© {copyrightDate} {copyrightName}
|
||||
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
|
||||
{copyrightName.length && !copyrightName.endsWith(".")
|
||||
? "."
|
||||
: ""}{" "}
|
||||
All rights reserved.
|
||||
</p>
|
||||
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
|
||||
<p>
|
||||
|
@ -1,16 +1,16 @@
|
||||
import CartModal from 'components/cart/modal';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
import MobileMenu from './mobile-menu';
|
||||
import Search, { SearchSkeleton } from './search';
|
||||
import CartModal from "components/cart/modal";
|
||||
import LogoSquare from "components/logo-square";
|
||||
import { getMenu } from "lib/shopify";
|
||||
import { Menu } from "lib/shopify/types";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import MobileMenu from "./mobile-menu";
|
||||
import Search, { SearchSkeleton } from "./search";
|
||||
|
||||
const { SITE_NAME } = process.env;
|
||||
|
||||
export async function Navbar() {
|
||||
const menu = await getMenu('next-js-frontend-header-menu');
|
||||
const menu = await getMenu("next-js-frontend-header-menu");
|
||||
|
||||
return (
|
||||
<nav className="relative flex items-center justify-between p-4 lg:px-6">
|
||||
|
@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { Fragment, Suspense, useEffect, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import Search, { SearchSkeleton } from './search';
|
||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { Menu } from "lib/shopify/types";
|
||||
import Search, { SearchSkeleton } from "./search";
|
||||
|
||||
export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
const pathname = usePathname();
|
||||
@ -22,8 +22,8 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -83,7 +83,11 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
|
||||
key={item.title}
|
||||
>
|
||||
<Link href={item.path} prefetch={true} onClick={closeMobileMenu}>
|
||||
<Link
|
||||
href={item.path}
|
||||
prefetch={true}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
|
@ -1,21 +1,24 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import Form from 'next/form';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import Form from "next/form";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function Search() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||
<Form
|
||||
action="/search"
|
||||
className="w-max-[550px] relative w-full lg:w-80 xl:w-full"
|
||||
>
|
||||
<input
|
||||
key={searchParams?.get('q')}
|
||||
key={searchParams?.get("q")}
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search for products..."
|
||||
autoComplete="off"
|
||||
defaultValue={searchParams?.get('q') || ''}
|
||||
defaultValue={searchParams?.get("q") || ""}
|
||||
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm 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">
|
||||
|
@ -1,9 +1,13 @@
|
||||
import Grid from 'components/grid';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
import Grid from "components/grid";
|
||||
import { GridTileImage } from "components/grid/tile";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ProductGridItems({ products }: { products: Product[] }) {
|
||||
export default function ProductGridItems({
|
||||
products,
|
||||
}: {
|
||||
products: Product[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
@ -18,7 +22,7 @@ export default function ProductGridItems({ products }: { products: Product[] })
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode,
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
|
@ -1,17 +1,17 @@
|
||||
import clsx from 'clsx';
|
||||
import { Suspense } from 'react';
|
||||
import clsx from "clsx";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getCollections } from 'lib/shopify';
|
||||
import FilterList from './filter';
|
||||
import { getCollections } from "lib/shopify";
|
||||
import FilterList from "./filter";
|
||||
|
||||
async function CollectionList() {
|
||||
const collections = await getCollections();
|
||||
return <FilterList list={collections} title="Collections" />;
|
||||
}
|
||||
|
||||
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded-sm';
|
||||
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
|
||||
const items = 'bg-neutral-400 dark:bg-neutral-700';
|
||||
const skeleton = "mb-3 h-4 w-5/6 animate-pulse rounded-sm";
|
||||
const activeAndTitles = "bg-neutral-800 dark:bg-neutral-300";
|
||||
const items = "bg-neutral-400 dark:bg-neutral-700";
|
||||
|
||||
export default function Collections() {
|
||||
return (
|
||||
|
@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import type { ListItem } from '.';
|
||||
import { FilterItem } from './item';
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import type { ListItem } from ".";
|
||||
import { FilterItem } from "./item";
|
||||
|
||||
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [active, setActive] = useState('');
|
||||
const [active, setActive] = useState("");
|
||||
const [openSelect, setOpenSelect] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -21,15 +21,15 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
return () => window.removeEventListener('click', handleClickOutside);
|
||||
window.addEventListener("click", handleClickOutside);
|
||||
return () => window.removeEventListener("click", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
list.forEach((listItem: ListItem) => {
|
||||
if (
|
||||
('path' in listItem && pathname === listItem.path) ||
|
||||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
|
||||
("path" in listItem && pathname === listItem.path) ||
|
||||
("slug" in listItem && searchParams.get("sort") === listItem.slug)
|
||||
) {
|
||||
setActive(listItem.title);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { SortFilterItem } from 'lib/constants';
|
||||
import { Suspense } from 'react';
|
||||
import FilterItemDropdown from './dropdown';
|
||||
import { FilterItem } from './item';
|
||||
import { SortFilterItem } from "lib/constants";
|
||||
import { Suspense } from "react";
|
||||
import FilterItemDropdown from "./dropdown";
|
||||
import { FilterItem } from "./item";
|
||||
|
||||
export type ListItem = SortFilterItem | PathFilterItem;
|
||||
export type PathFilterItem = { title: string; path: string };
|
||||
@ -16,7 +16,13 @@ function FilterItemList({ list }: { list: ListItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
|
||||
export default function FilterList({
|
||||
list,
|
||||
title,
|
||||
}: {
|
||||
list: ListItem[];
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<nav>
|
||||
|
@ -1,30 +1,30 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import clsx from 'clsx';
|
||||
import type { SortFilterItem } from 'lib/constants';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import type { ListItem, PathFilterItem } from '.';
|
||||
import clsx from "clsx";
|
||||
import type { SortFilterItem } from "lib/constants";
|
||||
import { createUrl } from "lib/utils";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import type { ListItem, PathFilterItem } from ".";
|
||||
|
||||
function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const active = pathname === item.path;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
const DynamicTag = active ? 'p' : Link;
|
||||
const DynamicTag = active ? "p" : Link;
|
||||
|
||||
newParams.delete('q');
|
||||
newParams.delete("q");
|
||||
|
||||
return (
|
||||
<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',
|
||||
"w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100",
|
||||
{
|
||||
'underline underline-offset-4': active
|
||||
}
|
||||
"underline underline-offset-4": active,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
@ -36,24 +36,27 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||
function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const active = searchParams.get('sort') === item.slug;
|
||||
const q = searchParams.get('q');
|
||||
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 })
|
||||
})
|
||||
...(item.slug && item.slug.length && { sort: item.slug }),
|
||||
}),
|
||||
);
|
||||
const DynamicTag = active ? 'p' : Link;
|
||||
const DynamicTag = active ? "p" : Link;
|
||||
|
||||
return (
|
||||
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}>
|
||||
<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:underline hover:underline-offset-4', {
|
||||
'underline underline-offset-4': active
|
||||
className={clsx("w-full hover:underline hover:underline-offset-4", {
|
||||
"underline underline-offset-4": active,
|
||||
})}
|
||||
>
|
||||
{item.title}
|
||||
@ -63,5 +66,9 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||
}
|
||||
|
||||
export function FilterItem({ item }: { item: ListItem }) {
|
||||
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
|
||||
return "path" in item ? (
|
||||
<PathFilterItem item={item} />
|
||||
) : (
|
||||
<SortFilterItem item={item} />
|
||||
);
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import clsx from "clsx";
|
||||
|
||||
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
|
||||
const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md";
|
||||
|
||||
const LoadingDots = ({ className }: { className: string }) => {
|
||||
return (
|
||||
<span className="mx-2 inline-flex items-center">
|
||||
<span className={clsx(dots, className)} />
|
||||
<span className={clsx(dots, 'animation-delay-[200ms]', className)} />
|
||||
<span className={clsx(dots, 'animation-delay-[400ms]', className)} />
|
||||
<span className={clsx(dots, "animation-delay-[200ms]", className)} />
|
||||
<span className={clsx(dots, "animation-delay-[400ms]", className)} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
@ -1,21 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
import LogoIcon from './icons/logo';
|
||||
import clsx from "clsx";
|
||||
import LogoIcon from "./icons/logo";
|
||||
|
||||
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
|
||||
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',
|
||||
"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'
|
||||
}
|
||||
"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'
|
||||
"h-[16px] w-[16px]": !size,
|
||||
"h-[10px] w-[10px]": size === "sm",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,23 +1,23 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import LogoIcon from './icons/logo';
|
||||
import { join } from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { ImageResponse } from "next/og";
|
||||
import LogoIcon from "./icons/logo";
|
||||
import { join } from "path";
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
export type Props = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default async function OpengraphImage(
|
||||
props?: Props
|
||||
props?: Props,
|
||||
): Promise<ImageResponse> {
|
||||
const { title } = {
|
||||
...{
|
||||
title: process.env.SITE_NAME
|
||||
title: process.env.SITE_NAME,
|
||||
},
|
||||
...props
|
||||
...props,
|
||||
};
|
||||
|
||||
const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf'));
|
||||
const file = await readFile(join(process.cwd(), "./fonts/Inter-Bold.ttf"));
|
||||
const font = Uint8Array.from(file).buffer;
|
||||
|
||||
return new ImageResponse(
|
||||
@ -34,12 +34,12 @@ export default async function OpengraphImage(
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
name: "Inter",
|
||||
data: font,
|
||||
style: 'normal',
|
||||
weight: 700
|
||||
}
|
||||
]
|
||||
}
|
||||
style: "normal",
|
||||
weight: 700,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1,23 +1,25 @@
|
||||
import clsx from 'clsx';
|
||||
import clsx from "clsx";
|
||||
|
||||
const Price = ({
|
||||
amount,
|
||||
className,
|
||||
currencyCode = 'USD',
|
||||
currencyCodeClassName
|
||||
currencyCode = "USD",
|
||||
currencyCodeClassName,
|
||||
}: {
|
||||
amount: string;
|
||||
className?: string;
|
||||
currencyCode: string;
|
||||
currencyCodeClassName?: string;
|
||||
} & React.ComponentProps<'p'>) => (
|
||||
} & React.ComponentProps<"p">) => (
|
||||
<p suppressHydrationWarning={true} className={className}>
|
||||
{`${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
currencyDisplay: 'narrowSymbol'
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(parseFloat(amount))}`}
|
||||
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
|
||||
<span
|
||||
className={clsx("ml-1 inline", currencyCodeClassName)}
|
||||
>{`${currencyCode}`}</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
|
@ -1,20 +1,25 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||
import Image from 'next/image';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import { GridTileImage } from "components/grid/tile";
|
||||
import { useProduct, useUpdateURL } from "components/product/product-context";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||
export function Gallery({
|
||||
images,
|
||||
}: {
|
||||
images: { src: string; altText: string }[];
|
||||
}) {
|
||||
const { state, updateImage } = useProduct();
|
||||
const updateURL = useUpdateURL();
|
||||
const imageIndex = state.image ? parseInt(state.image) : 0;
|
||||
|
||||
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
|
||||
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||
const previousImageIndex =
|
||||
imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||
|
||||
const buttonClassName =
|
||||
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
|
||||
"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 (
|
||||
<form>
|
||||
|
@ -1,7 +1,12 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
} from "react";
|
||||
|
||||
type ProductState = {
|
||||
[key: string]: string;
|
||||
@ -32,8 +37,8 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
|
||||
getInitialState(),
|
||||
(prevState: ProductState, update: ProductState) => ({
|
||||
...prevState,
|
||||
...update
|
||||
})
|
||||
...update,
|
||||
}),
|
||||
);
|
||||
|
||||
const updateOption = (name: string, value: string) => {
|
||||
@ -52,18 +57,20 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
|
||||
() => ({
|
||||
state,
|
||||
updateOption,
|
||||
updateImage
|
||||
updateImage,
|
||||
}),
|
||||
[state]
|
||||
[state],
|
||||
);
|
||||
|
||||
return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
|
||||
return (
|
||||
<ProductContext.Provider value={value}>{children}</ProductContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProduct() {
|
||||
const context = useContext(ProductContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useProduct must be used within a ProductProvider');
|
||||
throw new Error("useProduct must be used within a ProductProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Price from 'components/price';
|
||||
import Prose from 'components/prose';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { VariantSelector } from './variant-selector';
|
||||
import { AddToCart } from "components/cart/add-to-cart";
|
||||
import Price from "components/price";
|
||||
import Prose from "components/prose";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { VariantSelector } from "./variant-selector";
|
||||
|
||||
export function ProductDescription({ product }: { product: Product }) {
|
||||
return (
|
||||
|
@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||
import clsx from "clsx";
|
||||
import { useProduct, useUpdateURL } from "components/product/product-context";
|
||||
import { ProductOption, ProductVariant } from "lib/shopify/types";
|
||||
|
||||
type Combination = {
|
||||
id: string;
|
||||
@ -12,7 +12,7 @@ type Combination = {
|
||||
|
||||
export function VariantSelector({
|
||||
options,
|
||||
variants
|
||||
variants,
|
||||
}: {
|
||||
options: ProductOption[];
|
||||
variants: ProductVariant[];
|
||||
@ -20,7 +20,8 @@ export function VariantSelector({
|
||||
const { state, updateOption } = useProduct();
|
||||
const updateURL = useUpdateURL();
|
||||
const hasNoOptionsOrJustOneOption =
|
||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||
!options.length ||
|
||||
(options.length === 1 && options[0]?.values.length === 1);
|
||||
|
||||
if (hasNoOptionsOrJustOneOption) {
|
||||
return null;
|
||||
@ -30,9 +31,12 @@ export function VariantSelector({
|
||||
id: variant.id,
|
||||
availableForSale: variant.availableForSale,
|
||||
...variant.selectedOptions.reduce(
|
||||
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||
{}
|
||||
)
|
||||
(accumulator, option) => ({
|
||||
...accumulator,
|
||||
[option.name.toLowerCase()]: option.value,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
}));
|
||||
|
||||
return options.map((option) => (
|
||||
@ -47,15 +51,19 @@ export function VariantSelector({
|
||||
const optionParams = { ...state, [optionNameLowerCase]: value };
|
||||
|
||||
// Filter out invalid options and check if the option combination is available for sale.
|
||||
const filtered = Object.entries(optionParams).filter(([key, value]) =>
|
||||
options.find(
|
||||
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
||||
)
|
||||
const filtered = Object.entries(optionParams).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
|
||||
)
|
||||
([key, value]) =>
|
||||
combination[key] === value && combination.availableForSale,
|
||||
),
|
||||
);
|
||||
|
||||
// The option is active if it's in the selected options.
|
||||
@ -70,16 +78,16 @@ export function VariantSelector({
|
||||
key={value}
|
||||
aria-disabled={!isAvailableForSale}
|
||||
disabled={!isAvailableForSale}
|
||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||
title={`${option.name} ${value}${!isAvailableForSale ? " (Out of Stock)" : ""}`}
|
||||
className={clsx(
|
||||
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||
"flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900",
|
||||
{
|
||||
'cursor-default ring-2 ring-blue-600': isActive,
|
||||
'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600':
|
||||
"cursor-default ring-2 ring-blue-600": isActive,
|
||||
"ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600":
|
||||
!isActive && isAvailableForSale,
|
||||
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:before:bg-neutral-700':
|
||||
!isAvailableForSale
|
||||
}
|
||||
"relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:before:bg-neutral-700":
|
||||
!isAvailableForSale,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import clsx from "clsx";
|
||||
|
||||
const Prose = ({ html, className }: { html: string; className?: string }) => {
|
||||
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 prose-a:hover: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
|
||||
"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 prose-a:hover: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 }}
|
||||
/>
|
||||
|
@ -1,22 +1,23 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function WelcomeToast() {
|
||||
useEffect(() => {
|
||||
// ignore if screen height is too small
|
||||
if (window.innerHeight < 650) return;
|
||||
if (!document.cookie.includes('welcome-toast=2')) {
|
||||
toast('🛍️ Welcome to Next.js Commerce!', {
|
||||
id: 'welcome-toast',
|
||||
if (!document.cookie.includes("welcome-toast=2")) {
|
||||
toast("🛍️ Welcome to Next.js Commerce!", {
|
||||
id: "welcome-toast",
|
||||
duration: Infinity,
|
||||
onDismiss: () => {
|
||||
document.cookie = 'welcome-toast=2; max-age=31536000; path=/';
|
||||
document.cookie = "welcome-toast=2; max-age=31536000; path=/";
|
||||
},
|
||||
description: (
|
||||
<>
|
||||
This is a high-performance, SSR storefront powered by Shopify, Next.js, and Vercel.{' '}
|
||||
This is a high-performance, SSR storefront powered by Shopify,
|
||||
Next.js, and Vercel.{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates/next.js/nextjs-commerce"
|
||||
className="text-blue-600 hover:underline"
|
||||
@ -26,7 +27,7 @@ export function WelcomeToast() {
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
@ -1,31 +1,51 @@
|
||||
export type SortFilterItem = {
|
||||
title: string;
|
||||
slug: string | null;
|
||||
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
|
||||
sortKey: "RELEVANCE" | "BEST_SELLING" | "CREATED_AT" | "PRICE";
|
||||
reverse: boolean;
|
||||
};
|
||||
|
||||
export const defaultSort: SortFilterItem = {
|
||||
title: 'Relevance',
|
||||
title: "Relevance",
|
||||
slug: null,
|
||||
sortKey: 'RELEVANCE',
|
||||
reverse: false
|
||||
sortKey: "RELEVANCE",
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
export const sorting: SortFilterItem[] = [
|
||||
defaultSort,
|
||||
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
|
||||
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
|
||||
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
|
||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
||||
{
|
||||
title: "Trending",
|
||||
slug: "trending-desc",
|
||||
sortKey: "BEST_SELLING",
|
||||
reverse: false,
|
||||
}, // asc
|
||||
{
|
||||
title: "Latest arrivals",
|
||||
slug: "latest-desc",
|
||||
sortKey: "CREATED_AT",
|
||||
reverse: true,
|
||||
},
|
||||
{
|
||||
title: "Price: Low to high",
|
||||
slug: "price-asc",
|
||||
sortKey: "PRICE",
|
||||
reverse: false,
|
||||
}, // asc
|
||||
{
|
||||
title: "Price: High to low",
|
||||
slug: "price-desc",
|
||||
sortKey: "PRICE",
|
||||
reverse: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const TAGS = {
|
||||
collections: 'collections',
|
||||
products: 'products',
|
||||
cart: 'cart'
|
||||
collections: "collections",
|
||||
products: "products",
|
||||
cart: "cart",
|
||||
};
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
||||
export const HIDDEN_PRODUCT_TAG = "nextjs-frontend-hidden";
|
||||
export const DEFAULT_OPTION = "Default Title";
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = "/api/2023-01/graphql.json";
|
||||
|
@ -1,4 +1,4 @@
|
||||
import productFragment from './product';
|
||||
import productFragment from "./product";
|
||||
|
||||
const cartFragment = /* GraphQL */ `
|
||||
fragment cart on Cart {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import imageFragment from './image';
|
||||
import seoFragment from './seo';
|
||||
import imageFragment from "./image";
|
||||
import seoFragment from "./seo";
|
||||
|
||||
const productFragment = /* GraphQL */ `
|
||||
fragment product on Product {
|
||||
|
@ -1,36 +1,36 @@
|
||||
import {
|
||||
HIDDEN_PRODUCT_TAG,
|
||||
SHOPIFY_GRAPHQL_API_ENDPOINT,
|
||||
TAGS
|
||||
} from 'lib/constants';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
import { ensureStartsWith } from 'lib/utils';
|
||||
TAGS,
|
||||
} from "lib/constants";
|
||||
import { isShopifyError } from "lib/type-guards";
|
||||
import { ensureStartsWith } from "lib/utils";
|
||||
import {
|
||||
revalidateTag,
|
||||
unstable_cacheTag as cacheTag,
|
||||
unstable_cacheLife as cacheLife
|
||||
} from 'next/cache';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
unstable_cacheLife as cacheLife,
|
||||
} from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
addToCartMutation,
|
||||
createCartMutation,
|
||||
editCartItemsMutation,
|
||||
removeFromCartMutation
|
||||
} from './mutations/cart';
|
||||
import { getCartQuery } from './queries/cart';
|
||||
removeFromCartMutation,
|
||||
} from "./mutations/cart";
|
||||
import { getCartQuery } from "./queries/cart";
|
||||
import {
|
||||
getCollectionProductsQuery,
|
||||
getCollectionQuery,
|
||||
getCollectionsQuery
|
||||
} from './queries/collection';
|
||||
import { getMenuQuery } from './queries/menu';
|
||||
import { getPageQuery, getPagesQuery } from './queries/page';
|
||||
getCollectionsQuery,
|
||||
} from "./queries/collection";
|
||||
import { getMenuQuery } from "./queries/menu";
|
||||
import { getPageQuery, getPagesQuery } from "./queries/page";
|
||||
import {
|
||||
getProductQuery,
|
||||
getProductRecommendationsQuery,
|
||||
getProductsQuery
|
||||
} from './queries/product';
|
||||
getProductsQuery,
|
||||
} from "./queries/product";
|
||||
import {
|
||||
Cart,
|
||||
Collection,
|
||||
@ -55,23 +55,23 @@ import {
|
||||
ShopifyProductRecommendationsOperation,
|
||||
ShopifyProductsOperation,
|
||||
ShopifyRemoveFromCartOperation,
|
||||
ShopifyUpdateCartOperation
|
||||
} from './types';
|
||||
ShopifyUpdateCartOperation,
|
||||
} from "./types";
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
|
||||
: '';
|
||||
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, "https://")
|
||||
: "";
|
||||
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||
|
||||
type ExtractVariables<T> = T extends { variables: object }
|
||||
? T['variables']
|
||||
? T["variables"]
|
||||
: never;
|
||||
|
||||
export async function shopifyFetch<T>({
|
||||
headers,
|
||||
query,
|
||||
variables
|
||||
variables,
|
||||
}: {
|
||||
headers?: HeadersInit;
|
||||
query: string;
|
||||
@ -79,16 +79,16 @@ export async function shopifyFetch<T>({
|
||||
}): Promise<{ status: number; body: T } | never> {
|
||||
try {
|
||||
const result = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shopify-Storefront-Access-Token': key,
|
||||
...headers
|
||||
"Content-Type": "application/json",
|
||||
"X-Shopify-Storefront-Access-Token": key,
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(query && { query }),
|
||||
...(variables && { variables })
|
||||
})
|
||||
...(variables && { variables }),
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await result.json();
|
||||
@ -99,21 +99,21 @@ export async function shopifyFetch<T>({
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
body,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
throw {
|
||||
cause: e.cause?.toString() || 'unknown',
|
||||
cause: e.cause?.toString() || "unknown",
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query
|
||||
query,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -125,19 +125,19 @@ const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => {
|
||||
const reshapeCart = (cart: ShopifyCart): Cart => {
|
||||
if (!cart.cost?.totalTaxAmount) {
|
||||
cart.cost.totalTaxAmount = {
|
||||
amount: '0.0',
|
||||
currencyCode: cart.cost.totalAmount.currencyCode
|
||||
amount: "0.0",
|
||||
currencyCode: cart.cost.totalAmount.currencyCode,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cart,
|
||||
lines: removeEdgesAndNodes(cart.lines)
|
||||
lines: removeEdgesAndNodes(cart.lines),
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeCollection = (
|
||||
collection: ShopifyCollection
|
||||
collection: ShopifyCollection,
|
||||
): Collection | undefined => {
|
||||
if (!collection) {
|
||||
return undefined;
|
||||
@ -145,7 +145,7 @@ const reshapeCollection = (
|
||||
|
||||
return {
|
||||
...collection,
|
||||
path: `/search/${collection.handle}`
|
||||
path: `/search/${collection.handle}`,
|
||||
};
|
||||
};
|
||||
|
||||
@ -172,14 +172,14 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
|
||||
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
|
||||
return {
|
||||
...image,
|
||||
altText: image.altText || `${productTitle} - ${filename}`
|
||||
altText: image.altText || `${productTitle} - ${filename}`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const reshapeProduct = (
|
||||
product: ShopifyProduct,
|
||||
filterHiddenProducts: boolean = true
|
||||
filterHiddenProducts: boolean = true,
|
||||
) => {
|
||||
if (
|
||||
!product ||
|
||||
@ -193,7 +193,7 @@ const reshapeProduct = (
|
||||
return {
|
||||
...rest,
|
||||
images: reshapeImages(images, product.title),
|
||||
variants: removeEdgesAndNodes(variants)
|
||||
variants: removeEdgesAndNodes(variants),
|
||||
};
|
||||
};
|
||||
|
||||
@ -215,56 +215,56 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
|
||||
|
||||
export async function createCart(): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyCreateCartOperation>({
|
||||
query: createCartMutation
|
||||
query: createCartMutation,
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartCreate.cart);
|
||||
}
|
||||
|
||||
export async function addToCart(
|
||||
lines: { merchandiseId: string; quantity: number }[]
|
||||
lines: { merchandiseId: string; quantity: number }[],
|
||||
): Promise<Cart> {
|
||||
const cartId = (await cookies()).get('cartId')?.value!;
|
||||
const cartId = (await cookies()).get("cartId")?.value!;
|
||||
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
||||
query: addToCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
}
|
||||
lines,
|
||||
},
|
||||
});
|
||||
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
||||
}
|
||||
|
||||
export async function removeFromCart(lineIds: string[]): Promise<Cart> {
|
||||
const cartId = (await cookies()).get('cartId')?.value!;
|
||||
const cartId = (await cookies()).get("cartId")?.value!;
|
||||
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
|
||||
query: removeFromCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lineIds
|
||||
}
|
||||
lineIds,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesRemove.cart);
|
||||
}
|
||||
|
||||
export async function updateCart(
|
||||
lines: { id: string; merchandiseId: string; quantity: number }[]
|
||||
lines: { id: string; merchandiseId: string; quantity: number }[],
|
||||
): Promise<Cart> {
|
||||
const cartId = (await cookies()).get('cartId')?.value!;
|
||||
const cartId = (await cookies()).get("cartId")?.value!;
|
||||
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
|
||||
query: editCartItemsMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
}
|
||||
lines,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesUpdate.cart);
|
||||
}
|
||||
|
||||
export async function getCart(): Promise<Cart | undefined> {
|
||||
const cartId = (await cookies()).get('cartId')?.value;
|
||||
const cartId = (await cookies()).get("cartId")?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return undefined;
|
||||
@ -272,7 +272,7 @@ export async function getCart(): Promise<Cart | undefined> {
|
||||
|
||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||
query: getCartQuery,
|
||||
variables: { cartId }
|
||||
variables: { cartId },
|
||||
});
|
||||
|
||||
// Old carts becomes `null` when you checkout.
|
||||
@ -284,17 +284,17 @@ export async function getCart(): Promise<Cart | undefined> {
|
||||
}
|
||||
|
||||
export async function getCollection(
|
||||
handle: string
|
||||
handle: string,
|
||||
): Promise<Collection | undefined> {
|
||||
'use cache';
|
||||
"use cache";
|
||||
cacheTag(TAGS.collections);
|
||||
cacheLife('days');
|
||||
cacheLife("days");
|
||||
|
||||
const res = await shopifyFetch<ShopifyCollectionOperation>({
|
||||
query: getCollectionQuery,
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
handle,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeCollection(res.body.data.collection);
|
||||
@ -303,23 +303,23 @@ export async function getCollection(
|
||||
export async function getCollectionProducts({
|
||||
collection,
|
||||
reverse,
|
||||
sortKey
|
||||
sortKey,
|
||||
}: {
|
||||
collection: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
'use cache';
|
||||
"use cache";
|
||||
cacheTag(TAGS.collections, TAGS.products);
|
||||
cacheLife('days');
|
||||
cacheLife("days");
|
||||
|
||||
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
|
||||
query: getCollectionProductsQuery,
|
||||
variables: {
|
||||
handle: collection,
|
||||
reverse,
|
||||
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
|
||||
}
|
||||
sortKey: sortKey === "CREATED_AT" ? "CREATED" : sortKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.body.data.collection) {
|
||||
@ -328,60 +328,60 @@ export async function getCollectionProducts({
|
||||
}
|
||||
|
||||
return reshapeProducts(
|
||||
removeEdgesAndNodes(res.body.data.collection.products)
|
||||
removeEdgesAndNodes(res.body.data.collection.products),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCollections(): Promise<Collection[]> {
|
||||
'use cache';
|
||||
"use cache";
|
||||
cacheTag(TAGS.collections);
|
||||
cacheLife('days');
|
||||
cacheLife("days");
|
||||
|
||||
const res = await shopifyFetch<ShopifyCollectionsOperation>({
|
||||
query: getCollectionsQuery
|
||||
query: getCollectionsQuery,
|
||||
});
|
||||
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
|
||||
const collections = [
|
||||
{
|
||||
handle: '',
|
||||
title: 'All',
|
||||
description: 'All products',
|
||||
handle: "",
|
||||
title: "All",
|
||||
description: "All products",
|
||||
seo: {
|
||||
title: 'All',
|
||||
description: 'All products'
|
||||
title: "All",
|
||||
description: "All products",
|
||||
},
|
||||
path: '/search',
|
||||
updatedAt: new Date().toISOString()
|
||||
path: "/search",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
// Filter out the `hidden` collections.
|
||||
// Collections that start with `hidden-*` need to be hidden on the search page.
|
||||
...reshapeCollections(shopifyCollections).filter(
|
||||
(collection) => !collection.handle.startsWith('hidden')
|
||||
)
|
||||
(collection) => !collection.handle.startsWith("hidden"),
|
||||
),
|
||||
];
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
export async function getMenu(handle: string): Promise<Menu[]> {
|
||||
'use cache';
|
||||
"use cache";
|
||||
cacheTag(TAGS.collections);
|
||||
cacheLife('days');
|
||||
cacheLife("days");
|
||||
|
||||
const res = await shopifyFetch<ShopifyMenuOperation>({
|
||||
query: getMenuQuery,
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
handle,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
|
||||
title: item.title,
|
||||
path: item.url
|
||||
.replace(domain, '')
|
||||
.replace('/collections', '/search')
|
||||
.replace('/pages', '')
|
||||
.replace(domain, "")
|
||||
.replace("/collections", "/search")
|
||||
.replace("/pages", ""),
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
@ -389,7 +389,7 @@ export async function getMenu(handle: string): Promise<Menu[]> {
|
||||
export async function getPage(handle: string): Promise<Page> {
|
||||
const res = await shopifyFetch<ShopifyPageOperation>({
|
||||
query: getPageQuery,
|
||||
variables: { handle }
|
||||
variables: { handle },
|
||||
});
|
||||
|
||||
return res.body.data.pageByHandle;
|
||||
@ -397,39 +397,39 @@ export async function getPage(handle: string): Promise<Page> {
|
||||
|
||||
export async function getPages(): Promise<Page[]> {
|
||||
const res = await shopifyFetch<ShopifyPagesOperation>({
|
||||
query: getPagesQuery
|
||||
query: getPagesQuery,
|
||||
});
|
||||
|
||||
return removeEdgesAndNodes(res.body.data.pages);
|
||||
}
|
||||
|
||||
export async function getProduct(handle: string): Promise<Product | undefined> {
|
||||
'use cache';
|
||||
"use cache";
|
||||
cacheTag(TAGS.products);
|
||||
cacheLife('days');
|
||||
cacheLife("days");
|
||||
|
||||
const res = await shopifyFetch<ShopifyProductOperation>({
|
||||
query: getProductQuery,
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
handle,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeProduct(res.body.data.product, false);
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(
|
||||
productId: string
|
||||
productId: string,
|
||||
): Promise<Product[]> {
|
||||
'use cache';
|
||||
"use cache";
|
||||
cacheTag(TAGS.products);
|
||||
cacheLife('days');
|
||||
cacheLife("days");
|
||||
|
||||
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
|
||||
query: getProductRecommendationsQuery,
|
||||
variables: {
|
||||
productId
|
||||
}
|
||||
productId,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeProducts(res.body.data.productRecommendations);
|
||||
@ -438,23 +438,23 @@ export async function getProductRecommendations(
|
||||
export async function getProducts({
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
sortKey,
|
||||
}: {
|
||||
query?: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
'use cache';
|
||||
"use cache";
|
||||
cacheTag(TAGS.products);
|
||||
cacheLife('days');
|
||||
cacheLife("days");
|
||||
|
||||
const res = await shopifyFetch<ShopifyProductsOperation>({
|
||||
query: getProductsQuery,
|
||||
variables: {
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
}
|
||||
sortKey,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
|
||||
@ -465,22 +465,22 @@ export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
||||
// We always need to respond with a 200 status code to Shopify,
|
||||
// otherwise it will continue to retry the request.
|
||||
const collectionWebhooks = [
|
||||
'collections/create',
|
||||
'collections/delete',
|
||||
'collections/update'
|
||||
"collections/create",
|
||||
"collections/delete",
|
||||
"collections/update",
|
||||
];
|
||||
const productWebhooks = [
|
||||
'products/create',
|
||||
'products/delete',
|
||||
'products/update'
|
||||
"products/create",
|
||||
"products/delete",
|
||||
"products/update",
|
||||
];
|
||||
const topic = (await headers()).get('x-shopify-topic') || 'unknown';
|
||||
const secret = req.nextUrl.searchParams.get('secret');
|
||||
const topic = (await 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.');
|
||||
console.error("Invalid revalidation secret.");
|
||||
return NextResponse.json({ status: 401 });
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
import cartFragment from "../fragments/cart";
|
||||
|
||||
export const addToCartMutation = /* GraphQL */ `
|
||||
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
import cartFragment from "../fragments/cart";
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import productFragment from '../fragments/product';
|
||||
import seoFragment from '../fragments/seo';
|
||||
import productFragment from "../fragments/product";
|
||||
import seoFragment from "../fragments/seo";
|
||||
|
||||
const collectionFragment = /* GraphQL */ `
|
||||
fragment collection on Collection {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import seoFragment from '../fragments/seo';
|
||||
import seoFragment from "../fragments/seo";
|
||||
|
||||
const pageFragment = /* GraphQL */ `
|
||||
fragment page on Page {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import productFragment from '../fragments/product';
|
||||
import productFragment from "../fragments/product";
|
||||
|
||||
export const getProductQuery = /* GraphQL */ `
|
||||
query getProduct($handle: String!) {
|
||||
@ -10,7 +10,11 @@ export const getProductQuery = /* GraphQL */ `
|
||||
`;
|
||||
|
||||
export const getProductsQuery = /* GraphQL */ `
|
||||
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
|
||||
query getProducts(
|
||||
$sortKey: ProductSortKeys
|
||||
$reverse: Boolean
|
||||
$query: String
|
||||
) {
|
||||
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
|
||||
edges {
|
||||
node {
|
||||
|
@ -8,7 +8,7 @@ export type Edge<T> = {
|
||||
node: T;
|
||||
};
|
||||
|
||||
export type Cart = Omit<ShopifyCart, 'lines'> & {
|
||||
export type Cart = Omit<ShopifyCart, "lines"> & {
|
||||
lines: CartItem[];
|
||||
};
|
||||
|
||||
@ -68,7 +68,7 @@ export type Page = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
|
||||
export type Product = Omit<ShopifyProduct, "variants" | "images"> & {
|
||||
variants: ProductVariant[];
|
||||
images: Image[];
|
||||
};
|
||||
|
@ -4,8 +4,12 @@ export interface ShopifyErrorLike {
|
||||
cause?: Error;
|
||||
}
|
||||
|
||||
export const isObject = (object: unknown): object is Record<string, unknown> => {
|
||||
return typeof object === 'object' && object !== null && !Array.isArray(object);
|
||||
export const isObject = (
|
||||
object: unknown,
|
||||
): object is Record<string, unknown> => {
|
||||
return (
|
||||
typeof object === "object" && object !== null && !Array.isArray(object)
|
||||
);
|
||||
};
|
||||
|
||||
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
|
||||
@ -17,7 +21,7 @@ export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
|
||||
};
|
||||
|
||||
function findError<T extends object>(error: T): boolean {
|
||||
if (Object.prototype.toString.call(error) === '[object Error]') {
|
||||
if (Object.prototype.toString.call(error) === "[object Error]") {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
22
lib/utils.ts
22
lib/utils.ts
@ -1,15 +1,15 @@
|
||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
import { ReadonlyURLSearchParams } from "next/navigation";
|
||||
|
||||
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: 'http://localhost:3000';
|
||||
: "http://localhost:3000";
|
||||
|
||||
export const createUrl = (
|
||||
pathname: string,
|
||||
params: URLSearchParams | ReadonlyURLSearchParams
|
||||
params: URLSearchParams | ReadonlyURLSearchParams,
|
||||
) => {
|
||||
const paramsString = params.toString();
|
||||
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
|
||||
const queryString = `${paramsString.length ? "?" : ""}${paramsString}`;
|
||||
|
||||
return `${pathname}${queryString}`;
|
||||
};
|
||||
@ -21,8 +21,8 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
||||
|
||||
export const validateEnvironmentVariables = () => {
|
||||
const requiredEnvironmentVariables = [
|
||||
'SHOPIFY_STORE_DOMAIN',
|
||||
'SHOPIFY_STOREFRONT_ACCESS_TOKEN'
|
||||
"SHOPIFY_STORE_DOMAIN",
|
||||
"SHOPIFY_STOREFRONT_ACCESS_TOKEN",
|
||||
];
|
||||
const missingEnvironmentVariables = [] as string[];
|
||||
|
||||
@ -35,17 +35,17 @@ export const validateEnvironmentVariables = () => {
|
||||
if (missingEnvironmentVariables.length) {
|
||||
throw new Error(
|
||||
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
|
||||
'\n'
|
||||
)}\n`
|
||||
"\n",
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes("[") ||
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes("]")
|
||||
) {
|
||||
throw new Error(
|
||||
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
|
||||
"Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -2,16 +2,16 @@ export default {
|
||||
experimental: {
|
||||
ppr: true,
|
||||
inlineCss: true,
|
||||
useCache: true
|
||||
useCache: true,
|
||||
},
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
formats: ["image/avif", "image/webp"],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.shopify.com',
|
||||
pathname: '/s/files/**'
|
||||
}
|
||||
]
|
||||
}
|
||||
protocol: "https",
|
||||
hostname: "cdn.shopify.com",
|
||||
pathname: "/s/files/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
1277
pnpm-lock.yaml
generated
1277
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
}
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user