mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 23:46:58 +00:00
feat: init commercetools setup (#1)
* feat: init commercetools setup --------- Co-authored-by: Anja-Janina Stiefermann <anja.stiefermann@kernpunkt.de>
This commit is contained in:
parent
3a18f9a098
commit
feaa87a9c8
@ -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=""
|
23
.eslintrc.js
23
.eslintrc.js
@ -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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@ -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"
|
||||||
|
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -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'
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
.vercel
|
.vercel
|
||||||
.next
|
.next
|
||||||
pnpm-lock.yaml
|
yarn.lock
|
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@ -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",
|
||||||
|
56
README.md
56
README.md
@ -1,8 +1,6 @@
|
|||||||
[](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.
|
|
||||||
|
@ -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 (
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
export default function Error({ reset }: { reset: () => void }) {
|
export default function Error({ reset }: { reset: () => void }) {
|
||||||
return (
|
return (
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
14
app/page.tsx
14
app/page.tsx
@ -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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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`,
|
||||||
|
@ -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);
|
||||||
|
@ -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
|
||||||
|
@ -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 (
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import Grid from 'components/grid';
|
import Grid from "components/grid";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
|
@ -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">"{searchValue}"</span>
|
<span className="font-bold">"{searchValue}"</span>
|
||||||
</p>
|
</p>
|
||||||
|
@ -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()
|
||||||
}));
|
}));
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
@ -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 ? (
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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" />
|
||||||
|
@ -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">
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -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>
|
||||||
© {copyrightDate} {copyrightName}
|
© {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>
|
||||||
|
@ -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">
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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">
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 };
|
||||||
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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 (
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
@ -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
23
eslintrc.js
Normal 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
40
lib/client-builder.ts
Normal 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;
|
0
lib/commercetools/index.ts
Normal file
0
lib/commercetools/index.ts
Normal file
107
lib/commercetools/types.ts
Normal file
107
lib/commercetools/types.ts
Normal 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;
|
||||||
|
};
|
@ -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";
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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!]!) {
|
||||||
|
@ -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!) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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!) {
|
||||||
|
@ -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[];
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
14
lib/utils.ts
14
lib/utils.ts
@ -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."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
12
package.json
12
package.json
@ -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
3355
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,9 +0,0 @@
|
|||||||
/** @type {import('prettier').Config} */
|
|
||||||
module.exports = {
|
|
||||||
singleQuote: true,
|
|
||||||
arrowParens: 'always',
|
|
||||||
trailingComma: 'none',
|
|
||||||
printWidth: 100,
|
|
||||||
tabWidth: 2,
|
|
||||||
plugins: ['prettier-plugin-tailwindcss']
|
|
||||||
};
|
|
@ -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")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user