feat: init commercetools setup (#1)

* feat: init commercetools setup

---------

Co-authored-by: Anja-Janina Stiefermann <anja.stiefermann@kernpunkt.de>
This commit is contained in:
leonmargaritis 2023-11-17 11:39:54 +01:00 committed by GitHub
parent 3a18f9a098
commit feaa87a9c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 3861 additions and 3884 deletions

View File

@ -5,3 +5,10 @@ SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET="" SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN="" SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
CTP_PROJECT_KEY=""
CTP_CLIENT_SECRET=""
CTP_CLIENT_ID=""
CTP_AUTH_URL=""
CTP_API_URL=""
CTP_SCOPES=""

View File

@ -1,23 +0,0 @@
module.exports = {
extends: ['next', 'prettier'],
plugins: ['unicorn'],
rules: {
'no-unused-vars': [
'error',
{
args: 'after-used',
caughtErrors: 'none',
ignoreRestSiblings: true,
vars: 'all'
}
],
'prefer-const': 'error',
'react-hooks/exhaustive-deps': 'error',
'unicorn/filename-case': [
'error',
{
case: 'kebabCase'
}
]
}
};

View File

@ -1,6 +1,6 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: 'github-actions' - package-ecosystem: "github-actions"
directory: '/' directory: "/"
schedule: schedule:
interval: 'weekly' interval: "weekly"

View File

@ -16,7 +16,7 @@ jobs:
- name: Set node version - name: Set node version
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version-file: '.nvmrc' node-version-file: ".nvmrc"
- name: Set pnpm version - name: Set pnpm version
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
@ -26,7 +26,7 @@ jobs:
id: node-modules-cache id: node-modules-cache
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: '**/node_modules' path: "**/node_modules"
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }} key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies - name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true' if: steps.node-modules-cache.outputs.cache-hit != 'true'

View File

@ -1,3 +1,3 @@
.vercel .vercel
.next .next
pnpm-lock.yaml yarn.lock

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"singleQuote": false,
"arrowParens": "always",
"trailingComma": "none",
"printWidth": 100,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}

4
.vscode/launch.json vendored
View File

@ -5,7 +5,7 @@
"name": "Next.js: debug server-side", "name": "Next.js: debug server-side",
"type": "node-terminal", "type": "node-terminal",
"request": "launch", "request": "launch",
"command": "pnpm dev" "command": "yarn dev"
}, },
{ {
"name": "Next.js: debug client-side", "name": "Next.js: debug client-side",
@ -17,7 +17,7 @@
"name": "Next.js: debug full stack", "name": "Next.js: debug full stack",
"type": "node-terminal", "type": "node-terminal",
"request": "launch", "request": "launch",
"command": "pnpm dev", "command": "yarn dev",
"serverReadyAction": { "serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)", "pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s", "uriFormat": "%s",

View File

@ -1,8 +1,6 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE) # Next.js Commerce + commercetools
# Next.js Commerce A Next.js 14 and App Router-ready ecommerce template for commercetools, featuring:
A Next.js 14 and App Router-ready ecommerce template featuring:
- Next.js App Router - Next.js App Router
- Optimized for SEO using Next.js's Metadata - Optimized for SEO using Next.js's Metadata
@ -12,38 +10,8 @@ A Next.js 14 and App Router-ready ecommerce template featuring:
- New fetching and caching paradigms - New fetching and caching paradigms
- Dynamic OG images - Dynamic OG images
- Styling with Tailwind CSS - Styling with Tailwind CSS
- Checkout and payments with Shopify
- Automatic light/dark mode based on system settings - Automatic light/dark mode based on system settings
<h3 id="v1-note"></h3>
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
## Providers
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
- Shopify (this repository)
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
- [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
## Integrations
Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
## Running locally ## Running locally
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary. You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
@ -55,22 +23,10 @@ You will need to use the environment variables [defined in `.env.example`](.env.
3. Download your environment variables: `vercel env pull` 3. Download your environment variables: `vercel env pull`
```bash ```bash
pnpm install yarn install
pnpm dev yarn dev
``` ```
> For `windows users`: A symlink error may occur during the initial installation with `yarn`. To fix this you have to open an administrator console and run `yarn`.
Your app should now be running on [localhost:3000](http://localhost:3000/). Your app should now be running on [localhost:3000](http://localhost:3000/).
<details>
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
1. Run `vc link`.
1. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project.
1. Run `vc env pull` to get environment variables.
1. Run `pnpm dev` to ensure everything is working correctly.
</details>
## Vercel, Next.js Commerce, and Shopify Integration Guide
You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.

View File

@ -1,5 +1,5 @@
import Footer from 'components/layout/footer'; import Footer from "components/layout/footer";
import { Suspense } from 'react'; import { Suspense } from "react";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (

View File

@ -1,7 +1,7 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from "components/opengraph-image";
import { getPage } from 'lib/shopify'; import { getPage } from "lib/shopify";
export const runtime = 'edge'; export const runtime = "edge";
export default async function Image({ params }: { params: { page: string } }) { export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page); const page = await getPage(params.page);

View File

@ -1,10 +1,10 @@
import type { Metadata } from 'next'; import type { Metadata } from "next";
import Prose from 'components/prose'; import Prose from "components/prose";
import { getPage } from 'lib/shopify'; import { getPage } from "lib/shopify";
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation";
export const runtime = 'edge'; export const runtime = "edge";
export const revalidate = 43200; // 12 hours in seconds export const revalidate = 43200; // 12 hours in seconds
@ -23,7 +23,7 @@ export async function generateMetadata({
openGraph: { openGraph: {
publishedTime: page.createdAt, publishedTime: page.createdAt,
modifiedTime: page.updatedAt, modifiedTime: page.updatedAt,
type: 'article' type: "article"
} }
}; };
} }
@ -39,9 +39,9 @@ export default async function Page({ params }: { params: { page: string } }) {
<Prose className="mb-8" html={page.body as string} /> <Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic"> <p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, { {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric', year: "numeric",
month: 'long', month: "long",
day: 'numeric' day: "numeric"
}).format(new Date(page.updatedAt))}.`} }).format(new Date(page.updatedAt))}.`}
</p> </p>
</> </>

View File

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

View File

@ -1,4 +1,4 @@
'use client'; "use client";
export default function Error({ reset }: { reset: () => void }) { export default function Error({ reset }: { reset: () => void }) {
return ( return (

View File

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

View File

@ -1,15 +1,15 @@
import Navbar from 'components/layout/navbar'; import Navbar from "components/layout/navbar";
import { GeistSans } from 'geist/font'; import { GeistSans } from "geist/font";
import { ensureStartsWith } from 'lib/utils'; import { ensureStartsWith } from "lib/utils";
import { ReactNode, Suspense } from 'react'; import { ReactNode, Suspense } from "react";
import './globals.css'; import "./globals.css";
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env; const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000'; : "http://localhost:3000";
const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined; const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, "@") : undefined;
const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined; const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, "https://") : undefined;
export const metadata = { export const metadata = {
metadataBase: new URL(baseUrl), metadataBase: new URL(baseUrl),
@ -24,7 +24,7 @@ export const metadata = {
...(twitterCreator && ...(twitterCreator &&
twitterSite && { twitterSite && {
twitter: { twitter: {
card: 'summary_large_image', card: "summary_large_image",
creator: twitterCreator, creator: twitterCreator,
site: twitterSite site: twitterSite
} }

View File

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

View File

@ -1,14 +1,14 @@
import { Carousel } from 'components/carousel'; import { Carousel } from "components/carousel";
import { ThreeItemGrid } from 'components/grid/three-items'; import { ThreeItemGrid } from "components/grid/three-items";
import Footer from 'components/layout/footer'; import Footer from "components/layout/footer";
import { Suspense } from 'react'; import { Suspense } from "react";
export const runtime = 'edge'; export const runtime = "edge";
export const metadata = { export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.', description: "High-performance ecommerce store built with Next.js, Vercel, and Shopify.",
openGraph: { openGraph: {
type: 'website' type: "website"
} }
}; };

View File

@ -1,17 +1,17 @@
import type { Metadata } from 'next'; import type { Metadata } from "next";
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation";
import { Suspense } from 'react'; import { Suspense } from "react";
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from "components/grid/tile";
import Footer from 'components/layout/footer'; import Footer from "components/layout/footer";
import { Gallery } from 'components/product/gallery'; import { Gallery } from "components/product/gallery";
import { ProductDescription } from 'components/product/product-description'; import { ProductDescription } from "components/product/product-description";
import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG } from "lib/constants";
import { getProduct, getProductRecommendations } from 'lib/shopify'; import { getProduct, getProductRecommendations } from "lib/shopify";
import { Image } from 'lib/shopify/types'; import { Image } from "lib/shopify/types";
import Link from 'next/link'; import Link from "next/link";
export const runtime = 'edge'; export const runtime = "edge";
export async function generateMetadata({ export async function generateMetadata({
params params
@ -57,16 +57,16 @@ export default async function ProductPage({ params }: { params: { handle: string
if (!product) return notFound(); if (!product) return notFound();
const productJsonLd = { const productJsonLd = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'Product', "@type": "Product",
name: product.title, name: product.title,
description: product.description, description: product.description,
image: product.featuredImage.url, image: product.featuredImage.url,
offers: { offers: {
'@type': 'AggregateOffer', "@type": "AggregateOffer",
availability: product.availableForSale availability: product.availableForSale
? 'https://schema.org/InStock' ? "https://schema.org/InStock"
: 'https://schema.org/OutOfStock', : "https://schema.org/OutOfStock",
priceCurrency: product.priceRange.minVariantPrice.currencyCode, priceCurrency: product.priceRange.minVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount, highPrice: product.priceRange.maxVariantPrice.amount,
lowPrice: product.priceRange.minVariantPrice.amount lowPrice: product.priceRange.minVariantPrice.amount

View File

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

View File

@ -1,7 +1,7 @@
import OpengraphImage from 'components/opengraph-image'; import OpengraphImage from "components/opengraph-image";
import { getCollection } from 'lib/shopify'; import { getCollection } from "lib/shopify";
export const runtime = 'edge'; export const runtime = "edge";
export default async function Image({ params }: { params: { collection: string } }) { export default async function Image({ params }: { params: { collection: string } }) {
const collection = await getCollection(params.collection); const collection = await getCollection(params.collection);

View File

@ -1,12 +1,12 @@
import { getCollection, getCollectionProducts } from 'lib/shopify'; import { getCollection, getCollectionProducts } from "lib/shopify";
import { Metadata } from 'next'; import { Metadata } from "next";
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation";
import Grid from 'components/grid'; import Grid from "components/grid";
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from "components/layout/product-grid-items";
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from "lib/constants";
export const runtime = 'edge'; export const runtime = "edge";
export async function generateMetadata({ export async function generateMetadata({
params params

View File

@ -1,8 +1,8 @@
import Footer from 'components/layout/footer'; import Footer from "components/layout/footer";
import Collections from 'components/layout/search/collections'; import Collections from "components/layout/search/collections";
import FilterList from 'components/layout/search/filter'; import FilterList from "components/layout/search/filter";
import { sorting } from 'lib/constants'; import { sorting } from "lib/constants";
import { Suspense } from 'react'; import { Suspense } from "react";
export default function SearchLayout({ children }: { children: React.ReactNode }) { export default function SearchLayout({ children }: { children: React.ReactNode }) {
return ( return (

View File

@ -1,4 +1,4 @@
import Grid from 'components/grid'; import Grid from "components/grid";
export default function Loading() { export default function Loading() {
return ( return (

View File

@ -1,13 +1,13 @@
import Grid from 'components/grid'; import Grid from "components/grid";
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from "components/layout/product-grid-items";
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from "lib/constants";
import { getProducts } from 'lib/shopify'; import { getProducts } from "lib/shopify";
export const runtime = 'edge'; export const runtime = "edge";
export const metadata = { export const metadata = {
title: 'Search', title: "Search",
description: 'Search for products in the store.' description: "Search for products in the store."
}; };
export default async function SearchPage({ export default async function SearchPage({
@ -19,14 +19,14 @@ export default async function SearchPage({
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 products = await getProducts({ sortKey, reverse, query: searchValue });
const resultsText = products.length > 1 ? 'results' : 'result'; const resultsText = products.length > 1 ? "results" : "result";
return ( return (
<> <>
{searchValue ? ( {searchValue ? (
<p className="mb-4"> <p className="mb-4">
{products.length === 0 {products.length === 0
? 'There are no products that match ' ? "There are no products that match "
: `Showing ${products.length} ${resultsText} for `} : `Showing ${products.length} ${resultsText} for `}
<span className="font-bold">&quot;{searchValue}&quot;</span> <span className="font-bold">&quot;{searchValue}&quot;</span>
</p> </p>

View File

@ -1,6 +1,6 @@
import { getCollections, getPages, getProducts } from 'lib/shopify'; import { getCollections, getPages, getProducts } from "lib/shopify";
import { validateEnvironmentVariables } from 'lib/utils'; import { validateEnvironmentVariables } from "lib/utils";
import { MetadataRoute } from 'next'; import { MetadataRoute } from "next";
type Route = { type Route = {
url: string; url: string;
@ -9,12 +9,12 @@ type Route = {
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000'; : "http://localhost:3000";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
validateEnvironmentVariables(); validateEnvironmentVariables();
const routesMap = [''].map((route) => ({ const routesMap = [""].map((route) => ({
url: `${baseUrl}${route}`, url: `${baseUrl}${route}`,
lastModified: new Date().toISOString() lastModified: new Date().toISOString()
})); }));

View File

@ -1,10 +1,10 @@
import { getCollectionProducts } from 'lib/shopify'; import { getCollectionProducts } from "lib/shopify";
import Link from 'next/link'; import Link from "next/link";
import { GridTileImage } from './grid/tile'; import { GridTileImage } from "./grid/tile";
export async function Carousel() { export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page. // 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; if (!products?.length) return null;

View File

@ -1,12 +1,12 @@
'use server'; "use server";
import { TAGS } from 'lib/constants'; import { TAGS } from "lib/constants";
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify'; import { addToCart, createCart, getCart, removeFromCart, updateCart } from "lib/shopify";
import { revalidateTag } from 'next/cache'; import { revalidateTag } from "next/cache";
import { cookies } from 'next/headers'; import { cookies } from "next/headers";
export async function addItem(prevState: any, selectedVariantId: string | undefined) { export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = cookies().get('cartId')?.value; let cartId = cookies().get("cartId")?.value;
let cart; let cart;
if (cartId) { if (cartId) {
@ -16,33 +16,33 @@ export async function addItem(prevState: any, selectedVariantId: string | undefi
if (!cartId || !cart) { if (!cartId || !cart) {
cart = await createCart(); cart = await createCart();
cartId = cart.id; cartId = cart.id;
cookies().set('cartId', cartId); cookies().set("cartId", cartId);
} }
if (!selectedVariantId) { if (!selectedVariantId) {
return 'Missing product variant ID'; return "Missing product variant ID";
} }
try { try {
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]); await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
return 'Error adding item to cart'; return "Error adding item to cart";
} }
} }
export async function removeItem(prevState: any, lineId: string) { export async function removeItem(prevState: any, lineId: string) {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get("cartId")?.value;
if (!cartId) { if (!cartId) {
return 'Missing cart ID'; return "Missing cart ID";
} }
try { try {
await removeFromCart(cartId, [lineId]); await removeFromCart(cartId, [lineId]);
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
return 'Error removing item from cart'; return "Error removing item from cart";
} }
} }
@ -54,10 +54,10 @@ export async function updateItemQuantity(
quantity: number; quantity: number;
} }
) { ) {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get("cartId")?.value;
if (!cartId) { if (!cartId) {
return 'Missing cart ID'; return "Missing cart ID";
} }
const { lineId, variantId, quantity } = payload; const { lineId, variantId, quantity } = payload;
@ -78,6 +78,6 @@ export async function updateItemQuantity(
]); ]);
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
return 'Error updating item quantity'; return "Error updating item quantity";
} }
} }

View File

@ -1,12 +1,12 @@
'use client'; "use client";
import { PlusIcon } from '@heroicons/react/24/outline'; import { PlusIcon } from "@heroicons/react/24/outline";
import clsx from 'clsx'; import clsx from "clsx";
import { addItem } from 'components/cart/actions'; import { addItem } from "components/cart/actions";
import LoadingDots from 'components/loading-dots'; import LoadingDots from "components/loading-dots";
import { ProductVariant } from 'lib/shopify/types'; import { ProductVariant } from "lib/shopify/types";
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from "next/navigation";
import { useFormState, useFormStatus } from 'react-dom'; import { useFormState, useFormStatus } from "react-dom";
function SubmitButton({ function SubmitButton({
availableForSale, availableForSale,
@ -17,8 +17,8 @@ function SubmitButton({
}) { }) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
const buttonClasses = const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; "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'; const disabledClasses = "cursor-not-allowed opacity-60 hover:opacity-60";
if (!availableForSale) { if (!availableForSale) {
return ( return (
@ -51,7 +51,7 @@ function SubmitButton({
aria-label="Add to cart" aria-label="Add to cart"
aria-disabled={pending} aria-disabled={pending}
className={clsx(buttonClasses, { className={clsx(buttonClasses, {
'hover:opacity-90': true, "hover:opacity-90": true,
disabledClasses: pending disabledClasses: pending
})} })}
> >

View File

@ -1,10 +1,10 @@
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from "@heroicons/react/24/outline";
import clsx from 'clsx'; import clsx from "clsx";
export default function CloseCart({ className }: { className?: string }) { export default function CloseCart({ className }: { className?: string }) {
return ( 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"> <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)} /> <XMarkIcon className={clsx("h-6 transition-all ease-in-out hover:scale-110 ", className)} />
</div> </div>
); );
} }

View File

@ -1,11 +1,11 @@
'use client'; "use client";
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from "@heroicons/react/24/outline";
import clsx from 'clsx'; import clsx from "clsx";
import { removeItem } from 'components/cart/actions'; import { removeItem } from "components/cart/actions";
import LoadingDots from 'components/loading-dots'; import LoadingDots from "components/loading-dots";
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from "lib/shopify/types";
import { useFormState, useFormStatus } from 'react-dom'; import { useFormState, useFormStatus } from "react-dom";
function SubmitButton() { function SubmitButton() {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
@ -19,9 +19,9 @@ function SubmitButton() {
aria-label="Remove cart item" aria-label="Remove cart item"
aria-disabled={pending} aria-disabled={pending}
className={clsx( className={clsx(
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200', "ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200",
{ {
'cursor-not-allowed px-0': pending "cursor-not-allowed px-0": pending
} }
)} )}
> >

View File

@ -1,13 +1,13 @@
'use client'; "use client";
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline'; import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
import clsx from 'clsx'; import clsx from "clsx";
import { updateItemQuantity } from 'components/cart/actions'; import { updateItemQuantity } from "components/cart/actions";
import LoadingDots from 'components/loading-dots'; import LoadingDots from "components/loading-dots";
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from "lib/shopify/types";
import { useFormState, useFormStatus } from 'react-dom'; import { useFormState, useFormStatus } from "react-dom";
function SubmitButton({ type }: { type: 'plus' | 'minus' }) { function SubmitButton({ type }: { type: "plus" | "minus" }) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
return ( return (
@ -16,19 +16,19 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
onClick={(e: React.FormEvent<HTMLButtonElement>) => { onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault(); if (pending) e.preventDefault();
}} }}
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'} aria-label={type === "plus" ? "Increase item quantity" : "Reduce item quantity"}
aria-disabled={pending} aria-disabled={pending}
className={clsx( className={clsx(
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80', "ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80",
{ {
'cursor-not-allowed': pending, "cursor-not-allowed": pending,
'ml-auto': type === 'minus' "ml-auto": type === "minus"
} }
)} )}
> >
{pending ? ( {pending ? (
<LoadingDots className="bg-black dark:bg-white" /> <LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? ( ) : type === "plus" ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" /> <PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : ( ) : (
<MinusIcon className="h-4 w-4 dark:text-neutral-500" /> <MinusIcon className="h-4 w-4 dark:text-neutral-500" />
@ -37,12 +37,12 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
); );
} }
export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) { export function EditItemQuantityButton({ item, type }: { item: CartItem; type: "plus" | "minus" }) {
const [message, formAction] = useFormState(updateItemQuantity, null); const [message, formAction] = useFormState(updateItemQuantity, null);
const payload = { const payload = {
lineId: item.id, lineId: item.id,
variantId: item.merchandise.id, variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 quantity: type === "plus" ? item.quantity + 1 : item.quantity - 1
}; };
const actionWithVariant = formAction.bind(null, payload); const actionWithVariant = formAction.bind(null, payload);

View File

@ -1,9 +1,9 @@
import { getCart } from 'lib/shopify'; import { getCart } from "lib/shopify";
import { cookies } from 'next/headers'; import { cookies } from "next/headers";
import CartModal from './modal'; import CartModal from "./modal";
export default async function Cart() { export default async function Cart() {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get("cartId")?.value;
let cart; let cart;
if (cartId) { if (cartId) {

View File

@ -1,18 +1,18 @@
'use client'; "use client";
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from "@headlessui/react";
import { ShoppingCartIcon } from '@heroicons/react/24/outline'; import { ShoppingCartIcon } from "@heroicons/react/24/outline";
import Price from 'components/price'; import Price from "components/price";
import { DEFAULT_OPTION } from 'lib/constants'; import { DEFAULT_OPTION } from "lib/constants";
import type { Cart } from 'lib/shopify/types'; import type { Cart } from "lib/shopify/types";
import { createUrl } from 'lib/utils'; import { createUrl } from "lib/utils";
import Image from 'next/image'; import Image from "next/image";
import Link from 'next/link'; import Link from "next/link";
import { Fragment, useEffect, useRef, useState } from 'react'; import { Fragment, useEffect, useRef, useState } from "react";
import CloseCart from './close-cart'; import CloseCart from "./close-cart";
import { DeleteItemButton } from './delete-item-button'; import { DeleteItemButton } from "./delete-item-button";
import { EditItemQuantityButton } from './edit-item-quantity-button'; import { EditItemQuantityButton } from "./edit-item-quantity-button";
import OpenCart from './open-cart'; import OpenCart from "./open-cart";
type MerchandiseSearchParams = { type MerchandiseSearchParams = {
[key: string]: string; [key: string]: string;

View File

@ -1,5 +1,5 @@
import { ShoppingCartIcon } from '@heroicons/react/24/outline'; import { ShoppingCartIcon } from "@heroicons/react/24/outline";
import clsx from 'clsx'; import clsx from "clsx";
export default function OpenCart({ export default function OpenCart({
className, className,
@ -11,7 +11,7 @@ export default function OpenCart({
return ( 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"> <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 <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 ? ( {quantity ? (

View File

@ -1,16 +1,16 @@
import clsx from 'clsx'; import clsx from "clsx";
function Grid(props: React.ComponentProps<'ul'>) { function Grid(props: React.ComponentProps<"ul">) {
return ( 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} {props.children}
</ul> </ul>
); );
} }
function GridItem(props: React.ComponentProps<'li'>) { function GridItem(props: React.ComponentProps<"li">) {
return ( return (
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}> <li {...props} className={clsx("aspect-square transition-opacity", props.className)}>
{props.children} {props.children}
</li> </li>
); );

View File

@ -1,7 +1,7 @@
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from "components/grid/tile";
import { getCollectionProducts } from 'lib/shopify'; import { getCollectionProducts } from "lib/shopify";
import type { Product } from 'lib/shopify/types'; import type { Product } from "lib/shopify/types";
import Link from 'next/link'; import Link from "next/link";
function ThreeItemGridItem({ function ThreeItemGridItem({
item, item,
@ -9,24 +9,24 @@ function ThreeItemGridItem({
priority priority
}: { }: {
item: Product; item: Product;
size: 'full' | 'half'; size: "full" | "half";
priority?: boolean; priority?: boolean;
}) { }) {
return ( return (
<div <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" href={`/product/${item.handle}`}> <Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
<GridTileImage <GridTileImage
src={item.featuredImage.url} src={item.featuredImage.url}
fill fill
sizes={ 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} priority={priority}
alt={item.title} alt={item.title}
label={{ label={{
position: size === 'full' ? 'center' : 'bottom', position: size === "full" ? "center" : "bottom",
title: item.title as string, title: item.title as string,
amount: item.priceRange.maxVariantPrice.amount, amount: item.priceRange.maxVariantPrice.amount,
currencyCode: item.priceRange.maxVariantPrice.currencyCode currencyCode: item.priceRange.maxVariantPrice.currencyCode
@ -40,7 +40,7 @@ function ThreeItemGridItem({
export async function ThreeItemGrid() { export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page. // Collections that start with `hidden-*` are hidden from the search page.
const homepageItems = await getCollectionProducts({ const homepageItems = await getCollectionProducts({
collection: 'hidden-homepage-featured-items' collection: "hidden-homepage-featured-items"
}); });
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null; if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from "clsx";
import Image from 'next/image'; import Image from "next/image";
import Label from '../label'; import Label from "../label";
export function GridTileImage({ export function GridTileImage({
isInteractive = true, isInteractive = true,
@ -14,25 +14,25 @@ export function GridTileImage({
title: string; title: string;
amount: string; amount: string;
currencyCode: string; currencyCode: string;
position?: 'bottom' | 'center'; position?: "bottom" | "center";
}; };
} & React.ComponentProps<typeof Image>) { } & React.ComponentProps<typeof Image>) {
return ( return (
<div <div
className={clsx( 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, relative: label,
'border-2 border-blue-600': active, "border-2 border-blue-600": active,
'border-neutral-200 dark:border-neutral-800': !active "border-neutral-200 dark:border-neutral-800": !active
} }
)} )}
> >
{props.src ? ( {props.src ? (
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript // eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
<Image <Image
className={clsx('relative h-full w-full object-contain', { className={clsx("relative h-full w-full object-contain", {
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive "transition duration-300 ease-in-out group-hover:scale-105": isInteractive
})} })}
{...props} {...props}
/> />

View File

@ -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 ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-label={`${process.env.SITE_NAME} logo`} aria-label={`${process.env.SITE_NAME} logo`}
viewBox="0 0 32 28" viewBox="0 0 32 28"
{...props} {...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="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" /> <path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />

View File

@ -1,21 +1,21 @@
import clsx from 'clsx'; import clsx from "clsx";
import Price from './price'; import Price from "./price";
const Label = ({ const Label = ({
title, title,
amount, amount,
currencyCode, currencyCode,
position = 'bottom' position = "bottom"
}: { }: {
title: string; title: string;
amount: string; amount: string;
currencyCode: string; currencyCode: string;
position?: 'bottom' | 'center'; position?: "bottom" | "center";
}) => { }) => {
return ( return (
<div <div
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', { className={clsx("absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label", {
'lg:px-20 lg:pb-[35%]': position === 'center' "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"> <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">

View File

@ -1,10 +1,10 @@
'use client'; "use client";
import clsx from 'clsx'; import clsx from "clsx";
import { Menu } from 'lib/shopify/types'; import { Menu } from "lib/shopify/types";
import Link from 'next/link'; import Link from "next/link";
import { usePathname } from 'next/navigation'; import { usePathname } from "next/navigation";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
const FooterMenuItem = ({ item }: { item: Menu }) => { const FooterMenuItem = ({ item }: { item: Menu }) => {
const pathname = usePathname(); const pathname = usePathname();
@ -19,9 +19,9 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
<Link <Link
href={item.path} href={item.path}
className={clsx( className={clsx(
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm', "block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm",
{ {
'text-black dark:text-neutral-300': active "text-black dark:text-neutral-300": active
} }
)} )}
> >

View File

@ -1,18 +1,18 @@
import Link from 'next/link'; import Link from "next/link";
import FooterMenu from 'components/layout/footer-menu'; import FooterMenu from "components/layout/footer-menu";
import LogoSquare from 'components/logo-square'; import LogoSquare from "components/logo-square";
import { getMenu } from 'lib/shopify'; import { getMenu } from "lib/shopify";
import { Suspense } from 'react'; import { Suspense } from "react";
const { COMPANY_NAME, SITE_NAME } = process.env; const { COMPANY_NAME, SITE_NAME } = process.env;
export default async function Footer() { export default async function Footer() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : ''); const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : "");
const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700'; const skeleton = "w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700";
const menu = await getMenu('next-js-frontend-footer-menu'); const menu = await getMenu("next-js-frontend-footer-menu");
const copyrightName = COMPANY_NAME || SITE_NAME || ''; const copyrightName = COMPANY_NAME || SITE_NAME || "";
return ( return (
<footer className="text-sm text-neutral-500 dark:text-neutral-400"> <footer className="text-sm text-neutral-500 dark:text-neutral-400">
@ -53,7 +53,7 @@ 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"> <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> <p>
&copy; {copyrightDate} {copyrightName} &copy; {copyrightDate} {copyrightName}
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved. {copyrightName.length && !copyrightName.endsWith(".") ? "." : ""} All rights reserved.
</p> </p>
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" /> <hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
<p>Designed in California</p> <p>Designed in California</p>

View File

@ -1,16 +1,16 @@
import Cart from 'components/cart'; import Cart from "components/cart";
import OpenCart from 'components/cart/open-cart'; import OpenCart from "components/cart/open-cart";
import LogoSquare from 'components/logo-square'; import LogoSquare from "components/logo-square";
import { getMenu } from 'lib/shopify'; import { getMenu } from "lib/shopify";
import { Menu } from 'lib/shopify/types'; import { Menu } from "lib/shopify/types";
import Link from 'next/link'; import Link from "next/link";
import { Suspense } from 'react'; import { Suspense } from "react";
import MobileMenu from './mobile-menu'; import MobileMenu from "./mobile-menu";
import Search from './search'; import Search from "./search";
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;
export default async function Navbar() { export default async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu'); const menu = await getMenu("next-js-frontend-header-menu");
return ( return (
<nav className="relative flex items-center justify-between p-4 lg:px-6"> <nav className="relative flex items-center justify-between p-4 lg:px-6">

View File

@ -1,13 +1,13 @@
'use client'; "use client";
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from "@headlessui/react";
import Link from 'next/link'; import Link from "next/link";
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from "next/navigation";
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from "react";
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
import { Menu } from 'lib/shopify/types'; import { Menu } from "lib/shopify/types";
import Search from './search'; import Search from "./search";
export default function MobileMenu({ menu }: { menu: Menu[] }) { export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname(); const pathname = usePathname();
@ -22,8 +22,8 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
setIsOpen(false); setIsOpen(false);
} }
}; };
window.addEventListener('resize', handleResize); window.addEventListener("resize", handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener("resize", handleResize);
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {

View File

@ -1,8 +1,8 @@
'use client'; "use client";
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { createUrl } from 'lib/utils'; import { createUrl } from "lib/utils";
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from "next/navigation";
export default function Search() { export default function Search() {
const router = useRouter(); const router = useRouter();
@ -16,23 +16,23 @@ export default function Search() {
const newParams = new URLSearchParams(searchParams.toString()); const newParams = new URLSearchParams(searchParams.toString());
if (search.value) { if (search.value) {
newParams.set('q', search.value); newParams.set("q", search.value);
} else { } else {
newParams.delete('q'); newParams.delete("q");
} }
router.push(createUrl('/search', newParams)); router.push(createUrl("/search", newParams));
} }
return ( return (
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full"> <form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input <input
key={searchParams?.get('q')} key={searchParams?.get("q")}
type="text" type="text"
name="search" name="search"
placeholder="Search for products..." placeholder="Search for products..."
autoComplete="off" autoComplete="off"
defaultValue={searchParams?.get('q') || ''} defaultValue={searchParams?.get("q") || ""}
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400" className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
/> />
<div className="absolute right-0 top-0 mr-3 flex h-full items-center"> <div className="absolute right-0 top-0 mr-3 flex h-full items-center">

View File

@ -1,7 +1,7 @@
import Grid from 'components/grid'; import Grid from "components/grid";
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from "components/grid/tile";
import { Product } from 'lib/shopify/types'; import { Product } from "lib/shopify/types";
import Link from 'next/link'; import Link from "next/link";
export default function ProductGridItems({ products }: { products: Product[] }) { export default function ProductGridItems({ products }: { products: Product[] }) {
return ( return (

View File

@ -1,17 +1,17 @@
import clsx from 'clsx'; import clsx from "clsx";
import { Suspense } from 'react'; import { Suspense } from "react";
import { getCollections } from 'lib/shopify'; import { getCollections } from "lib/shopify";
import FilterList from './filter'; import FilterList from "./filter";
async function CollectionList() { async function CollectionList() {
const collections = await getCollections(); const collections = await getCollections();
return <FilterList list={collections} title="Collections" />; return <FilterList list={collections} title="Collections" />;
} }
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded'; const skeleton = "mb-3 h-4 w-5/6 animate-pulse rounded";
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300'; const activeAndTitles = "bg-neutral-800 dark:bg-neutral-300";
const items = 'bg-neutral-400 dark:bg-neutral-700'; const items = "bg-neutral-400 dark:bg-neutral-700";
export default function Collections() { export default function Collections() {
return ( return (

View File

@ -1,16 +1,16 @@
'use client'; "use client";
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from "@heroicons/react/24/outline";
import type { ListItem } from '.'; import type { ListItem } from ".";
import { FilterItem } from './item'; import { FilterItem } from "./item";
export default function FilterItemDropdown({ list }: { list: ListItem[] }) { export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [active, setActive] = useState(''); const [active, setActive] = useState("");
const [openSelect, setOpenSelect] = useState(false); const [openSelect, setOpenSelect] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -21,15 +21,15 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
} }
}; };
window.addEventListener('click', handleClickOutside); window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside); return () => window.removeEventListener("click", handleClickOutside);
}, []); }, []);
useEffect(() => { useEffect(() => {
list.forEach((listItem: ListItem) => { list.forEach((listItem: ListItem) => {
if ( if (
('path' in listItem && pathname === listItem.path) || ("path" in listItem && pathname === listItem.path) ||
('slug' in listItem && searchParams.get('sort') === listItem.slug) ("slug" in listItem && searchParams.get("sort") === listItem.slug)
) { ) {
setActive(listItem.title); setActive(listItem.title);
} }

View File

@ -1,6 +1,6 @@
import { SortFilterItem } from 'lib/constants'; import { SortFilterItem } from "lib/constants";
import FilterItemDropdown from './dropdown'; import FilterItemDropdown from "./dropdown";
import { FilterItem } from './item'; import { FilterItem } from "./item";
export type ListItem = SortFilterItem | PathFilterItem; export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = { title: string; path: string }; export type PathFilterItem = { title: string; path: string };

View File

@ -1,29 +1,29 @@
'use client'; "use client";
import clsx from 'clsx'; import clsx from "clsx";
import { SortFilterItem } from 'lib/constants'; import { SortFilterItem } from "lib/constants";
import { createUrl } from 'lib/utils'; import { createUrl } from "lib/utils";
import Link from 'next/link'; import Link from "next/link";
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from "next/navigation";
import type { ListItem, PathFilterItem } from '.'; import type { ListItem, PathFilterItem } from ".";
function PathFilterItem({ item }: { item: PathFilterItem }) { function PathFilterItem({ item }: { item: PathFilterItem }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const active = pathname === item.path; const active = pathname === item.path;
const newParams = new URLSearchParams(searchParams.toString()); const newParams = new URLSearchParams(searchParams.toString());
const DynamicTag = active ? 'p' : Link; const DynamicTag = active ? "p" : Link;
newParams.delete('q'); newParams.delete("q");
return ( return (
<li className="mt-2 flex text-black dark:text-white" key={item.title}> <li className="mt-2 flex text-black dark:text-white" key={item.title}>
<DynamicTag <DynamicTag
href={createUrl(item.path, newParams)} href={createUrl(item.path, newParams)}
className={clsx( 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
} }
)} )}
> >
@ -36,8 +36,8 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
function SortFilterItem({ item }: { item: SortFilterItem }) { function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const active = searchParams.get('sort') === item.slug; const active = searchParams.get("sort") === item.slug;
const q = searchParams.get('q'); const q = searchParams.get("q");
const href = createUrl( const href = createUrl(
pathname, pathname,
new URLSearchParams({ new URLSearchParams({
@ -45,15 +45,15 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
...(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 ( 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 <DynamicTag
prefetch={!active ? false : undefined} prefetch={!active ? false : undefined}
href={href} href={href}
className={clsx('w-full hover:underline hover:underline-offset-4', { className={clsx("w-full hover:underline hover:underline-offset-4", {
'underline underline-offset-4': active "underline underline-offset-4": active
})} })}
> >
{item.title} {item.title}
@ -63,5 +63,5 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
} }
export function FilterItem({ item }: { item: ListItem }) { 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} />;
} }

View File

@ -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 }) => { const LoadingDots = ({ className }: { className: string }) => {
return ( return (
<span className="mx-2 inline-flex items-center"> <span className="mx-2 inline-flex items-center">
<span className={clsx(dots, className)} /> <span className={clsx(dots, className)} />
<span className={clsx(dots, 'animation-delay-[200ms]', className)} /> <span className={clsx(dots, "animation-delay-[200ms]", className)} />
<span className={clsx(dots, 'animation-delay-[400ms]', className)} /> <span className={clsx(dots, "animation-delay-[400ms]", className)} />
</span> </span>
); );
}; };

View File

@ -1,21 +1,21 @@
import clsx from 'clsx'; import clsx from "clsx";
import LogoIcon from './icons/logo'; import LogoIcon from "./icons/logo";
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) { export default function LogoSquare({ size }: { size?: "sm" | undefined }) {
return ( return (
<div <div
className={clsx( 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-[40px] w-[40px] rounded-xl": !size,
'h-[30px] w-[30px] rounded-lg': size === 'sm' "h-[30px] w-[30px] rounded-lg": size === "sm"
} }
)} )}
> >
<LogoIcon <LogoIcon
className={clsx({ className={clsx({
'h-[16px] w-[16px]': !size, "h-[16px] w-[16px]": !size,
'h-[10px] w-[10px]': size === 'sm' "h-[10px] w-[10px]": size === "sm"
})} })}
/> />
</div> </div>

View File

@ -1,5 +1,5 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from "next/og";
import LogoIcon from './icons/logo'; import LogoIcon from "./icons/logo";
export type Props = { export type Props = {
title?: string; title?: string;
@ -27,11 +27,11 @@ export default async function OpengraphImage(props?: Props): Promise<ImageRespon
height: 630, height: 630,
fonts: [ fonts: [
{ {
name: 'Inter', name: "Inter",
data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) => data: await fetch(new URL("../fonts/Inter-Bold.ttf", import.meta.url)).then((res) =>
res.arrayBuffer() res.arrayBuffer()
), ),
style: 'normal', style: "normal",
weight: 700 weight: 700
} }
] ]

View File

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

View File

@ -1,30 +1,30 @@
'use client'; "use client";
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from "components/grid/tile";
import { createUrl } from 'lib/utils'; import { createUrl } from "lib/utils";
import Image from 'next/image'; import Image from "next/image";
import Link from 'next/link'; import Link from "next/link";
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from "next/navigation";
export function Gallery({ images }: { images: { src: string; altText: string }[] }) { export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const imageSearchParam = searchParams.get('image'); const imageSearchParam = searchParams.get("image");
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0; const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
const nextSearchParams = new URLSearchParams(searchParams.toString()); const nextSearchParams = new URLSearchParams(searchParams.toString());
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0; const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
nextSearchParams.set('image', nextImageIndex.toString()); nextSearchParams.set("image", nextImageIndex.toString());
const nextUrl = createUrl(pathname, nextSearchParams); const nextUrl = createUrl(pathname, nextSearchParams);
const previousSearchParams = new URLSearchParams(searchParams.toString()); const previousSearchParams = new URLSearchParams(searchParams.toString());
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1; const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
previousSearchParams.set('image', previousImageIndex.toString()); previousSearchParams.set("image", previousImageIndex.toString());
const previousUrl = createUrl(pathname, previousSearchParams); const previousUrl = createUrl(pathname, previousSearchParams);
const buttonClassName = 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 ( return (
<> <>
@ -71,7 +71,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
const isActive = index === imageIndex; const isActive = index === imageIndex;
const imageSearchParams = new URLSearchParams(searchParams.toString()); const imageSearchParams = new URLSearchParams(searchParams.toString());
imageSearchParams.set('image', index.toString()); imageSearchParams.set("image", index.toString());
return ( return (
<li key={image.src} className="h-20 w-20"> <li key={image.src} className="h-20 w-20">

View File

@ -1,8 +1,8 @@
import { AddToCart } from 'components/cart/add-to-cart'; import { AddToCart } from "components/cart/add-to-cart";
import Price from 'components/price'; import Price from "components/price";
import Prose from 'components/prose'; import Prose from "components/prose";
import { Product } from 'lib/shopify/types'; import { Product } from "lib/shopify/types";
import { VariantSelector } from './variant-selector'; import { VariantSelector } from "./variant-selector";
export function ProductDescription({ product }: { product: Product }) { export function ProductDescription({ product }: { product: Product }) {
return ( return (

View File

@ -1,9 +1,9 @@
'use client'; "use client";
import clsx from 'clsx'; import clsx from "clsx";
import { ProductOption, ProductVariant } from 'lib/shopify/types'; import { ProductOption, ProductVariant } from "lib/shopify/types";
import { createUrl } from 'lib/utils'; import { createUrl } from "lib/utils";
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from "next/navigation";
type Combination = { type Combination = {
id: string; id: string;
@ -84,14 +84,14 @@ export function VariantSelector({
onClick={() => { onClick={() => {
router.replace(optionUrl, { scroll: false }); router.replace(optionUrl, { scroll: false });
}} }}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`} title={`${option.name} ${value}${!isAvailableForSale ? " (Out of Stock)" : ""}`}
className={clsx( 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, "cursor-default ring-2 ring-blue-600": isActive,
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ': "ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ":
!isActive && isAvailableForSale, !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 before:dark:bg-neutral-700': "relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700":
!isAvailableForSale !isAvailableForSale
} }
)} )}

View File

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

23
eslintrc.js Normal file
View File

@ -0,0 +1,23 @@
module.exports = {
extends: ["next", "prettier"],
plugins: ["unicorn"],
rules: {
"no-unused-vars": [
"error",
{
args: "after-used",
caughtErrors: "none",
ignoreRestSiblings: true,
vars: "all"
}
],
"prefer-const": "error",
"react-hooks/exhaustive-deps": "error",
"unicorn/filename-case": [
"error",
{
case: "kebabCase"
}
]
}
};

40
lib/client-builder.ts Normal file
View File

@ -0,0 +1,40 @@
import {
ClientBuilder,
type AuthMiddlewareOptions,
type HttpMiddlewareOptions
} from "@commercetools/sdk-client-v2";
import { createApiBuilderFromCtpClient } from "@commercetools/platform-sdk";
const authUrl = process.env.CTP_AUTH_URL as string;
const apiUrl = process.env.CTP_API_URL as string;
const clientId = process.env.CTP_CLIENT_ID as string;
const clientSecret = process.env.CTP_CLIENT_SECRET as string;
const projectKey = process.env.CTP_PROJECT_KEY as string;
const scopes = [process.env.CTP_SCOPES as string];
const authMiddlewareOptions: AuthMiddlewareOptions = {
host: authUrl,
projectKey,
credentials: {
clientId,
clientSecret
},
scopes
};
const httpMiddlewareOptions: HttpMiddlewareOptions = {
host: apiUrl
};
const ctpClient = new ClientBuilder()
.withProjectKey(projectKey)
.withClientCredentialsFlow(authMiddlewareOptions)
.withHttpMiddleware(httpMiddlewareOptions)
.withLoggerMiddleware()
.build();
const apiRoot = createApiBuilderFromCtpClient(ctpClient).withProjectKey({
projectKey
});
export default apiRoot;

View File

107
lib/commercetools/types.ts Normal file
View File

@ -0,0 +1,107 @@
export type Cart = {
id: string;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: CartItem[];
totalQuantity: number;
};
export type CartItem = {
id: string;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: Product;
};
};
export type Collection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: ProductVariant[];
featuredImage: Image;
images: Image[];
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};

View File

@ -1,31 +1,31 @@
export type SortFilterItem = { export type SortFilterItem = {
title: string; title: string;
slug: string | null; slug: string | null;
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE'; sortKey: "RELEVANCE" | "BEST_SELLING" | "CREATED_AT" | "PRICE";
reverse: boolean; reverse: boolean;
}; };
export const defaultSort: SortFilterItem = { export const defaultSort: SortFilterItem = {
title: 'Relevance', title: "Relevance",
slug: null, slug: null,
sortKey: 'RELEVANCE', sortKey: "RELEVANCE",
reverse: false reverse: false
}; };
export const sorting: SortFilterItem[] = [ export const sorting: SortFilterItem[] = [
defaultSort, defaultSort,
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc { title: "Trending", slug: "trending-desc", sortKey: "BEST_SELLING", reverse: false }, // asc
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true }, { 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: Low to high", slug: "price-asc", sortKey: "PRICE", reverse: false }, // asc
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true } { title: "Price: High to low", slug: "price-desc", sortKey: "PRICE", reverse: true }
]; ];
export const TAGS = { export const TAGS = {
collections: 'collections', collections: "collections",
products: 'products', products: "products",
cart: 'cart' cart: "cart"
}; };
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden'; export const HIDDEN_PRODUCT_TAG = "nextjs-frontend-hidden";
export const DEFAULT_OPTION = 'Default Title'; export const DEFAULT_OPTION = "Default Title";
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json'; export const SHOPIFY_GRAPHQL_API_ENDPOINT = "/api/2023-01/graphql.json";

View File

@ -1,4 +1,4 @@
import productFragment from './product'; import productFragment from "./product";
const cartFragment = /* GraphQL */ ` const cartFragment = /* GraphQL */ `
fragment cart on Cart { fragment cart on Cart {

View File

@ -1,5 +1,5 @@
import imageFragment from './image'; import imageFragment from "./image";
import seoFragment from './seo'; import seoFragment from "./seo";
const productFragment = /* GraphQL */ ` const productFragment = /* GraphQL */ `
fragment product on Product { fragment product on Product {

View File

@ -1,28 +1,28 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from "lib/constants";
import { isShopifyError } from 'lib/type-guards'; import { isShopifyError } from "lib/type-guards";
import { ensureStartsWith } from 'lib/utils'; import { ensureStartsWith } from "lib/utils";
import { revalidateTag } from 'next/cache'; import { revalidateTag } from "next/cache";
import { headers } from 'next/headers'; import { headers } from "next/headers";
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { import {
addToCartMutation, addToCartMutation,
createCartMutation, createCartMutation,
editCartItemsMutation, editCartItemsMutation,
removeFromCartMutation removeFromCartMutation
} from './mutations/cart'; } from "./mutations/cart";
import { getCartQuery } from './queries/cart'; import { getCartQuery } from "./queries/cart";
import { import {
getCollectionProductsQuery, getCollectionProductsQuery,
getCollectionQuery, getCollectionQuery,
getCollectionsQuery getCollectionsQuery
} from './queries/collection'; } from "./queries/collection";
import { getMenuQuery } from './queries/menu'; import { getMenuQuery } from "./queries/menu";
import { getPageQuery, getPagesQuery } from './queries/page'; import { getPageQuery, getPagesQuery } from "./queries/page";
import { import {
getProductQuery, getProductQuery,
getProductRecommendationsQuery, getProductRecommendationsQuery,
getProductsQuery getProductsQuery
} from './queries/product'; } from "./queries/product";
import { import {
Cart, Cart,
Collection, Collection,
@ -48,18 +48,18 @@ import {
ShopifyProductsOperation, ShopifyProductsOperation,
ShopifyRemoveFromCartOperation, ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation ShopifyUpdateCartOperation
} from './types'; } from "./types";
const domain = process.env.SHOPIFY_STORE_DOMAIN 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 endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!; const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never; type ExtractVariables<T> = T extends { variables: object } ? T["variables"] : never;
export async function shopifyFetch<T>({ export async function shopifyFetch<T>({
cache = 'force-cache', cache = "force-cache",
headers, headers,
query, query,
tags, tags,
@ -73,10 +73,10 @@ export async function shopifyFetch<T>({
}): Promise<{ status: number; body: T } | never> { }): Promise<{ status: number; body: T } | never> {
try { try {
const result = await fetch(endpoint, { const result = await fetch(endpoint, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-Shopify-Storefront-Access-Token': key, "X-Shopify-Storefront-Access-Token": key,
...headers ...headers
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -100,7 +100,7 @@ export async function shopifyFetch<T>({
} catch (e) { } catch (e) {
if (isShopifyError(e)) { if (isShopifyError(e)) {
throw { throw {
cause: e.cause?.toString() || 'unknown', cause: e.cause?.toString() || "unknown",
status: e.status || 500, status: e.status || 500,
message: e.message, message: e.message,
query query
@ -121,8 +121,8 @@ const removeEdgesAndNodes = (array: Connection<any>) => {
const reshapeCart = (cart: ShopifyCart): Cart => { const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) { if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = { cart.cost.totalTaxAmount = {
amount: '0.0', amount: "0.0",
currencyCode: 'USD' currencyCode: "USD"
}; };
} }
@ -204,7 +204,7 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
export async function createCart(): Promise<Cart> { export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({ const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation, query: createCartMutation,
cache: 'no-store' cache: "no-store"
}); });
return reshapeCart(res.body.data.cartCreate.cart); return reshapeCart(res.body.data.cartCreate.cart);
@ -220,7 +220,7 @@ export async function addToCart(
cartId, cartId,
lines lines
}, },
cache: 'no-store' cache: "no-store"
}); });
return reshapeCart(res.body.data.cartLinesAdd.cart); return reshapeCart(res.body.data.cartLinesAdd.cart);
} }
@ -232,7 +232,7 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise
cartId, cartId,
lineIds lineIds
}, },
cache: 'no-store' cache: "no-store"
}); });
return reshapeCart(res.body.data.cartLinesRemove.cart); return reshapeCart(res.body.data.cartLinesRemove.cart);
@ -248,7 +248,7 @@ export async function updateCart(
cartId, cartId,
lines lines
}, },
cache: 'no-store' cache: "no-store"
}); });
return reshapeCart(res.body.data.cartLinesUpdate.cart); return reshapeCart(res.body.data.cartLinesUpdate.cart);
@ -259,7 +259,7 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
query: getCartQuery, query: getCartQuery,
variables: { cartId }, variables: { cartId },
tags: [TAGS.cart], tags: [TAGS.cart],
cache: 'no-store' cache: "no-store"
}); });
// Old carts becomes `null` when you checkout. // Old carts becomes `null` when you checkout.
@ -297,7 +297,7 @@ export async function getCollectionProducts({
variables: { variables: {
handle: collection, handle: collection,
reverse, reverse,
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey sortKey: sortKey === "CREATED_AT" ? "CREATED" : sortKey
} }
}); });
@ -317,20 +317,20 @@ export async function getCollections(): Promise<Collection[]> {
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections); const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [ const collections = [
{ {
handle: '', handle: "",
title: 'All', title: "All",
description: 'All products', description: "All products",
seo: { seo: {
title: 'All', title: "All",
description: 'All products' description: "All products"
}, },
path: '/search', path: "/search",
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}, },
// Filter out the `hidden` collections. // Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page. // Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter( ...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith('hidden') (collection) => !collection.handle.startsWith("hidden")
) )
]; ];
@ -349,7 +349,7 @@ export async function getMenu(handle: string): Promise<Menu[]> {
return ( return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({ res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title, title: item.title,
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '') path: item.url.replace(domain, "").replace("/collections", "/search").replace("/pages", "")
})) || [] })) || []
); );
} }
@ -421,15 +421,15 @@ export async function getProducts({
export async function revalidate(req: NextRequest): Promise<NextResponse> { export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify, // We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request. // otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update']; const collectionWebhooks = ["collections/create", "collections/delete", "collections/update"];
const productWebhooks = ['products/create', 'products/delete', 'products/update']; const productWebhooks = ["products/create", "products/delete", "products/update"];
const topic = headers().get('x-shopify-topic') || 'unknown'; const topic = headers().get("x-shopify-topic") || "unknown";
const secret = req.nextUrl.searchParams.get('secret'); const secret = req.nextUrl.searchParams.get("secret");
const isCollectionUpdate = collectionWebhooks.includes(topic); const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic); const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) { if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.'); console.error("Invalid revalidation secret.");
return NextResponse.json({ status: 200 }); return NextResponse.json({ status: 200 });
} }

View File

@ -1,4 +1,4 @@
import cartFragment from '../fragments/cart'; import cartFragment from "../fragments/cart";
export const addToCartMutation = /* GraphQL */ ` export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) { mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {

View File

@ -1,4 +1,4 @@
import cartFragment from '../fragments/cart'; import cartFragment from "../fragments/cart";
export const getCartQuery = /* GraphQL */ ` export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) { query getCart($cartId: ID!) {

View File

@ -1,5 +1,5 @@
import productFragment from '../fragments/product'; import productFragment from "../fragments/product";
import seoFragment from '../fragments/seo'; import seoFragment from "../fragments/seo";
const collectionFragment = /* GraphQL */ ` const collectionFragment = /* GraphQL */ `
fragment collection on Collection { fragment collection on Collection {

View File

@ -1,4 +1,4 @@
import seoFragment from '../fragments/seo'; import seoFragment from "../fragments/seo";
const pageFragment = /* GraphQL */ ` const pageFragment = /* GraphQL */ `
fragment page on Page { fragment page on Page {

View File

@ -1,4 +1,4 @@
import productFragment from '../fragments/product'; import productFragment from "../fragments/product";
export const getProductQuery = /* GraphQL */ ` export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) { query getProduct($handle: String!) {

View File

@ -8,7 +8,7 @@ export type Edge<T> = {
node: T; node: T;
}; };
export type Cart = Omit<ShopifyCart, 'lines'> & { export type Cart = Omit<ShopifyCart, "lines"> & {
lines: CartItem[]; lines: CartItem[];
}; };
@ -61,7 +61,7 @@ export type Page = {
updatedAt: string; updatedAt: string;
}; };
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & { export type Product = Omit<ShopifyProduct, "variants" | "images"> & {
variants: ProductVariant[]; variants: ProductVariant[];
images: Image[]; images: Image[];
}; };

View File

@ -5,7 +5,7 @@ export interface ShopifyErrorLike {
} }
export const isObject = (object: unknown): object is Record<string, unknown> => { export const isObject = (object: unknown): object is Record<string, unknown> => {
return typeof object === 'object' && object !== null && !Array.isArray(object); return typeof object === "object" && object !== null && !Array.isArray(object);
}; };
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => { export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
@ -17,7 +17,7 @@ export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
}; };
function findError<T extends object>(error: T): boolean { 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; return true;
} }

View File

@ -1,8 +1,8 @@
import { ReadonlyURLSearchParams } from 'next/navigation'; import { ReadonlyURLSearchParams } from "next/navigation";
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => { export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
const paramsString = params.toString(); const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`; const queryString = `${paramsString.length ? "?" : ""}${paramsString}`;
return `${pathname}${queryString}`; return `${pathname}${queryString}`;
}; };
@ -11,7 +11,7 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`; stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => { export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN']; const requiredEnvironmentVariables = ["SHOPIFY_STORE_DOMAIN", "SHOPIFY_STOREFRONT_ACCESS_TOKEN"];
const missingEnvironmentVariables = [] as string[]; const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => { requiredEnvironmentVariables.forEach((envVar) => {
@ -23,17 +23,17 @@ export const validateEnvironmentVariables = () => {
if (missingEnvironmentVariables.length) { if (missingEnvironmentVariables.length) {
throw new Error( 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( `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 ( 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( 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."
); );
} }
}; };

View File

@ -5,20 +5,20 @@ module.exports = {
ignoreDuringBuilds: true ignoreDuringBuilds: true
}, },
images: { images: {
formats: ['image/avif', 'image/webp'], formats: ["image/avif", "image/webp"],
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: "https",
hostname: 'cdn.shopify.com', hostname: "cdn.shopify.com",
pathname: '/s/files/**' pathname: "/s/files/**"
} }
] ]
}, },
async redirects() { async redirects() {
return [ return [
{ {
source: '/password', source: "/password",
destination: '/', destination: "/",
permanent: true permanent: true
} }
]; ];

View File

@ -1,9 +1,11 @@
{ {
"name": "nextjs-commerce",
"version": "1.0.0",
"description": "Next.js commerce + commercetools adapter",
"repository": "git@github.com:kernpunkt/nextjs-commerce",
"private": true, "private": true,
"packageManager": "pnpm@8.2.0",
"engines": { "engines": {
"node": ">=18", "node": ">=18"
"pnpm": ">=7"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@ -13,7 +15,7 @@
"lint-staged": "lint-staged", "lint-staged": "lint-staged",
"prettier": "prettier --write --ignore-unknown .", "prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .", "prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm lint && pnpm prettier:check" "test": "next lint && prettier --check --ignore-unknown ."
}, },
"git": { "git": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged"
@ -22,6 +24,8 @@
"*": "prettier --write --ignore-unknown" "*": "prettier --write --ignore-unknown"
}, },
"dependencies": { "dependencies": {
"@commercetools/platform-sdk": "^7.0.0",
"@commercetools/sdk-client-v2": "^2.3.0",
"@headlessui/react": "^1.7.17", "@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"clsx": "^2.0.0", "clsx": "^2.0.0",

3355
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
arrowParens: 'always',
trailingComma: 'none',
printWidth: 100,
tabWidth: 2,
plugins: ['prettier-plugin-tailwindcss']
};

View File

@ -1,12 +1,12 @@
const plugin = require('tailwindcss/plugin'); const plugin = require("tailwindcss/plugin");
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], content: ["./app/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['var(--font-geist-sans)'] sans: ["var(--font-geist-sans)"]
}, },
keyframes: { keyframes: {
fadeIn: { fadeIn: {
@ -14,19 +14,19 @@ module.exports = {
to: { opacity: 1 } to: { opacity: 1 }
}, },
marquee: { marquee: {
'0%': { transform: 'translateX(0%)' }, "0%": { transform: "translateX(0%)" },
'100%': { transform: 'translateX(-100%)' } "100%": { transform: "translateX(-100%)" }
}, },
blink: { blink: {
'0%': { opacity: 0.2 }, "0%": { opacity: 0.2 },
'20%': { opacity: 1 }, "20%": { opacity: 1 },
'100% ': { opacity: 0.2 } "100% ": { opacity: 0.2 }
} }
}, },
animation: { animation: {
fadeIn: 'fadeIn .3s ease-in-out', fadeIn: "fadeIn .3s ease-in-out",
carousel: 'marquee 60s linear infinite', carousel: "marquee 60s linear infinite",
blink: 'blink 1.4s both infinite' blink: "blink 1.4s both infinite"
} }
} }
}, },
@ -34,19 +34,19 @@ module.exports = {
hoverOnlyWhenSupported: true hoverOnlyWhenSupported: true
}, },
plugins: [ plugins: [
require('@tailwindcss/container-queries'), require("@tailwindcss/container-queries"),
require('@tailwindcss/typography'), require("@tailwindcss/typography"),
plugin(({ matchUtilities, theme }) => { plugin(({ matchUtilities, theme }) => {
matchUtilities( matchUtilities(
{ {
'animation-delay': (value) => { "animation-delay": (value) => {
return { return {
'animation-delay': value "animation-delay": value
}; };
} }
}, },
{ {
values: theme('transitionDelay') values: theme("transitionDelay")
} }
); );
}) })

3219
yarn.lock Normal file

File diff suppressed because it is too large Load Diff