mirror of
https://github.com/vercel/commerce.git
synced 2025-05-15 14:06:59 +00:00
Merge pull request #2 from BrocksiNet/poc/react-nextjs-new-design
poc/react addapt the new design from vercel
This commit is contained in:
commit
1364b8504f
@ -1,3 +1,4 @@
|
|||||||
|
COMPANY_NAME="shopware AG"
|
||||||
TWITTER_CREATOR="@shopware"
|
TWITTER_CREATOR="@shopware"
|
||||||
TWITTER_SITE="https://www.shopware.com/en/solutions/shopware-composable-frontends/"
|
TWITTER_SITE="https://www.shopware.com/en/solutions/shopware-composable-frontends/"
|
||||||
SITE_NAME="Next.js Commerce with Shopware Composable Frontends"
|
SITE_NAME="Next.js Commerce with Shopware Composable Frontends"
|
||||||
|
49
.github/workflows/e2e.yml
vendored
49
.github/workflows/e2e.yml
vendored
@ -1,49 +0,0 @@
|
|||||||
name: e2e
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Runs "at 09:00 and 15:00, Monday through Friday" (see https://crontab.guru)
|
|
||||||
- cron: '0 9,15 * * 1-5'
|
|
||||||
jobs:
|
|
||||||
e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Cancel running workflows
|
|
||||||
uses: styfle/cancel-workflow-action@0.11.0
|
|
||||||
with:
|
|
||||||
access_token: ${{ github.token }}
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: Set node version
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version-file: '.nvmrc'
|
|
||||||
- name: Set pnpm version
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
version: 7
|
|
||||||
- name: Cache node_modules
|
|
||||||
id: node-modules-cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: '**/node_modules'
|
|
||||||
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
|
||||||
run: pnpm install
|
|
||||||
- name: Get playwright version
|
|
||||||
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./node_modules/@playwright/test/package.json').version)")" >> $GITHUB_ENV
|
|
||||||
- name: Cache playwright
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: playwright-cache
|
|
||||||
with:
|
|
||||||
path: '~/.cache/ms-playwright'
|
|
||||||
key: playwright-cache-${{ env.PLAYWRIGHT_VERSION }}
|
|
||||||
- name: Install playwright browsers
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
- name: Install playwright browser dependencies
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
||||||
run: npx playwright install-deps
|
|
||||||
- name: Run tests
|
|
||||||
run: pnpm test:e2e
|
|
@ -12,7 +12,9 @@ A Next.js 13 and App Router-ready ecommerce template featuring:
|
|||||||
- Styling with Tailwind CSS
|
- Styling with Tailwind CSS
|
||||||
- Automatic light/dark mode based on system settings
|
- Automatic light/dark mode based on system settings
|
||||||
|
|
||||||
> 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)
|
<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).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
@ -17,13 +17,6 @@ export async function generateMetadata({ params }: { params: { cms: string } }):
|
|||||||
title: page.seo?.title || page.title,
|
title: page.seo?.title || page.title,
|
||||||
description: page.seo?.description || page.bodySummary,
|
description: page.seo?.description || page.bodySummary,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: `/api/og?title=${encodeURIComponent(page.title)}`,
|
|
||||||
width: 1200,
|
|
||||||
height: 630
|
|
||||||
}
|
|
||||||
],
|
|
||||||
publishedTime: page.createdAt,
|
publishedTime: page.createdAt,
|
||||||
modifiedTime: page.updatedAt,
|
modifiedTime: page.updatedAt,
|
||||||
type: 'article'
|
type: 'article'
|
||||||
|
@ -4,7 +4,7 @@ import { Suspense } from 'react';
|
|||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<div className="w-full bg-white dark:bg-black">
|
<div className="w-full">
|
||||||
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
|
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
|
||||||
<Suspense>{children}</Suspense>
|
<Suspense>{children}</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,37 +1,8 @@
|
|||||||
import { TAGS } from 'lib/constants';
|
import { revalidate } from 'lib/shopify';
|
||||||
import { revalidateTag } from 'next/cache';
|
|
||||||
import { headers } from 'next/headers';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
// We always need to respond with a 200 status code to Shopify,
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// otherwise it will continue to retry the request.
|
return revalidate(req);
|
||||||
export async function POST(req: NextRequest): Promise<Response> {
|
|
||||||
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
|
|
||||||
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
|
|
||||||
const topic = headers().get('x-shopify-topic') || 'unknown';
|
|
||||||
const secret = req.nextUrl.searchParams.get('secret');
|
|
||||||
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
|
||||||
const isProductUpdate = productWebhooks.includes(topic);
|
|
||||||
|
|
||||||
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
|
|
||||||
console.error('Invalid revalidation secret.');
|
|
||||||
return NextResponse.json({ status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isCollectionUpdate && !isProductUpdate) {
|
|
||||||
// We don't need to revalidate anything for any other topics.
|
|
||||||
return NextResponse.json({ status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCollectionUpdate) {
|
|
||||||
revalidateTag(TAGS.collections);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isProductUpdate) {
|
|
||||||
revalidateTag(TAGS.products);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
|
||||||
}
|
}
|
||||||
|
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 15 KiB |
@ -2,8 +2,20 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
|
||||||
|
}
|
||||||
|
@ -33,7 +33,7 @@ const inter = Inter({
|
|||||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={inter.variable}>
|
<html lang="en" className={inter.variable}>
|
||||||
<body className="bg-white text-black selection:bg-teal-300 dark:bg-black dark:text-white dark:selection:bg-fuchsia-600 dark:selection:text-white">
|
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
@ -8,13 +8,6 @@ export const runtime = 'edge';
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopware.',
|
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopware.',
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: `/api/og?title=${encodeURIComponent(process.env.SITE_NAME || '')}`,
|
|
||||||
width: 1200,
|
|
||||||
height: 630
|
|
||||||
}
|
|
||||||
],
|
|
||||||
type: 'website'
|
type: 'website'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -2,16 +2,14 @@ import type { Metadata } from 'next';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import { AddToCart } from 'components/cart/add-to-cart';
|
import { GridTileImage } from 'components/grid/tile';
|
||||||
import Grid from 'components/grid';
|
|
||||||
import Footer from 'components/layout/footer';
|
import Footer from 'components/layout/footer';
|
||||||
import { VariantSelector } from 'components/product/variant-selector';
|
|
||||||
import ProductGridItems from 'components/layout/product-grid-items';
|
|
||||||
import { Gallery } from 'components/product/gallery';
|
import { Gallery } from 'components/product/gallery';
|
||||||
import Prose from 'components/prose';
|
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/shopware';
|
import { getProduct, getProductRecommendations } from 'lib/shopware';
|
||||||
import { Image } from 'lib/shopware/types';
|
import { Image } from 'lib/shopware/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
@ -26,17 +24,17 @@ export async function generateMetadata({
|
|||||||
if (!product) return notFound();
|
if (!product) return notFound();
|
||||||
|
|
||||||
const { url, width, height, altText: alt } = product.featuredImage || {};
|
const { url, width, height, altText: alt } = product.featuredImage || {};
|
||||||
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: product.seo.title || product.title,
|
title: product.seo.title || product.title,
|
||||||
description: product.seo.description || product.description,
|
description: product.seo.description || product.description,
|
||||||
robots: {
|
robots: {
|
||||||
index: hide,
|
index: indexable,
|
||||||
follow: hide,
|
follow: indexable,
|
||||||
googleBot: {
|
googleBot: {
|
||||||
index: hide,
|
index: indexable,
|
||||||
follow: hide
|
follow: indexable
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
openGraph: url
|
openGraph: url
|
||||||
@ -77,19 +75,17 @@ export default async function ProductPage({ params }: { params: { handle: string
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: JSON.stringify(productJsonLd)
|
__html: JSON.stringify(productJsonLd)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="lg:grid lg:grid-cols-6">
|
<div className="mx-auto max-w-screen-2xl px-4">
|
||||||
<div className="lg:col-span-4">
|
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row">
|
||||||
|
<div className="h-full w-full basis-full lg:basis-4/6">
|
||||||
<Gallery
|
<Gallery
|
||||||
title={product.title}
|
|
||||||
amount={product.priceRange.maxVariantPrice.amount}
|
|
||||||
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
|
||||||
images={product.images.map((image: Image) => ({
|
images={product.images.map((image: Image) => ({
|
||||||
src: image.url,
|
src: image.url,
|
||||||
altText: image.altText
|
altText: image.altText
|
||||||
@ -97,27 +93,18 @@ export default async function ProductPage({ params }: { params: { handle: string
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 lg:col-span-2">
|
<div className="basis-full lg:basis-2/6">
|
||||||
<VariantSelector options={product.options} variants={product.variants} />
|
<ProductDescription product={product} />
|
||||||
|
|
||||||
{product.descriptionHtml ? (
|
|
||||||
<Prose className="mb-6 text-sm leading-tight" html={product.descriptionHtml} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AddToCart
|
|
||||||
product={product}
|
|
||||||
variants={product.variants}
|
|
||||||
availableForSale={product.availableForSale}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<RelatedProducts id={product.id} />
|
<RelatedProducts id={product.id} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Suspense>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,11 +114,30 @@ async function RelatedProducts({ id }: { id: string }) {
|
|||||||
if (!relatedProducts.length) return null;
|
if (!relatedProducts.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-8">
|
<div className="py-8">
|
||||||
<div className="mb-4 text-3xl font-bold">Related Products</div>
|
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
||||||
<Grid className="grid-cols-2 lg:grid-cols-5">
|
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||||
<ProductGridItems products={relatedProducts} />
|
{relatedProducts.map((product) => (
|
||||||
</Grid>
|
<li
|
||||||
|
key={product.path}
|
||||||
|
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
||||||
|
>
|
||||||
|
<Link className="relative h-full w-full" href={`/product/${product.path}`}>
|
||||||
|
<GridTileImage
|
||||||
|
alt={product.title}
|
||||||
|
label={{
|
||||||
|
title: product.title,
|
||||||
|
amount: product.priceRange.maxVariantPrice.amount,
|
||||||
|
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||||
|
}}
|
||||||
|
src={product.featuredImage?.url}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -50,23 +50,28 @@ export default async function CategoryPage({
|
|||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
|
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
|
||||||
<div className="order-first flex-none md:w-1/6">
|
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||||
<Collections collection={params.collection} />
|
<Collections collection={params.collection} />
|
||||||
</div>
|
</div>
|
||||||
<div className="order-last min-h-screen w-full md:order-none">
|
<div className="order-last min-h-screen w-full md:order-none">
|
||||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||||
<ProductGridItems products={products} />
|
<ProductGridItems products={products} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<nav aria-label="Collection pagination" className="block items-center sm:flex">
|
{total > limit ? (
|
||||||
|
<nav
|
||||||
|
aria-label="Collection pagination"
|
||||||
|
className="mb-2 mt-4 block items-center sm:flex"
|
||||||
|
>
|
||||||
<Pagination
|
<Pagination
|
||||||
itemsPerPage={limit}
|
itemsPerPage={limit}
|
||||||
itemsTotal={total}
|
itemsTotal={total}
|
||||||
currentPage={page ? parseInt(page) - 1 : 0}
|
currentPage={page ? parseInt(page) - 1 : 0}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="order-none md:order-last md:w-1/6 md:flex-none">
|
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||||
<FilterList list={sorting} title="Sort by" />
|
<FilterList list={sorting} title="Sort by" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,20 +4,23 @@ import { sorting } from 'lib/constants';
|
|||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
|
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
|
||||||
<div className="order-first flex-none md:w-1/6"></div>
|
<div className="order-first w-full flex-none md:max-w-[125px]"></div>
|
||||||
<div className="order-last min-h-screen w-full md:order-none">
|
<div className="order-last min-h-screen w-full md:order-none">
|
||||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||||
{Array(12)
|
{Array(12)
|
||||||
.fill(0)
|
.fill(0)
|
||||||
.map((_, index) => {
|
.map((_, index) => {
|
||||||
return (
|
return (
|
||||||
<Grid.Item key={index} className="animate-pulse bg-gray-100 dark:bg-gray-900" />
|
<Grid.Item
|
||||||
|
key={index}
|
||||||
|
className="animate-pulse bg-neutral-100 dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
<div className="order-none md:order-last md:w-1/6 md:flex-none">
|
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||||
<FilterList list={sorting} title="Sort by" />
|
<FilterList list={sorting} title="Sort by" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -25,28 +25,32 @@ export default async function SearchPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{searchValue && products.length === 0 ? (
|
{searchValue && products.length === 0 ? (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
|
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
|
||||||
<p>
|
<p className="mb-4">
|
||||||
{'There are no products that match '}
|
{'There are no products that match '}
|
||||||
<span className="font-bold">"{searchValue}"</span>
|
<span className="font-bold">"{searchValue}"</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{products.length > 0 ? (
|
{products.length > 0 ? (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
|
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
|
||||||
<div className="order-first flex-none md:w-1/6">
|
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||||
{searchValue ? (
|
{searchValue ? (
|
||||||
<p>
|
<p className="mb-4 text-sm text-neutral-500">
|
||||||
{`Showing ${products.length} ${resultsText} for `}
|
{`Showing ${products.length} ${resultsText} for `}
|
||||||
<span className="font-bold">"{searchValue}"</span>
|
<span className="font-bold">"{searchValue}"</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="pt-4">Good place to add other suggest search terms ;)</p>
|
<p className="pt-4 text-xs text-neutral-500">
|
||||||
|
Good place to add other suggested search terms ;)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="order-last min-h-screen w-full md:order-none">
|
||||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||||
<ProductGridItems products={products} />
|
<ProductGridItems products={products} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<div className="order-none md:order-last md:w-1/6 md:flex-none">
|
</div>
|
||||||
|
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||||
<FilterList list={sorting} title="Sort by" />
|
<FilterList list={sorting} title="Sort by" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
import { getProductSeoUrls } from 'lib/shopware';
|
import { getProductSeoUrls } from 'lib/shopware';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
type Route = {
|
||||||
|
url: string;
|
||||||
|
lastModified: string;
|
||||||
|
};
|
||||||
|
|
||||||
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<Promise<Promise<MetadataRoute.Sitemap>>> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const routesMap = [''].map((route) => ({
|
const routesMap = [''].map((route) => ({
|
||||||
url: `${baseUrl}${route}`,
|
url: `${baseUrl}${route}`,
|
||||||
lastModified: new Date().toISOString()
|
lastModified: new Date().toISOString()
|
||||||
@ -19,7 +24,13 @@ export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.S
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchedRoutes = (await Promise.all([productsPromise])).flat();
|
let fetchedRoutes: Route[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetchedRoutes = (await Promise.all([productsPromise])).flat();
|
||||||
|
} catch (error) {
|
||||||
|
throw JSON.stringify(error, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
return [...routesMap, ...fetchedRoutes];
|
return [...routesMap, ...fetchedRoutes];
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { getCollectionProducts } from 'lib/shopware';
|
import { getCollectionProducts } from 'lib/shopware';
|
||||||
import { isSeoUrls } from 'lib/shopware/helpers';
|
import { isSeoUrls } from 'lib/shopware/helpers';
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { GridTileImage } from './grid/tile';
|
||||||
|
|
||||||
export async function Carousel() {
|
export async function Carousel() {
|
||||||
const collectionName = isSeoUrls()
|
const collectionName = isSeoUrls()
|
||||||
@ -13,32 +13,33 @@ export async function Carousel() {
|
|||||||
|
|
||||||
if (!products?.length) return null;
|
if (!products?.length) return null;
|
||||||
|
|
||||||
|
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
|
||||||
|
const carouselProducts = [...products, ...products, ...products];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full overflow-hidden bg-white dark:bg-black">
|
<div className=" w-full overflow-x-auto pb-6 pt-1">
|
||||||
<div className="flex animate-carousel">
|
<ul className="flex animate-carousel gap-4">
|
||||||
{[...products, ...products].map((product, i) => (
|
{carouselProducts.map((product, i) => (
|
||||||
<Link
|
<li
|
||||||
key={`${product.path}${i}`}
|
key={`${product.path}${i}`}
|
||||||
href={`/product/${product.path}`}
|
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||||
className="relative mx-2 my-8 h-[30vh] w-1/2 flex-none md:w-1/3"
|
|
||||||
>
|
>
|
||||||
{product.featuredImage ? (
|
<Link href={`/product/${product.path}`} className="relative h-full w-full">
|
||||||
<Image
|
<GridTileImage
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
className="h-full object-contain"
|
label={{
|
||||||
|
title: product.title,
|
||||||
|
amount: product.priceRange.maxVariantPrice.amount,
|
||||||
|
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||||
|
}}
|
||||||
|
src={product.featuredImage?.url}
|
||||||
fill
|
fill
|
||||||
sizes="33vw"
|
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||||
src={product.featuredImage.url}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center justify-center">
|
|
||||||
<div className="inline-flex bg-white p-4 text-xl font-semibold text-black dark:bg-black dark:text-white">
|
|
||||||
{product.title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,20 @@ export const fetchCart = async function (cartId?: string): Promise<ExtendedCart
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
||||||
const cartId = cookies().get('sw-context-token')?.value;
|
let cartId = cookies().get('sw-context-token')?.value;
|
||||||
|
let cart;
|
||||||
|
|
||||||
|
if (cartId) {
|
||||||
|
cart = await fetchCart(cartId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cartId || !cart) {
|
||||||
|
cart = await fetchCart();
|
||||||
|
if (cart && cart.token) {
|
||||||
|
cartId = cart.token;
|
||||||
|
cookies().set('sw-context-token', cartId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!variantId) {
|
if (!variantId) {
|
||||||
return new Error('Missing variantId');
|
return new Error('Missing variantId');
|
||||||
@ -33,7 +46,6 @@ export const addItem = async (variantId: string | undefined): Promise<Error | un
|
|||||||
const apiClient = getApiClient(cartId);
|
const apiClient = getApiClient(cartId);
|
||||||
|
|
||||||
// this part allows us to click multiple times on addToCart and increase the qty with that
|
// this part allows us to click multiple times on addToCart and increase the qty with that
|
||||||
const cart = await fetchCart(cartId);
|
|
||||||
const itemInCart = cart?.lineItems?.filter((item) => item.id === variantId) as
|
const itemInCart = cart?.lineItems?.filter((item) => item.id === variantId) as
|
||||||
| ExtendedLineItem
|
| ExtendedLineItem
|
||||||
| undefined;
|
| undefined;
|
||||||
@ -41,7 +53,7 @@ export const addItem = async (variantId: string | undefined): Promise<Error | un
|
|||||||
quantity = itemInCart.quantity + 1;
|
quantity = itemInCart.quantity + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.invoke('addLineItem post /checkout/cart/line-item', {
|
const response = await apiClient.invoke('addLineItem post /checkout/cart/line-item', {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: variantId,
|
id: variantId,
|
||||||
@ -51,6 +63,11 @@ export const addItem = async (variantId: string | undefined): Promise<Error | un
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const errorMessage = alertErrorMessages(response);
|
||||||
|
if (errorMessage !== '') {
|
||||||
|
return { message: errorMessage } as Error;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiClientError) {
|
if (error instanceof ApiClientError) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -61,6 +78,20 @@ export const addItem = async (variantId: string | undefined): Promise<Error | un
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function alertErrorMessages(response: ExtendedCart): string {
|
||||||
|
let errorMessages: string = '';
|
||||||
|
if (response.errors) {
|
||||||
|
Object.values(response.errors).forEach(function (value) {
|
||||||
|
// @ts-ignore
|
||||||
|
if (value.messageKey && value.message && value.messageKey === 'product-out-of-stock') {
|
||||||
|
errorMessages += value.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorMessages;
|
||||||
|
}
|
||||||
|
|
||||||
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
|
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
|
||||||
const cartId = cookies().get('sw-context-token')?.value;
|
const cartId = cookies().get('sw-context-token')?.value;
|
||||||
|
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
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 { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { useEffect, useState, useTransition } from 'react';
|
|
||||||
|
|
||||||
import LoadingDots from 'components/loading-dots';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import { Product } from 'lib/shopware/types';
|
import { ProductVariant, Product } from 'lib/shopware/types';
|
||||||
import { ProductVariant } from 'lib/shopware/types';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
export function AddToCart({
|
export function AddToCart({
|
||||||
product,
|
product,
|
||||||
@ -18,34 +17,36 @@ export function AddToCart({
|
|||||||
availableForSale: boolean;
|
availableForSale: boolean;
|
||||||
product: Product;
|
product: Product;
|
||||||
}) {
|
}) {
|
||||||
const [selectedVariantId, setSelectedVariantId] = useState(product.id);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const defaultVariantId = variants.length === 1 ? variants[0]?.id : product.id;
|
||||||
useEffect(() => {
|
|
||||||
const variant = variants.find((variant: ProductVariant) =>
|
const variant = variants.find((variant: ProductVariant) =>
|
||||||
variant.selectedOptions.every(
|
variant.selectedOptions.every(
|
||||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const selectedVariantId = variant?.id || defaultVariantId;
|
||||||
if (variant) {
|
const title = !availableForSale
|
||||||
setSelectedVariantId(variant.id);
|
? 'Out of stock'
|
||||||
}
|
: !selectedVariantId
|
||||||
}, [searchParams, variants, setSelectedVariantId, selectedVariantId]);
|
? 'Please select options'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Add item to cart"
|
aria-label="Add item to cart"
|
||||||
disabled={isPending}
|
disabled={isPending || !availableForSale || !selectedVariantId}
|
||||||
|
title={title}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!availableForSale) return;
|
// Safeguard in case someone messes with `disabled` in devtools.
|
||||||
|
if (!availableForSale || !selectedVariantId) return;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const error = await addItem(selectedVariantId);
|
const error = await addItem(selectedVariantId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
alert(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,15 +54,17 @@ export function AddToCart({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex w-full items-center justify-center bg-black p-4 text-sm uppercase tracking-wide text-white opacity-90 hover:opacity-100 dark:bg-white dark:text-black',
|
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed opacity-60': !availableForSale,
|
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
|
||||||
'cursor-not-allowed': isPending
|
'cursor-not-allowed': isPending
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="absolute left-0 ml-4">
|
||||||
|
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
|
||||||
|
</div>
|
||||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||||
{isPending ? <LoadingDots className="bg-white dark:bg-black" /> : null}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
10
components/cart/close-cart.tsx
Normal file
10
components/cart/close-cart.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function CloseCart({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||||
|
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110 ', className)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,11 +1,11 @@
|
|||||||
import CloseIcon from 'components/icons/close';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import LoadingDots from 'components/loading-dots';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { removeItem } from 'components/cart/actions';
|
||||||
import type { CartItem } from 'lib/shopware/types';
|
import type { CartItem } from 'lib/shopware/types';
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
import { removeItem } from 'components/cart/actions';
|
|
||||||
|
|
||||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -28,16 +28,16 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
|
|||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed px-0': isPending
|
'cursor-not-allowed px-0': isPending
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-white" />
|
||||||
) : (
|
) : (
|
||||||
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
|
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||||
import MinusIcon from 'components/icons/minus';
|
|
||||||
import PlusIcon from 'components/icons/plus';
|
|
||||||
import LoadingDots from 'components/loading-dots';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import type { CartItem } from 'lib/shopware/types';
|
import type { CartItem } from 'lib/shopware/types';
|
||||||
|
|
||||||
@ -42,7 +41,7 @@ export default function EditItemQuantityButton({
|
|||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
'ease flex 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': isPending,
|
'cursor-not-allowed': isPending,
|
||||||
'ml-auto': type === 'minus'
|
'ml-auto': type === 'minus'
|
||||||
@ -52,9 +51,9 @@ export default function EditItemQuantityButton({
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-black dark:bg-white" />
|
||||||
) : type === 'plus' ? (
|
) : type === 'plus' ? (
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
) : (
|
) : (
|
||||||
<MinusIcon className="h-4 w-4" />
|
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -11,14 +11,6 @@ export default async function Cart() {
|
|||||||
resCart = await fetchCart(cartId);
|
resCart = await fetchCart(cartId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let newToken;
|
|
||||||
if (!cartId && !resCart) {
|
|
||||||
resCart = await fetchCart();
|
|
||||||
if (resCart?.token) {
|
|
||||||
newToken = resCart?.token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cart;
|
let cart;
|
||||||
if (resCart) {
|
if (resCart) {
|
||||||
cart = transformCart(resCart);
|
cart = transformCart(resCart);
|
||||||
@ -28,10 +20,5 @@ export default async function Cart() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cartIdUpdated = false;
|
return <CartModal cart={cart} />;
|
||||||
if (cartId !== newToken) {
|
|
||||||
cartIdUpdated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <CartModal cart={cart} cartIdUpdated={cartIdUpdated} />;
|
|
||||||
}
|
}
|
||||||
|
@ -1,63 +1,49 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Dialog, Transition } from '@headlessui/react';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import Image from 'next/image';
|
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import CartIcon from 'components/icons/cart';
|
|
||||||
import CloseIcon from 'components/icons/close';
|
|
||||||
import ShoppingBagIcon from 'components/icons/shopping-bag';
|
|
||||||
import Price from 'components/price';
|
import Price from 'components/price';
|
||||||
import { DEFAULT_OPTION } from 'lib/constants';
|
import { DEFAULT_OPTION } from 'lib/constants';
|
||||||
import type { Cart } from 'lib/shopware/types';
|
import type { Cart } from 'lib/shopware/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
import { useCookies } from 'react-cookie';
|
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';
|
||||||
|
|
||||||
type MerchandiseSearchParams = {
|
type MerchandiseSearchParams = {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdUpdated: boolean }) {
|
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||||
const [, setCookie] = useCookies(['sw-context-token']);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const quantityRef = useRef(cart.totalQuantity);
|
const quantityRef = useRef(cart?.totalQuantity);
|
||||||
const openCart = () => setIsOpen(true);
|
const openCart = () => setIsOpen(true);
|
||||||
const closeCart = () => setIsOpen(false);
|
const closeCart = () => setIsOpen(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cartIdUpdated) {
|
// Open cart modal when quantity changes.
|
||||||
setCookie('sw-context-token', cart.id, {
|
if (cart?.totalQuantity !== quantityRef.current) {
|
||||||
path: '/',
|
|
||||||
sameSite: 'strict',
|
|
||||||
secure: process.env.NODE_ENV === 'production'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, [setCookie, cartIdUpdated, cart.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Open cart modal when when quantity changes.
|
|
||||||
if (cart.totalQuantity !== quantityRef.current) {
|
|
||||||
// But only if it's not already open (quantity also changes when editing items in cart).
|
// But only if it's not already open (quantity also changes when editing items in cart).
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always update the quantity reference
|
// Always update the quantity reference
|
||||||
quantityRef.current = cart.totalQuantity;
|
quantityRef.current = cart?.totalQuantity;
|
||||||
}
|
}
|
||||||
}, [isOpen, cart, quantityRef]);
|
}, [isOpen, cart?.totalQuantity, quantityRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button aria-label="Open cart" onClick={openCart} data-testid="open-cart">
|
<button aria-label="Open cart" onClick={openCart}>
|
||||||
<CartIcon quantity={cart.totalQuantity} />
|
<OpenCart quantity={cart?.totalQuantity} />
|
||||||
</button>
|
</button>
|
||||||
<Transition show={isOpen}>
|
<Transition show={isOpen}>
|
||||||
<Dialog onClose={closeCart} className="relative z-50" data-testid="cart">
|
<Dialog onClose={closeCart} className="relative z-50">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition-all ease-in-out duration-300"
|
enter="transition-all ease-in-out duration-300"
|
||||||
@ -78,27 +64,23 @@ export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdU
|
|||||||
leaveFrom="translate-x-0"
|
leaveFrom="translate-x-0"
|
||||||
leaveTo="translate-x-full"
|
leaveTo="translate-x-full"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col bg-white p-6 text-black dark:bg-black dark:text-white md:w-3/5 lg:w-2/5">
|
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl dark:border-neutral-700 dark:bg-black/80 dark:text-white md:w-[390px]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-lg font-bold">My Cart</p>
|
<p className="text-lg font-semibold">My Cart</p>
|
||||||
<button
|
|
||||||
aria-label="Close cart"
|
<button aria-label="Close cart" onClick={closeCart}>
|
||||||
onClick={closeCart}
|
<CloseCart />
|
||||||
className="text-black transition-colors hover:text-gray-500 dark:text-gray-100"
|
|
||||||
data-testid="close-cart"
|
|
||||||
>
|
|
||||||
<CloseIcon className="h-7" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cart.lines.length === 0 ? (
|
{!cart || cart.lines.length === 0 ? (
|
||||||
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
||||||
<ShoppingBagIcon className="h-16" />
|
<ShoppingCartIcon className="h-16" />
|
||||||
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
|
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col justify-between overflow-hidden">
|
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||||
<ul className="flex-grow overflow-auto p-6">
|
<ul className="flex-grow overflow-auto py-4">
|
||||||
{cart.lines.map((item, i) => {
|
{cart.lines.map((item, i) => {
|
||||||
const merchandiseSearchParams = {} as MerchandiseSearchParams;
|
const merchandiseSearchParams = {} as MerchandiseSearchParams;
|
||||||
|
|
||||||
@ -114,15 +96,20 @@ export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdU
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={i} data-testid="cart-item">
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
||||||
|
>
|
||||||
|
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||||
|
<div className="absolute z-40 -mt-2 ml-[55px]">
|
||||||
|
<DeleteItemButton item={item} />
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
className="flex flex-row space-x-4 py-4"
|
|
||||||
href={merchandiseUrl}
|
href={merchandiseUrl}
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
|
className="z-30 flex flex-row space-x-4"
|
||||||
>
|
>
|
||||||
<div className="relative h-16 w-16 cursor-pointer overflow-hidden bg-white">
|
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||||
{item.merchandise.product.featuredImage.url !== '' &&
|
|
||||||
typeof item.merchandise.product.featuredImage.url !== 'undefined' ? (
|
|
||||||
<Image
|
<Image
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
width={64}
|
width={64}
|
||||||
@ -133,61 +120,55 @@ export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdU
|
|||||||
}
|
}
|
||||||
src={item.merchandise.product.featuredImage.url}
|
src={item.merchandise.product.featuredImage.url}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col text-base">
|
<div className="flex flex-1 flex-col text-base">
|
||||||
<span className="font-semibold">
|
<span className="leading-tight">
|
||||||
{item.merchandise.product.title}
|
{item.merchandise.product.title}
|
||||||
</span>
|
</span>
|
||||||
{item.merchandise.title !== DEFAULT_OPTION ? (
|
{item.merchandise.title !== DEFAULT_OPTION ? (
|
||||||
<p className="text-sm" data-testid="cart-product-variant">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{item.merchandise.title}
|
{item.merchandise.title}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="flex h-16 flex-col justify-between">
|
||||||
<Price
|
<Price
|
||||||
className="flex flex-col justify-between space-y-2 text-sm"
|
className="flex justify-end space-y-2 text-right text-sm"
|
||||||
amount={item.cost.totalAmount.amount}
|
amount={item.cost.totalAmount.amount}
|
||||||
currencyCode={item.cost.totalAmount.currencyCode}
|
currencyCode={item.cost.totalAmount.currencyCode}
|
||||||
/>
|
/>
|
||||||
</Link>
|
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex h-9 flex-row">
|
|
||||||
<DeleteItemButton item={item} />
|
|
||||||
<p className="ml-2 flex w-full items-center justify-center border dark:border-gray-700">
|
|
||||||
<span className="w-full px-2">{item.quantity}</span>
|
|
||||||
</p>
|
|
||||||
<EditItemQuantityButton item={item} type="minus" />
|
<EditItemQuantityButton item={item} type="minus" />
|
||||||
|
<p className="w-6 text-center">
|
||||||
|
<span className="w-full text-sm">{item.quantity}</span>
|
||||||
|
</p>
|
||||||
<EditItemQuantityButton item={item} type="plus" />
|
<EditItemQuantityButton item={item} type="plus" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="border-t border-gray-200 pt-2 text-sm text-black dark:text-white">
|
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||||
<p>Subtotal</p>
|
|
||||||
<Price
|
|
||||||
className="text-right"
|
|
||||||
amount={cart.cost.subtotalAmount.amount}
|
|
||||||
currencyCode={cart.cost.subtotalAmount.currencyCode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<p>Taxes</p>
|
<p>Taxes</p>
|
||||||
<Price
|
<Price
|
||||||
className="text-right"
|
className="text-right text-base text-black dark:text-white"
|
||||||
amount={cart.cost.totalTaxAmount.amount}
|
amount={cart.cost.totalTaxAmount.amount}
|
||||||
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2">
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||||
<p>Shipping</p>
|
<p>Shipping</p>
|
||||||
<p className="text-right">Calculated at checkout</p>
|
<p className="text-right">Calculated at checkout</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex items-center justify-between font-bold">
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||||
<p>Total</p>
|
<p>Total</p>
|
||||||
<Price
|
<Price
|
||||||
className="text-right"
|
className="text-right text-base text-black dark:text-white"
|
||||||
amount={cart.cost.totalAmount.amount}
|
amount={cart.cost.totalAmount.amount}
|
||||||
currencyCode={cart.cost.totalAmount.currencyCode}
|
currencyCode={cart.cost.totalAmount.currencyCode}
|
||||||
/>
|
/>
|
||||||
@ -195,9 +176,9 @@ export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdU
|
|||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={cart.checkoutUrl}
|
href={cart.checkoutUrl}
|
||||||
className="flex w-full items-center justify-center bg-black p-3 text-sm font-medium uppercase text-white opacity-90 hover:opacity-100 dark:bg-white dark:text-black"
|
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<span>Proceed to Checkout</span>
|
Proceed to Checkout
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
24
components/cart/open-cart.tsx
Normal file
24
components/cart/open-cart.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function OpenCart({
|
||||||
|
className,
|
||||||
|
quantity
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
quantity?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||||
|
<ShoppingCartIcon
|
||||||
|
className={clsx('h-4 transition-all ease-in-out hover:scale-110 ', className)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{quantity ? (
|
||||||
|
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
|
||||||
|
{quantity}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import ReactPaginate from 'react-paginate';
|
import ReactPaginate from 'react-paginate';
|
||||||
|
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
@ -52,18 +54,18 @@ export default function Pagination({
|
|||||||
initialPage={currentPage}
|
initialPage={currentPage}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
breakLabel="..."
|
breakLabel="..."
|
||||||
nextLabel=">>"
|
nextLabel=<ArrowRightIcon className="h-5" />
|
||||||
previousLabel="<<"
|
previousLabel=<ArrowLeftIcon className="h-5" />
|
||||||
renderOnZeroPageCount={null}
|
renderOnZeroPageCount={null}
|
||||||
containerClassName="inline sm:flex text-base h-10 mx-auto"
|
containerClassName="inline sm:flex text-base h-10 mx-auto"
|
||||||
activeClassName="active"
|
activeClassName="active"
|
||||||
pageClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white [&.active]:bg-gray-100"
|
pageClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border rounded-lg border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white [&.active]:bg-gray-100"
|
||||||
pageLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
pageLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
||||||
previousClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white [&.disabled]:hidden"
|
previousClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border rounded-lg border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white [&.disabled]:hidden"
|
||||||
previousLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
previousLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
||||||
nextClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white [&.disabled]:hidden"
|
nextClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border rounded-lg border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white [&.disabled]:hidden"
|
||||||
nextLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
nextLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
||||||
breakClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
breakClassName="m-2 sm:m-0 sm:mx-2 text-gray-500 bg-white border rounded-lg border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
breakLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
breakLinkClassName="flex items-center justify-center px-4 h-10 ml-0 leading-tight"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -2,7 +2,7 @@ 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 py-5', props.className)}>
|
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
@ -10,17 +10,12 @@ function Grid(props: React.ComponentProps<'ul'>) {
|
|||||||
|
|
||||||
function GridItem(props: React.ComponentProps<'li'>) {
|
function GridItem(props: React.ComponentProps<'li'>) {
|
||||||
return (
|
return (
|
||||||
<li
|
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
'relative aspect-square h-full w-full overflow-hidden transition-opacity',
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Grid.Item = GridItem;
|
Grid.Item = GridItem;
|
||||||
|
|
||||||
export default Grid;
|
export default Grid;
|
||||||
|
@ -7,25 +7,27 @@ import Link from 'next/link';
|
|||||||
function ThreeItemGridItem({
|
function ThreeItemGridItem({
|
||||||
item,
|
item,
|
||||||
size,
|
size,
|
||||||
background
|
priority
|
||||||
}: {
|
}: {
|
||||||
item: Product;
|
item: Product;
|
||||||
size: 'full' | 'half';
|
size: 'full' | 'half';
|
||||||
background: 'white' | 'pink' | 'purple' | 'black';
|
priority?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'}
|
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||||
>
|
>
|
||||||
<Link className="block h-full" href={`/product/${item.path}`}>
|
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.path}`}>
|
||||||
<GridTileImage
|
<GridTileImage
|
||||||
src={item.featuredImage.url}
|
src={item.featuredImage.url}
|
||||||
width={size === 'full' ? 1080 : 540}
|
fill
|
||||||
height={size === 'full' ? 1080 : 540}
|
sizes={
|
||||||
priority={true}
|
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
|
||||||
background={background}
|
}
|
||||||
|
priority={priority}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
labels={{
|
label={{
|
||||||
|
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
|
||||||
@ -50,10 +52,10 @@ export async function ThreeItemGrid() {
|
|||||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="lg:grid lg:grid-cols-6 lg:grid-rows-2" data-testid="homepage-products">
|
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
|
||||||
<ThreeItemGridItem size="full" item={firstProduct} background="purple" />
|
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||||
<ThreeItemGridItem size="half" item={secondProduct} background="black" />
|
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||||
<ThreeItemGridItem size="half" item={thirdProduct} background="pink" />
|
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,70 +1,50 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Label from '../label';
|
||||||
import Price from 'components/price';
|
|
||||||
|
|
||||||
export function GridTileImage({
|
export function GridTileImage({
|
||||||
isInteractive = true,
|
isInteractive = true,
|
||||||
background,
|
|
||||||
active,
|
active,
|
||||||
labels,
|
label,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
isInteractive?: boolean;
|
isInteractive?: boolean;
|
||||||
background?: 'white' | 'pink' | 'purple' | 'black' | 'purple-dark' | 'blue' | 'cyan' | 'gray';
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
labels?: {
|
label?: {
|
||||||
title: string;
|
title: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
isSmall?: boolean;
|
position?: 'bottom' | 'center';
|
||||||
};
|
};
|
||||||
} & React.ComponentProps<typeof Image>) {
|
} & React.ComponentProps<typeof Image>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden', {
|
className={clsx(
|
||||||
'bg-white dark:bg-white': background === 'white',
|
'flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
|
||||||
'bg-[#ff0080] dark:bg-[#ff0080]': background === 'pink',
|
{
|
||||||
'bg-[#7928ca] dark:bg-[#7928ca]': background === 'purple',
|
relative: label,
|
||||||
'bg-gray-900 dark:bg-gray-900': background === 'black',
|
'border-2 border-blue-600': active,
|
||||||
'bg-violetDark dark:bg-violetDark': background === 'purple-dark',
|
'border-neutral-200 dark:border-neutral-800': !active
|
||||||
'bg-blue-500 dark:bg-blue-500': background === 'blue',
|
}
|
||||||
'bg-cyan-500 dark:bg-cyan-500': background === 'cyan',
|
)}
|
||||||
'bg-gray-100 dark:bg-gray-100': background === 'gray',
|
|
||||||
'bg-gray-100 dark:bg-gray-900': !background,
|
|
||||||
relative: labels
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{active !== undefined && active ? (
|
|
||||||
<span className="absolute h-full w-full bg-white opacity-25"></span>
|
|
||||||
) : null}
|
|
||||||
{props.src ? (
|
{props.src ? (
|
||||||
|
// 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 hover:scale-105': isInteractive,
|
'transition duration-300 ease-in-out hover:scale-105': isInteractive,
|
||||||
'm-4 max-h-[8rem] min-h-[8rem]': props.width === 200 && props.height === 200 // this styling is for the thumbnails below gallery on product detail page
|
'max-h-[4rem] min-h-[4rem]': props.width === 200 && props.height === 200 // this styling is for the thumbnails below gallery on product detail page
|
||||||
})}
|
})}
|
||||||
{...props}
|
{...props}
|
||||||
alt={props.title || ''}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{labels ? (
|
{label ? (
|
||||||
<div className="absolute left-0 top-0 w-3/4 text-black dark:text-white">
|
<Label
|
||||||
<h3
|
title={label.title}
|
||||||
data-testid="product-name"
|
amount={label.amount}
|
||||||
className={clsx(
|
currencyCode={label.currencyCode}
|
||||||
'inline bg-white box-decoration-clone py-3 pl-5 font-semibold leading-loose shadow-[1.25rem_0_0] shadow-white dark:bg-black dark:shadow-black',
|
position={label.position}
|
||||||
!labels.isSmall ? 'text-3xl' : 'text-lg'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{labels.title}
|
|
||||||
</h3>
|
|
||||||
<Price
|
|
||||||
className="w-fit bg-white px-5 py-3 text-sm font-semibold dark:bg-black dark:text-white"
|
|
||||||
amount={labels.amount}
|
|
||||||
currencyCode={labels.currencyCode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
export default function ArrowLeftIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M19 12H5" />
|
|
||||||
<path d="M12 19L5 12L12 5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
export default function CaretRightIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M9 18l6-6-6-6" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import ShoppingBagIcon from './shopping-bag';
|
|
||||||
|
|
||||||
export default function CartIcon({
|
|
||||||
className,
|
|
||||||
quantity
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
quantity?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<ShoppingBagIcon
|
|
||||||
className={clsx(
|
|
||||||
'h-6 transition-all ease-in-out hover:scale-110 hover:text-gray-500 dark:hover:text-gray-300',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{quantity ? (
|
|
||||||
<div className="absolute bottom-0 left-0 -mb-3 -ml-3 flex h-5 w-5 items-center justify-center rounded-full border-2 border-white bg-black text-xs text-white dark:border-black dark:bg-white dark:text-black">
|
|
||||||
{quantity}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
export default function CloseIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M18 6L6 18" />
|
|
||||||
<path d="M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,20 +1,18 @@
|
|||||||
export default function LogoIcon({ className }: { className?: string }) {
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
export type ImageProps = {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LogoIcon(props: ImageProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<Image
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
src="https://www.shopware.com/media/pages/solutions/shopware-frontends/shopware-frontends-intro-graphic-base.svg"
|
||||||
aria-label={`${process.env.SITE_NAME} logo`}
|
alt="Shopware Composable Frontends Logo"
|
||||||
viewBox="0 0 32 32"
|
width={Number(props.width)}
|
||||||
strokeWidth="1.5"
|
height={Number(props.height)}
|
||||||
strokeLinecap="round"
|
></Image>
|
||||||
strokeLinejoin="round"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<rect width="100%" height="100%" rx="16" className="fill-black dark:fill-white" />
|
|
||||||
<path
|
|
||||||
className=" fill-white dark:fill-black"
|
|
||||||
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
export default function MenuIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M4 6h16M4 12h16m-7 6h7" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
export default function MinusIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M5 12H19" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
export default function PlusIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M12 5V19" />
|
|
||||||
<path d="M5 12H19" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
export default function SearchIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
export default function ShoppingBagIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 22"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M4 1L1 5V19C1 19.5304 1.21071 20.0391 1.58579 20.4142C1.96086 20.7893 2.46957 21 3 21H17C17.5304 21 18.0391 20.7893 18.4142 20.4142C18.7893 20.0391 19 19.5304 19 19V5L16 1H4Z" />
|
|
||||||
<path d="M1 5H19" />
|
|
||||||
<path d="M14 9C14 10.0609 13.5786 11.0783 12.8284 11.8284C12.0783 12.5786 11.0609 13 10 13C8.93913 13 7.92172 12.5786 7.17157 11.8284C6.42143 11.0783 6 10.0609 6 9" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
export default function VercelIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-label="Vercel.com Logo"
|
|
||||||
viewBox="0 0 89 20"
|
|
||||||
fill="currentColor"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M11.5625 0L23.125 20H0L11.5625 0Z" />
|
|
||||||
<path d="M49.875 10.625C49.875 7.40625 47.5 5.15625 44.0937 5.15625C40.6875 5.15625 38.3125 7.40625 38.3125 10.625C38.3125 13.7812 40.875 16.0937 44.4062 16.0937C46.3438 16.0937 48.0938 15.375 49.2188 14.0625L47.0938 12.8437C46.4375 13.5 45.4688 13.9062 44.4062 13.9062C42.8438 13.9062 41.5 13.0625 41.0312 11.7812L40.9375 11.5625H49.7812C49.8438 11.25 49.875 10.9375 49.875 10.625ZM40.9062 9.6875L40.9688 9.5C41.375 8.15625 42.5625 7.34375 44.0625 7.34375C45.5938 7.34375 46.75 8.15625 47.1562 9.5L47.2188 9.6875H40.9062Z" />
|
|
||||||
<path d="M83.5313 10.625C83.5313 7.40625 81.1563 5.15625 77.75 5.15625C74.3438 5.15625 71.9688 7.40625 71.9688 10.625C71.9688 13.7812 74.5313 16.0937 78.0625 16.0937C80 16.0937 81.75 15.375 82.875 14.0625L80.75 12.8437C80.0938 13.5 79.125 13.9062 78.0625 13.9062C76.5 13.9062 75.1563 13.0625 74.6875 11.7812L74.5938 11.5625H83.4375C83.5 11.25 83.5313 10.9375 83.5313 10.625ZM74.5625 9.6875L74.625 9.5C75.0313 8.15625 76.2188 7.34375 77.7188 7.34375C79.25 7.34375 80.4063 8.15625 80.8125 9.5L80.875 9.6875H74.5625Z" />
|
|
||||||
<path d="M68.5313 8.84374L70.6563 7.62499C69.6563 6.06249 67.875 5.18749 65.7188 5.18749C62.3125 5.18749 59.9375 7.43749 59.9375 10.6562C59.9375 13.875 62.3125 16.125 65.7188 16.125C67.875 16.125 69.6563 15.25 70.6563 13.6875L68.5313 12.4687C67.9688 13.4062 66.9688 13.9375 65.7188 13.9375C63.75 13.9375 62.4375 12.625 62.4375 10.6562C62.4375 8.68749 63.75 7.37499 65.7188 7.37499C66.9375 7.37499 67.9688 7.90624 68.5313 8.84374Z" />
|
|
||||||
<path d="M88.2188 1.75H85.7188V15.8125H88.2188V1.75Z" />
|
|
||||||
<path d="M40.1563 1.75H37.2813L31.7813 11.25L26.2813 1.75H23.375L31.7813 16.25L40.1563 1.75Z" />
|
|
||||||
<path d="M57.8438 8.0625C58.125 8.0625 58.4062 8.09375 58.6875 8.15625V5.5C56.5625 5.5625 54.5625 6.75 54.5625 8.21875V5.5H52.0625V15.8125H54.5625V11.3437C54.5625 9.40625 55.9062 8.0625 57.8438 8.0625Z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
34
components/label.tsx
Normal file
34
components/label.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import Price from './price';
|
||||||
|
|
||||||
|
const Label = ({
|
||||||
|
title,
|
||||||
|
amount,
|
||||||
|
currencyCode,
|
||||||
|
position = 'bottom'
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
amount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
position?: 'bottom' | 'center';
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
|
||||||
|
'lg:px-20 lg:pb-[35%]': position === 'center'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center rounded-full border bg-white/70 p-1 text-[10px] font-semibold text-black backdrop-blur-md @[275px]/label:text-xs dark:border-neutral-800 dark:bg-black/70 dark:text-white">
|
||||||
|
<h3 className="mr-4 inline pl-2 leading-none tracking-tight">{title}</h3>
|
||||||
|
<Price
|
||||||
|
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||||
|
amount={amount}
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
currencyCodeClassName="hidden @[275px]/label:inline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Label;
|
58
components/layout/footer-menu.tsx
Normal file
58
components/layout/footer-menu.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Menu } from 'lib/shopware/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const FooterMenuItem = ({ item }: { item: Menu }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [active, setActive] = useState(pathname === item.path);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(pathname === item.path);
|
||||||
|
}, [pathname, item.path]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={item.path}
|
||||||
|
className={clsx(
|
||||||
|
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm',
|
||||||
|
{
|
||||||
|
'text-black dark:text-neutral-300': active
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FooterMenu({ menu }: { menu: Menu[] }) {
|
||||||
|
if (!menu.length) return null;
|
||||||
|
|
||||||
|
return menu.map((item: Menu) => (
|
||||||
|
<nav className="col-span-1 lg:col-span-3" key={item.title + item.type}>
|
||||||
|
{item.type === 'headline' ? <span className="font-bold">{item.title}</span> : null}
|
||||||
|
{item.children.length > 0 ? (
|
||||||
|
<ul className="py-3 md:py-0 md:pt-4" key={item.title}>
|
||||||
|
{item.children.map((item: Menu) => (
|
||||||
|
<FooterMenuItem key={item.title} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
// if there are no children, at least display a link
|
||||||
|
<Link
|
||||||
|
key={item.title}
|
||||||
|
href={item.path}
|
||||||
|
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
));
|
||||||
|
}
|
@ -2,58 +2,45 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
import GitHubIcon from 'components/icons/github';
|
import GitHubIcon from 'components/icons/github';
|
||||||
import LogoIcon from 'components/icons/logo';
|
import FooterMenu from 'components/layout/footer-menu';
|
||||||
|
import LogoSquare from 'components/logo-square';
|
||||||
import { getMenu } from 'lib/shopware';
|
import { getMenu } from 'lib/shopware';
|
||||||
import { Menu } from 'lib/shopware/types';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
const { 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 menu = await getMenu({ type: 'footer-navigation', depth: 2 });
|
const menu = await getMenu({ type: 'footer-navigation', depth: 2 });
|
||||||
|
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-gray-700 bg-white text-black dark:bg-black dark:text-white">
|
<footer className="text-sm text-neutral-400 dark:text-neutral-600">
|
||||||
<div className="mx-auto w-full max-w-7xl px-6">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
|
||||||
<div className="grid grid-cols-1 gap-8 border-b border-gray-700 py-12 transition-colors duration-150 lg:grid-cols-12">
|
<div className="grid grid-cols-1 gap-8 transition-colors duration-150 lg:grid-cols-12">
|
||||||
<div className="col-span-1 lg:col-span-4">
|
<div className="col-span-1 lg:col-span-3">
|
||||||
<a className="flex flex-initial items-center font-bold" href="/">
|
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
|
||||||
<span className="mr-4">
|
<LogoSquare size="sm" />
|
||||||
<LogoIcon className="h-8" />
|
<span className="uppercase">{SITE_NAME}</span>
|
||||||
</span>
|
</Link>
|
||||||
<span>{SITE_NAME}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{menu.map((item: Menu) => (
|
<Suspense
|
||||||
<nav className="col-span-1 lg:col-span-3" key={item.title + item.type}>
|
fallback={
|
||||||
{item.type === 'headline' ? <span className="font-bold">{item.title}</span> : null}
|
<div className="flex h-[188px] w-[200px] flex-col gap-2">
|
||||||
{item.children.length > 0 ? (
|
<div className={skeleton} />
|
||||||
<ul className="py-3 md:py-0 md:pt-4" key={item.title}>
|
<div className={skeleton} />
|
||||||
{item.children.map((item: Menu) => (
|
<div className={skeleton} />
|
||||||
<li key={item.title} className="py-3 md:py-0 md:pb-4">
|
<div className={skeleton} />
|
||||||
<Link
|
<div className={skeleton} />
|
||||||
href={item.path}
|
<div className={skeleton} />
|
||||||
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{item.title}
|
<FooterMenu menu={menu} />
|
||||||
</Link>
|
</Suspense>
|
||||||
</li>
|
<div className="md:ml-auto">
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
// if there are no children, at least display a link
|
|
||||||
<Link
|
|
||||||
key={item.title}
|
|
||||||
href={item.path}
|
|
||||||
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
))}
|
|
||||||
<div className="col-span-1 inline-grid justify-items-end text-black dark:text-white lg:col-span-2">
|
|
||||||
<a
|
<a
|
||||||
aria-label="Github Repository"
|
aria-label="Github Repository"
|
||||||
href="https://github.com/shopware/frontends"
|
href="https://github.com/shopware/frontends"
|
||||||
@ -64,14 +51,16 @@ export default async function Footer() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center justify-between space-y-4 pb-10 pt-6 text-sm md:flex-row">
|
</div>
|
||||||
|
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 xl:px-0">
|
||||||
<p>
|
<p>
|
||||||
© {copyrightDate} {SITE_NAME}. All rights reserved.
|
© {copyrightDate} {copyrightName}
|
||||||
|
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center text-sm text-white dark:text-black">
|
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
|
||||||
<span className="text-black dark:text-white">
|
<p>Created by Shopware Composable Frontends</p>
|
||||||
Created by Shopware Composable Frontends
|
<div className="md:ml-auto">
|
||||||
</span>
|
|
||||||
<a
|
<a
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
href="https://frontends.shopware.com/"
|
href="https://frontends.shopware.com/"
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
import Cart from 'components/cart';
|
import Cart from 'components/cart';
|
||||||
import CartIcon from 'components/icons/cart';
|
import OpenCart from 'components/cart/open-cart';
|
||||||
import LogoIcon from 'components/icons/logo';
|
import LogoSquare from 'components/logo-square';
|
||||||
import { getMenu } from 'lib/shopware';
|
import { getMenu } from 'lib/shopware';
|
||||||
import { Menu } from 'lib/shopware/types';
|
import { Menu } from 'lib/shopware/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import MobileMenu from './mobile-menu';
|
import MobileMenu from './mobile-menu';
|
||||||
import Search from './search';
|
import Search from './search';
|
||||||
|
|
||||||
@ -13,23 +12,26 @@ export default async function Navbar() {
|
|||||||
const menu = await getMenu({ type: 'main-navigation' });
|
const menu = await getMenu({ type: 'main-navigation' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="relative flex items-center justify-between bg-white p-4 dark:bg-black lg:px-6">
|
<nav className="relative flex items-center justify-between p-4 lg:px-6">
|
||||||
<div className="block w-1/3 md:hidden">
|
<div className="block flex-none md:hidden">
|
||||||
<MobileMenu menu={menu} />
|
<MobileMenu menu={menu} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-self-center md:w-6/12 md:justify-self-start">
|
<div className="flex w-full items-center">
|
||||||
<div className="md:mr-4">
|
<div className="flex w-full md:w-4/6">
|
||||||
<Link href="/" aria-label="Go back home">
|
<Link
|
||||||
<LogoIcon className="h-8 transition-transform hover:scale-110" />
|
href="/"
|
||||||
|
aria-label="Go back home"
|
||||||
|
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
||||||
|
>
|
||||||
|
<LogoSquare />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
{menu.length ? (
|
{menu.length ? (
|
||||||
<ul className="hidden md:flex md:items-center">
|
<ul className="hidden gap-6 text-sm md:flex md:items-center">
|
||||||
{menu.map((item: Menu) => (
|
{menu.map((item: Menu) => (
|
||||||
<li key={item.title}>
|
<li key={item.title}>
|
||||||
<Link
|
<Link
|
||||||
href={item.path}
|
href={item.path}
|
||||||
className="rounded-lg px-2 py-1 text-gray-800 hover:text-gray-500 dark:text-gray-200 dark:hover:text-gray-400"
|
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
@ -38,15 +40,15 @@ export default async function Navbar() {
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden w-3/12 md:block">
|
<div className="hidden justify-center md:flex md:w-1/6">
|
||||||
<Search />
|
<Search />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-end md:w-1/6">
|
||||||
<div className="flex w-3/12 justify-end">
|
<Suspense fallback={<OpenCart />}>
|
||||||
<Suspense fallback={<CartIcon className="h-6" />}>
|
|
||||||
<Cart />
|
<Cart />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,7 @@ 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 CloseIcon from 'components/icons/close';
|
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import MenuIcon from 'components/icons/menu';
|
|
||||||
import { Menu } from 'lib/shopware/types';
|
import { Menu } from 'lib/shopware/types';
|
||||||
import Search from './search';
|
import Search from './search';
|
||||||
|
|
||||||
@ -36,10 +35,9 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
|||||||
<button
|
<button
|
||||||
onClick={openMobileMenu}
|
onClick={openMobileMenu}
|
||||||
aria-label="Open mobile menu"
|
aria-label="Open mobile menu"
|
||||||
className="md:hidden"
|
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white md:hidden"
|
||||||
data-testid="open-mobile-menu"
|
|
||||||
>
|
>
|
||||||
<MenuIcon className="h-6" />
|
<Bars3Icon className="h-4" />
|
||||||
</button>
|
</button>
|
||||||
<Transition show={isOpen}>
|
<Transition show={isOpen}>
|
||||||
<Dialog onClose={closeMobileMenu} className="relative z-50">
|
<Dialog onClose={closeMobileMenu} className="relative z-50">
|
||||||
@ -66,26 +64,24 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
|||||||
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
|
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<button
|
<button
|
||||||
className="mb-4"
|
className="mb-4 flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"
|
||||||
onClick={closeMobileMenu}
|
onClick={closeMobileMenu}
|
||||||
aria-label="Close mobile menu"
|
aria-label="Close mobile menu"
|
||||||
data-testid="close-mobile-menu"
|
|
||||||
>
|
>
|
||||||
<CloseIcon className="h-6" />
|
<XMarkIcon className="h-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mb-4 w-full">
|
<div className="mb-4 w-full">
|
||||||
<Search />
|
<Search />
|
||||||
</div>
|
</div>
|
||||||
{menu.length ? (
|
{menu.length ? (
|
||||||
<ul className="flex flex-col">
|
<ul className="flex w-full flex-col">
|
||||||
{menu.map((item: Menu) => (
|
{menu.map((item: Menu) => (
|
||||||
<li key={item.title}>
|
<li
|
||||||
<Link
|
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
|
||||||
href={item.path}
|
key={item.title}
|
||||||
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-gray-500 dark:text-white"
|
|
||||||
onClick={closeMobileMenu}
|
|
||||||
>
|
>
|
||||||
|
<Link href={item.path} onClick={closeMobileMenu}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import SearchIcon from 'components/icons/search';
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchValue(searchParams?.get('q') || '');
|
||||||
|
}, [searchParams, setSearchValue]);
|
||||||
|
|
||||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -26,20 +32,18 @@ export default function Search() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||||
onSubmit={onSubmit}
|
|
||||||
className="relative m-0 flex w-full items-center border border-gray-200 bg-transparent p-0 dark:border-gray-500"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="search"
|
name="search"
|
||||||
placeholder="Search for products..."
|
placeholder="Search for products..."
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
defaultValue={searchParams?.get('q') || ''}
|
value={searchValue}
|
||||||
className="w-full px-4 py-2 text-black dark:bg-black dark:text-gray-100"
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||||
<SearchIcon className="h-5" />
|
<MagnifyingGlassIcon className="h-4" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
@ -8,18 +8,17 @@ export default function ProductGridItems({ products }: { products: Product[] })
|
|||||||
<>
|
<>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<Grid.Item key={product.path} className="animate-fadeIn">
|
<Grid.Item key={product.path} className="animate-fadeIn">
|
||||||
<Link className="h-full w-full" href={`/product/${product.path}`}>
|
<Link className="relative inline-block h-full w-full" href={`/product/${product.path}`}>
|
||||||
<GridTileImage
|
<GridTileImage
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
labels={{
|
label={{
|
||||||
isSmall: true,
|
|
||||||
title: product.title,
|
title: product.title,
|
||||||
amount: product.priceRange.maxVariantPrice.amount,
|
amount: product.priceRange.maxVariantPrice.amount,
|
||||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||||
}}
|
}}
|
||||||
src={product.featuredImage?.url}
|
src={product.featuredImage?.url}
|
||||||
width={600}
|
fill
|
||||||
height={600}
|
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
|
@ -14,14 +14,14 @@ async function CollectionList({ collection }: { collection: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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-gray-800 dark:bg-gray-300';
|
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
|
||||||
const items = 'bg-gray-400 dark:bg-gray-700';
|
const items = 'bg-neutral-400 dark:bg-neutral-700';
|
||||||
|
|
||||||
export default function Collections({ collection }: { collection: string }) {
|
export default function Collections({ collection }: { collection: string }) {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 pl-10 lg:block">
|
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 lg:block">
|
||||||
<div className={clsx(skeleton, activeAndTitles)} />
|
<div className={clsx(skeleton, activeAndTitles)} />
|
||||||
<div className={clsx(skeleton, activeAndTitles)} />
|
<div className={clsx(skeleton, activeAndTitles)} />
|
||||||
<div className={clsx(skeleton, items)} />
|
<div className={clsx(skeleton, items)} />
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
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 Caret from 'components/icons/caret-right';
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import type { ListItem } from '.';
|
import type { ListItem } from '.';
|
||||||
import { FilterItem } from './item';
|
import { FilterItem } from './item';
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
|
|||||||
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
|
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
|
||||||
>
|
>
|
||||||
<div>{active}</div>
|
<div>{active}</div>
|
||||||
<Caret className="h-4 rotate-90" />
|
<ChevronDownIcon className="h-4" />
|
||||||
</div>
|
</div>
|
||||||
{openSelect && (
|
{openSelect && (
|
||||||
<div
|
<div
|
||||||
|
@ -7,21 +7,19 @@ export type PathFilterItem = { title: string; path: string };
|
|||||||
|
|
||||||
function FilterItemList({ list }: { list: ListItem[] }) {
|
function FilterItemList({ list }: { list: ListItem[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="hidden md:block">
|
<>
|
||||||
{list.map((item: ListItem, i) => (
|
{list.map((item: ListItem, i) => (
|
||||||
<FilterItem key={i} item={item} />
|
<FilterItem key={i} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
|
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="col-span-2 w-full flex-none px-6 py-2 md:py-4 md:pl-10">
|
<nav>
|
||||||
{title ? (
|
{title ? <h3 className="hidden text-xs text-neutral-500 md:block">{title}</h3> : null}
|
||||||
<h3 className="hidden font-semibold text-black dark:text-white md:block">{title}</h3>
|
|
||||||
) : null}
|
|
||||||
<ul className="hidden md:block">
|
<ul className="hidden md:block">
|
||||||
<FilterItemList list={list} />
|
<FilterItemList list={list} />
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -5,32 +5,30 @@ 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 { useEffect, useState } from 'react';
|
|
||||||
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, setActive] = useState(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;
|
||||||
|
|
||||||
newParams.delete('q');
|
newParams.delete('q');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActive(pathname === item.path);
|
|
||||||
}, [pathname, item.path]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
|
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
|
||||||
<Link
|
<DynamicTag
|
||||||
href={createUrl(item.path, newParams)}
|
href={createUrl(item.path, newParams)}
|
||||||
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
|
className={clsx(
|
||||||
'text-gray-600 dark:text-gray-400': !active,
|
'w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100',
|
||||||
'font-semibold text-black dark:text-white': active
|
{
|
||||||
})}
|
'underline underline-offset-4': active
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</DynamicTag>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -38,14 +36,9 @@ 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, setActive] = useState(searchParams.get('sort') === item.slug);
|
const active = searchParams.get('sort') === item.slug;
|
||||||
const q = searchParams.get('q');
|
const q = searchParams.get('q');
|
||||||
const page = searchParams.get('page');
|
const page = searchParams.get('page');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActive(searchParams.get('sort') === item.slug);
|
|
||||||
}, [searchParams, item.slug]);
|
|
||||||
|
|
||||||
const href =
|
const href =
|
||||||
item.slug && item.slug.length
|
item.slug && item.slug.length
|
||||||
? createUrl(
|
? createUrl(
|
||||||
@ -57,19 +50,19 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
: pathname;
|
: pathname;
|
||||||
|
const DynamicTag = active ? 'p' : Link;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
|
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}>
|
||||||
<Link
|
<DynamicTag
|
||||||
prefetch={false}
|
prefetch={!active ? false : undefined}
|
||||||
href={href}
|
href={href}
|
||||||
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
|
className={clsx('w-full', {
|
||||||
'text-gray-600 dark:text-gray-400': !active,
|
'underline underline-offset-4': active
|
||||||
'font-semibold text-black dark:text-white': active
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</DynamicTag>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
25
components/logo-square.tsx
Normal file
25
components/logo-square.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import LogoIcon from './icons/logo';
|
||||||
|
|
||||||
|
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black',
|
||||||
|
{
|
||||||
|
'h-[40px] w-[40px] rounded-xl': !size,
|
||||||
|
'h-[30px] w-[30px] rounded-lg': size === 'sm'
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LogoIcon
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className={clsx({
|
||||||
|
'h-[16px] w-[16px]': !size,
|
||||||
|
'h-[10px] w-[10px]': size === 'sm'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { ImageResponse } from 'next/server';
|
import { ImageResponse } from 'next/server';
|
||||||
|
import LogoIcon from './icons/logo';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -15,15 +16,9 @@ export default async function OpengraphImage(props?: Props): Promise<ImageRespon
|
|||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
||||||
<svg viewBox="0 0 32 32" width="140">
|
<div tw="flex flex-none items-center justify-center border border-neutral-700 h-[160px] w-[160px] rounded-3xl">
|
||||||
<rect width="100%" height="100%" rx="16" fill="white" />
|
<LogoIcon width="64" height="58" />
|
||||||
<path
|
</div>
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
fill="black"
|
|
||||||
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
|
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
@ -1,17 +1,23 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
const Price = ({
|
const Price = ({
|
||||||
amount,
|
amount,
|
||||||
|
className,
|
||||||
currencyCode = 'USD',
|
currencyCode = 'USD',
|
||||||
...props
|
currencyCodeClassName
|
||||||
}: {
|
}: {
|
||||||
amount: string;
|
amount: string;
|
||||||
|
className?: string;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
|
currencyCodeClassName?: string;
|
||||||
} & React.ComponentProps<'p'>) => (
|
} & React.ComponentProps<'p'>) => (
|
||||||
<p suppressHydrationWarning={true} {...props}>
|
<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))} ${currencyCode}`}
|
}).format(parseFloat(amount))}`}
|
||||||
|
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,98 +1,98 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { GridTileImage } from 'components/grid/tile';
|
import { GridTileImage } from 'components/grid/tile';
|
||||||
import ArrowLeftIcon from 'components/icons/arrow-left';
|
import { createUrl } from 'lib/utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
export function Gallery({
|
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||||
title,
|
const pathname = usePathname();
|
||||||
amount,
|
const searchParams = useSearchParams();
|
||||||
currencyCode,
|
const imageSearchParam = searchParams.get('image');
|
||||||
images
|
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
amount: string;
|
|
||||||
currencyCode: string;
|
|
||||||
images: { src: string; altText: string }[];
|
|
||||||
}) {
|
|
||||||
const [currentImage, setCurrentImage] = useState(0);
|
|
||||||
|
|
||||||
function handleNavigate(direction: 'next' | 'previous') {
|
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
if (direction === 'next') {
|
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
|
||||||
setCurrentImage(currentImage + 1 < images.length ? currentImage + 1 : 0);
|
nextSearchParams.set('image', nextImageIndex.toString());
|
||||||
} else {
|
const nextUrl = createUrl(pathname, nextSearchParams);
|
||||||
setCurrentImage(currentImage === 0 ? images.length - 1 : currentImage - 1);
|
|
||||||
}
|
const previousSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
}
|
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||||
|
previousSearchParams.set('image', previousImageIndex.toString());
|
||||||
|
const previousUrl = createUrl(pathname, previousSearchParams);
|
||||||
|
|
||||||
const buttonClassName =
|
const buttonClassName =
|
||||||
'px-9 cursor-pointer ease-in-and-out duration-200 transition-bg bg-[#7928ca] hover:bg-violetDark';
|
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="mr-8 h-full">
|
||||||
<div className="relative h-full max-h-[600px] overflow-hidden">
|
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
|
||||||
{images[currentImage] && (
|
{images[imageIndex] && (
|
||||||
<GridTileImage
|
<Image
|
||||||
src={images[currentImage]?.src as string}
|
className="h-full w-full object-contain"
|
||||||
alt={images[currentImage]?.altText as string}
|
fill
|
||||||
width={600}
|
sizes="(min-width: 1024px) 66vw, 100vw"
|
||||||
height={600}
|
alt={images[imageIndex]?.altText as string}
|
||||||
isInteractive={false}
|
src={images[imageIndex]?.src as string}
|
||||||
priority={true}
|
priority={true}
|
||||||
background="purple"
|
|
||||||
labels={{
|
|
||||||
title,
|
|
||||||
amount,
|
|
||||||
currencyCode
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{images.length > 1 ? (
|
{images.length > 1 ? (
|
||||||
<div className="absolute bottom-10 right-10 flex h-12 flex-row border border-white text-white shadow-xl dark:border-black dark:text-black">
|
<div className="absolute bottom-[15%] flex w-full justify-center">
|
||||||
<button
|
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
|
||||||
|
<Link
|
||||||
aria-label="Previous product image"
|
aria-label="Previous product image"
|
||||||
className={clsx(buttonClassName, 'border-r border-white dark:border-black')}
|
href={previousUrl}
|
||||||
onClick={() => handleNavigate('previous')}
|
className={buttonClassName}
|
||||||
|
scroll={false}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-6" />
|
<ArrowLeftIcon className="h-5" />
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
|
||||||
|
<Link
|
||||||
aria-label="Next product image"
|
aria-label="Next product image"
|
||||||
className={clsx(buttonClassName)}
|
href={nextUrl}
|
||||||
onClick={() => handleNavigate('next')}
|
className={buttonClassName}
|
||||||
|
scroll={false}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-6 rotate-180" />
|
<ArrowRightIcon className="h-5" />
|
||||||
</button>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{images.length > 1 ? (
|
{images.length > 1 ? (
|
||||||
<div className="flex">
|
<ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
|
||||||
{images.map((image, index) => {
|
{images.map((image, index) => {
|
||||||
const isActive = index === currentImage;
|
const isActive = index === imageIndex;
|
||||||
|
const imageSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
imageSearchParams.set('image', index.toString());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<li key={image.src} className="h-auto w-20">
|
||||||
|
<Link
|
||||||
aria-label="Enlarge product image"
|
aria-label="Enlarge product image"
|
||||||
key={image.src}
|
href={createUrl(pathname, imageSearchParams)}
|
||||||
className="h-full w-1/4"
|
scroll={false}
|
||||||
onClick={() => setCurrentImage(index)}
|
className="h-full w-full"
|
||||||
>
|
>
|
||||||
<GridTileImage
|
<GridTileImage
|
||||||
alt={image?.altText}
|
alt={image.altText}
|
||||||
src={image.src}
|
src={image.src}
|
||||||
width={200}
|
width={200}
|
||||||
height={200}
|
height={200}
|
||||||
background="purple-dark"
|
|
||||||
active={isActive}
|
active={isActive}
|
||||||
/>
|
/>
|
||||||
</button>
|
</Link>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
35
components/product/product-description.tsx
Normal file
35
components/product/product-description.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { AddToCart } from 'components/cart/add-to-cart';
|
||||||
|
import Price from 'components/price';
|
||||||
|
import Prose from 'components/prose';
|
||||||
|
import { Product } from 'lib/shopware/types';
|
||||||
|
import { VariantSelector } from './variant-selector';
|
||||||
|
|
||||||
|
export function ProductDescription({ product }: { product: Product }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
||||||
|
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1>
|
||||||
|
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
|
||||||
|
<Price
|
||||||
|
amount={product.priceRange.maxVariantPrice.amount}
|
||||||
|
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VariantSelector options={product.options} variants={product.variants} />
|
||||||
|
|
||||||
|
<AddToCart
|
||||||
|
product={product}
|
||||||
|
variants={product.variants}
|
||||||
|
availableForSale={product.availableForSale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{product.descriptionHtml ? (
|
||||||
|
<Prose
|
||||||
|
className="m-6 max-h-96 overflow-y-auto text-sm leading-tight dark:text-white/[60%]"
|
||||||
|
html={product.descriptionHtml}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -4,17 +4,12 @@ import clsx from 'clsx';
|
|||||||
import { ProductOption, ProductVariant } from 'lib/shopware/types';
|
import { ProductOption, ProductVariant } from 'lib/shopware/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
type ParamsMap = {
|
type Combination = {
|
||||||
[key: string]: string; // ie. { color: 'Red', size: 'Large', ... }
|
|
||||||
};
|
|
||||||
|
|
||||||
type OptimizedVariant = {
|
|
||||||
id: string;
|
id: string;
|
||||||
availableForSale: boolean;
|
availableForSale: boolean;
|
||||||
params: URLSearchParams;
|
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
|
||||||
[key: string]: string | boolean | URLSearchParams; // ie. { color: 'Red', size: 'Large', ... }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VariantSelector({
|
export function VariantSelector({
|
||||||
@ -25,8 +20,7 @@ export function VariantSelector({
|
|||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const currentParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
|
||||||
const hasNoOptionsOrJustOneOption =
|
const hasNoOptionsOrJustOneOption =
|
||||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||||
|
|
||||||
@ -34,96 +28,77 @@ export function VariantSelector({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard any unexpected options or values from url and create params map.
|
const combinations: Combination[] = variants.map((variant) => ({
|
||||||
const paramsMap: ParamsMap = Object.fromEntries(
|
|
||||||
Array.from(currentParams.entries()).filter(([key, value]) =>
|
|
||||||
options.find((option) => option.name.toLowerCase() === key && option.values.includes(value))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Optimize variants for easier lookups.
|
|
||||||
const optimizedVariants: OptimizedVariant[] = variants.map((variant) => {
|
|
||||||
const optimized: OptimizedVariant = {
|
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
availableForSale: variant.availableForSale,
|
availableForSale: variant.availableForSale,
|
||||||
params: new URLSearchParams()
|
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
|
||||||
};
|
...variant.selectedOptions.reduce(
|
||||||
|
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||||
variant.selectedOptions.forEach((selectedOption) => {
|
{}
|
||||||
const name = selectedOption.name.toLowerCase();
|
)
|
||||||
const value = selectedOption.value;
|
}));
|
||||||
|
|
||||||
optimized[name] = value;
|
|
||||||
optimized.params.set(name, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return optimized;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the first variant that is:
|
|
||||||
//
|
|
||||||
// 1. Available for sale
|
|
||||||
// 2. Matches all options specified in the url (note that this
|
|
||||||
// could be a partial match if some options are missing from the url).
|
|
||||||
//
|
|
||||||
// If no match (full or partial) is found, use the first variant that is
|
|
||||||
// available for sale.
|
|
||||||
const selectedVariant: OptimizedVariant | undefined =
|
|
||||||
optimizedVariants.find(
|
|
||||||
(variant) =>
|
|
||||||
variant.availableForSale &&
|
|
||||||
Object.entries(paramsMap).every(([key, value]) => variant[key] === value)
|
|
||||||
) || optimizedVariants.find((variant) => variant.availableForSale);
|
|
||||||
|
|
||||||
const selectedVariantParams = new URLSearchParams(selectedVariant?.params);
|
|
||||||
const currentUrl = createUrl(pathname, currentParams);
|
|
||||||
const selectedVariantUrl = createUrl(pathname, selectedVariantParams);
|
|
||||||
|
|
||||||
if (currentUrl !== selectedVariantUrl) {
|
|
||||||
router.replace(selectedVariantUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return options.map((option) => (
|
return options.map((option) => (
|
||||||
<dl className="mb-8" key={option.id}>
|
<dl className="mb-8" key={option.id}>
|
||||||
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||||
<dd className="flex flex-wrap gap-3">
|
<dd className="flex flex-wrap gap-3">
|
||||||
{option.values.map((value) => {
|
{option.values.map((value) => {
|
||||||
// Base option params on selected variant params.
|
const optionNameLowerCase = option.name.toLowerCase();
|
||||||
const optionParams = new URLSearchParams(selectedVariantParams);
|
|
||||||
// Update the params using the current option to reflect how the url would change.
|
|
||||||
optionParams.set(option.name.toLowerCase(), value);
|
|
||||||
|
|
||||||
const optionUrl = createUrl(pathname, optionParams);
|
// Base option params on current params so we can preserve any other param state in the url.
|
||||||
|
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
// The option is active if it in the url params.
|
// Update the option params using the current option to reflect how the url *would* change,
|
||||||
const isActive = selectedVariantParams.get(option.name.toLowerCase()) === value;
|
// if the option was clicked.
|
||||||
|
optionSearchParams.set(optionNameLowerCase, value);
|
||||||
|
const optionUrl = createUrl(pathname, optionSearchParams);
|
||||||
|
|
||||||
// The option is available for sale if it fully matches the variant in the option's url params.
|
// In order to determine if an option is available for sale, we need to:
|
||||||
// It's super important to note that this is the options params, *not* the selected variant's params.
|
//
|
||||||
// This is the "magic" that will cross check possible future variant combinations and preemptively
|
// 1. Filter out all other param state
|
||||||
// disable combinations that are not possible.
|
// 2. Filter out invalid options
|
||||||
const isAvailableForSale = optimizedVariants.find((a) =>
|
// 3. Check if the option combination is available for sale
|
||||||
Array.from(optionParams.entries()).every(([key, value]) => a[key] === value)
|
//
|
||||||
)?.availableForSale;
|
// This is the "magic" that will cross check possible variant combinations and preemptively
|
||||||
|
// disable combinations that are not available. For example, if the color gray is only available in size medium,
|
||||||
|
// then all other sizes should be disabled.
|
||||||
|
const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
|
||||||
|
options.find(
|
||||||
|
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const isAvailableForSale = combinations.find((combination) =>
|
||||||
|
filtered.every(
|
||||||
|
([key, value]) => combination[key] === value && combination.availableForSale
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The option is active if it's in the url params.
|
||||||
|
const isActive = searchParams.get(optionNameLowerCase) === value;
|
||||||
|
|
||||||
|
// You can't disable a link, so we need to render something that isn't clickable.
|
||||||
const DynamicTag = isAvailableForSale ? Link : 'p';
|
const DynamicTag = isAvailableForSale ? Link : 'p';
|
||||||
|
const dynamicProps = {
|
||||||
|
...(isAvailableForSale && { scroll: false })
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicTag
|
<DynamicTag
|
||||||
key={value}
|
key={value}
|
||||||
|
aria-disabled={!isAvailableForSale}
|
||||||
href={optionUrl}
|
href={optionUrl}
|
||||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex h-12 min-w-[48px] items-center justify-center rounded-full px-2 text-sm',
|
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||||
{
|
{
|
||||||
'cursor-default ring-2 ring-black dark:ring-white': isActive,
|
'cursor-default ring-2 ring-blue-600': isActive,
|
||||||
'ring-1 ring-gray-300 transition duration-300 ease-in-out hover:scale-110 hover:bg-gray-100 hover:ring-black dark:ring-gray-700 dark:hover:bg-transparent dark:hover:ring-white':
|
'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-gray-100 ring-1 ring-gray-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-gray-300 before:transition-transform dark:bg-gray-900 dark:ring-gray-700 before:dark:bg-gray-700':
|
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
|
||||||
!isAvailableForSale
|
!isAvailableForSale
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
data-testid={isActive ? 'selected-variant' : 'variant'}
|
{...dynamicProps}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</DynamicTag>
|
</DynamicTag>
|
||||||
|
@ -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-gray-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
|
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
dangerouslySetInnerHTML={{ __html: html as string }}
|
dangerouslySetInnerHTML={{ __html: html as string }}
|
||||||
|
@ -1,73 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
const regex = (text: string) => new RegExp(text, 'gim');
|
|
||||||
|
|
||||||
test('should be able to open and close cart', async ({ page }) => {
|
|
||||||
let cart;
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await page.getByTestId('open-cart').click();
|
|
||||||
cart = await page.getByTestId('cart');
|
|
||||||
await expect(cart).toBeVisible();
|
|
||||||
await expect(cart).toHaveText(regex('your cart is empty'));
|
|
||||||
|
|
||||||
await page.getByTestId('close-cart').click();
|
|
||||||
cart = await page.getByTestId('cart');
|
|
||||||
await expect(cart).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to add item to cart, without selecting a variant, assuming first variant', async ({
|
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.getByTestId('homepage-products').locator('a').first().click();
|
|
||||||
|
|
||||||
const productName = await page.getByTestId('product-name').first().innerText();
|
|
||||||
const firstVariant = await page.getByTestId('variant').first().innerText();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: regex('add to cart') }).click();
|
|
||||||
|
|
||||||
const cart = await page.getByTestId('cart');
|
|
||||||
|
|
||||||
await expect(cart).toBeVisible();
|
|
||||||
|
|
||||||
const cartItems = await page.getByTestId('cart-item').all();
|
|
||||||
let isItemInCart = false;
|
|
||||||
|
|
||||||
for (const item of cartItems) {
|
|
||||||
const cartProductName = await item.getByTestId('cart-product-name').innerText();
|
|
||||||
const cartProductVariant = await item.getByTestId('cart-product-variant').innerText();
|
|
||||||
|
|
||||||
if (cartProductName === productName && cartProductVariant === firstVariant) {
|
|
||||||
isItemInCart = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(isItemInCart).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to add item to cart by selecting a variant', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.getByTestId('homepage-products').locator('a').first().click();
|
|
||||||
|
|
||||||
const selectedProductName = await page.getByTestId('product-name').first().innerText();
|
|
||||||
const secondVariant = await page.getByTestId('variant').nth(1);
|
|
||||||
|
|
||||||
await secondVariant.click();
|
|
||||||
const selectedProductVariant = await page.getByTestId('selected-variant').innerText();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: regex('add to cart') }).click();
|
|
||||||
|
|
||||||
const cart = await page.getByTestId('cart');
|
|
||||||
|
|
||||||
await expect(cart).toBeVisible();
|
|
||||||
|
|
||||||
const cartItem = await page.getByTestId('cart-item').first();
|
|
||||||
const cartItemProductName = await cartItem.getByTestId('cart-product-name').innerText();
|
|
||||||
const cartItemProductVariant = await cartItem.getByTestId('cart-product-variant').innerText();
|
|
||||||
|
|
||||||
await expect(cartItemProductName).toBe(selectedProductName);
|
|
||||||
await expect(cartItemProductVariant).toBe(selectedProductVariant);
|
|
||||||
});
|
|
@ -1,16 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 600, height: 900 } });
|
|
||||||
test('should be able to open and close mobile menu', async ({ page }) => {
|
|
||||||
let mobileMenu;
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await page.getByTestId('open-mobile-menu').click();
|
|
||||||
mobileMenu = await page.getByTestId('mobile-menu');
|
|
||||||
await expect(mobileMenu).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByTestId('close-mobile-menu').click();
|
|
||||||
mobileMenu = await page.getByTestId('mobile-menu');
|
|
||||||
await expect(mobileMenu).toBeHidden();
|
|
||||||
});
|
|
@ -1,5 +1,8 @@
|
|||||||
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 { revalidateTag } from 'next/cache';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import {
|
import {
|
||||||
addToCartMutation,
|
addToCartMutation,
|
||||||
createCartMutation,
|
createCartMutation,
|
||||||
@ -23,6 +26,7 @@ import {
|
|||||||
Cart,
|
Cart,
|
||||||
Collection,
|
Collection,
|
||||||
Connection,
|
Connection,
|
||||||
|
Image,
|
||||||
Menu,
|
Menu,
|
||||||
Page,
|
Page,
|
||||||
Product,
|
Product,
|
||||||
@ -151,6 +155,18 @@ const reshapeCollections = (collections: ShopifyCollection[]) => {
|
|||||||
return reshapedCollections;
|
return reshapedCollections;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reshapeImages = (images: Connection<Image>, productTitle: string) => {
|
||||||
|
const flattened = removeEdgesAndNodes(images);
|
||||||
|
|
||||||
|
return flattened.map((image) => {
|
||||||
|
const filename = image.url.match(/.*\/(.*)\..*/)[1];
|
||||||
|
return {
|
||||||
|
...image,
|
||||||
|
altText: image.altText || `${productTitle} - ${filename}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
|
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
|
||||||
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
|
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -160,7 +176,7 @@ const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean =
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
images: removeEdgesAndNodes(images),
|
images: reshapeImages(images, product.title),
|
||||||
variants: removeEdgesAndNodes(variants)
|
variants: removeEdgesAndNodes(variants)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -234,15 +250,16 @@ export async function updateCart(
|
|||||||
return reshapeCart(res.body.data.cartLinesUpdate.cart);
|
return reshapeCart(res.body.data.cartLinesUpdate.cart);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCart(cartId: string): Promise<Cart | null> {
|
export async function getCart(cartId: string): Promise<Cart | undefined> {
|
||||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||||
query: getCartQuery,
|
query: getCartQuery,
|
||||||
variables: { cartId },
|
variables: { cartId },
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Old carts becomes `null` when you checkout.
|
||||||
if (!res.body.data.cart) {
|
if (!res.body.data.cart) {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return reshapeCart(res.body.data.cart);
|
return reshapeCart(res.body.data.cart);
|
||||||
@ -275,7 +292,7 @@ export async function getCollectionProducts({
|
|||||||
variables: {
|
variables: {
|
||||||
handle: collection,
|
handle: collection,
|
||||||
reverse,
|
reverse,
|
||||||
sortKey
|
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -394,3 +411,35 @@ export async function getProducts({
|
|||||||
|
|
||||||
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
|
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
|
||||||
|
export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// We always need to respond with a 200 status code to Shopify,
|
||||||
|
// otherwise it will continue to retry the request.
|
||||||
|
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
|
||||||
|
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
|
||||||
|
const topic = headers().get('x-shopify-topic') || 'unknown';
|
||||||
|
const secret = req.nextUrl.searchParams.get('secret');
|
||||||
|
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
||||||
|
const isProductUpdate = productWebhooks.includes(topic);
|
||||||
|
|
||||||
|
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
|
||||||
|
console.error('Invalid revalidation secret.');
|
||||||
|
return NextResponse.json({ status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCollectionUpdate && !isProductUpdate) {
|
||||||
|
// We don't need to revalidate anything for any other topics.
|
||||||
|
return NextResponse.json({ status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCollectionUpdate) {
|
||||||
|
revalidateTag(TAGS.collections);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProductUpdate) {
|
||||||
|
revalidateTag(TAGS.products);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||||
|
}
|
||||||
|
@ -10,6 +10,7 @@ type operationsWithoutOriginal = Omit<
|
|||||||
| 'readProductCrossSellings'
|
| 'readProductCrossSellings'
|
||||||
| 'readProductListing'
|
| 'readProductListing'
|
||||||
| 'searchPage'
|
| 'searchPage'
|
||||||
|
| 'addLineItem'
|
||||||
| 'readCart'
|
| 'readCart'
|
||||||
| 'deleteLineItem'
|
| 'deleteLineItem'
|
||||||
>;
|
>;
|
||||||
@ -21,6 +22,7 @@ export type extendedPaths =
|
|||||||
| 'readProductCrossSellings post /product/{productId}/cross-selling'
|
| 'readProductCrossSellings post /product/{productId}/cross-selling'
|
||||||
| 'readProductListing post /product-listing/{categoryId}'
|
| 'readProductListing post /product-listing/{categoryId}'
|
||||||
| 'searchPage post /search'
|
| 'searchPage post /search'
|
||||||
|
| 'addLineItem post /checkout/cart/line-item'
|
||||||
| 'readCart get /checkout/cart?name'
|
| 'readCart get /checkout/cart?name'
|
||||||
| 'deleteLineItem delete /checkout/cart/line-item?id[]={ids}'
|
| 'deleteLineItem delete /checkout/cart/line-item?id[]={ids}'
|
||||||
| operationPaths;
|
| operationPaths;
|
||||||
@ -32,6 +34,7 @@ export type extendedOperations = operationsWithoutOriginal & {
|
|||||||
readProductCrossSellings: extendedReadProductCrossSellings;
|
readProductCrossSellings: extendedReadProductCrossSellings;
|
||||||
readProductListing: extendedReadProductListing;
|
readProductListing: extendedReadProductListing;
|
||||||
searchPage: extendedSearchPage;
|
searchPage: extendedSearchPage;
|
||||||
|
addLineItem: extendedAddLineItem;
|
||||||
readCart: extendedReadCart;
|
readCart: extendedReadCart;
|
||||||
deleteLineItem: extendedDeleteLineItem;
|
deleteLineItem: extendedDeleteLineItem;
|
||||||
};
|
};
|
||||||
@ -340,6 +343,26 @@ type extendedReadProductListing = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type extendedCartItems = components['schemas']['ArrayStruct'] & {
|
||||||
|
items?: Partial<ExtendedLineItem>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type extendedAddLineItem = {
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
'application/json': extendedCartItems;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** The updated cart. */
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': ExtendedCart;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type extendedReadCart = {
|
type extendedReadCart = {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
18
package.json
18
package.json
@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@8.2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18",
|
"node": ">=18",
|
||||||
"pnpm": ">=8"
|
"pnpm": ">=7"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@ -12,29 +13,26 @@
|
|||||||
"prettier": "prettier --write --ignore-unknown .",
|
"prettier": "prettier --write --ignore-unknown .",
|
||||||
"prettier:check": "prettier --check --ignore-unknown .",
|
"prettier:check": "prettier --check --ignore-unknown .",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test": "pnpm lint && pnpm prettier:check",
|
"test": "pnpm lint && pnpm prettier:check"
|
||||||
"test:e2e": "playwright test"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "prettier --write --ignore-unknown"
|
"*": "prettier --write --ignore-unknown"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.15",
|
"@headlessui/react": "^1.7.15",
|
||||||
|
"@heroicons/react": "^2.0.18",
|
||||||
"@shopware/api-client": "0.0.0-canary-20230725064840",
|
"@shopware/api-client": "0.0.0-canary-20230725064840",
|
||||||
"@vercel/og": "^0.5.9",
|
"clsx": "^2.0.0",
|
||||||
"clsx": "^1.2.1",
|
|
||||||
"is-empty-iterable": "^3.0.0",
|
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-cookie": "^4.1.1",
|
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-paginate": "^8.2.0"
|
"react-paginate": "^8.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.36.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@types/node": "^20.4.4",
|
"@types/node": "20.4.4",
|
||||||
"@types/react": "18.2.15",
|
"@types/react": "18.2.16",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
"@vercel/git-hooks": "^1.0.0",
|
"@vercel/git-hooks": "^1.0.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
import { PlaywrightTestConfig, devices } from '@playwright/test';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const baseURL = `http://localhost:${process.env.PORT || 3000}`;
|
|
||||||
const config: PlaywrightTestConfig = {
|
|
||||||
testDir: path.join(__dirname, 'e2e'),
|
|
||||||
retries: 2,
|
|
||||||
outputDir: '.playwright',
|
|
||||||
webServer: {
|
|
||||||
command: 'pnpm build && pnpm start',
|
|
||||||
url: baseURL,
|
|
||||||
timeout: 120 * 1000,
|
|
||||||
reuseExistingServer: !process.env.CI
|
|
||||||
},
|
|
||||||
use: {
|
|
||||||
baseURL,
|
|
||||||
trace: 'retry-with-trace'
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'Desktop Chrome',
|
|
||||||
use: {
|
|
||||||
...devices['Desktop Chrome']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Desktop Safari',
|
|
||||||
use: {
|
|
||||||
...devices['Desktop Safari']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mobile Chrome',
|
|
||||||
use: {
|
|
||||||
...devices['Pixel 5']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mobile Safari',
|
|
||||||
use: devices['iPhone 12']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
245
pnpm-lock.yaml
generated
245
pnpm-lock.yaml
generated
@ -8,27 +8,21 @@ dependencies:
|
|||||||
'@headlessui/react':
|
'@headlessui/react':
|
||||||
specifier: ^1.7.15
|
specifier: ^1.7.15
|
||||||
version: 1.7.15(react-dom@18.2.0)(react@18.2.0)
|
version: 1.7.15(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@heroicons/react':
|
||||||
|
specifier: ^2.0.18
|
||||||
|
version: 2.0.18(react@18.2.0)
|
||||||
'@shopware/api-client':
|
'@shopware/api-client':
|
||||||
specifier: 0.0.0-canary-20230725064840
|
specifier: 0.0.0-canary-20230725064840
|
||||||
version: 0.0.0-canary-20230725064840
|
version: 0.0.0-canary-20230725064840
|
||||||
'@vercel/og':
|
|
||||||
specifier: ^0.5.9
|
|
||||||
version: 0.5.9
|
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^1.2.1
|
specifier: ^2.0.0
|
||||||
version: 1.2.1
|
version: 2.0.0
|
||||||
is-empty-iterable:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.0.0
|
|
||||||
next:
|
next:
|
||||||
specifier: 13.4.12
|
specifier: 13.4.12
|
||||||
version: 13.4.12(react-dom@18.2.0)(react@18.2.0)
|
version: 13.4.12(react-dom@18.2.0)(react@18.2.0)
|
||||||
react:
|
react:
|
||||||
specifier: 18.2.0
|
specifier: 18.2.0
|
||||||
version: 18.2.0
|
version: 18.2.0
|
||||||
react-cookie:
|
|
||||||
specifier: ^4.1.1
|
|
||||||
version: 4.1.1(react@18.2.0)
|
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 18.2.0
|
specifier: 18.2.0
|
||||||
version: 18.2.0(react@18.2.0)
|
version: 18.2.0(react@18.2.0)
|
||||||
@ -37,18 +31,18 @@ dependencies:
|
|||||||
version: 8.2.0(react@18.2.0)
|
version: 8.2.0(react@18.2.0)
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@tailwindcss/container-queries':
|
||||||
specifier: ^1.36.1
|
specifier: ^0.1.1
|
||||||
version: 1.36.1
|
version: 0.1.1(tailwindcss@3.3.3)
|
||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.9
|
specifier: ^0.5.9
|
||||||
version: 0.5.9(tailwindcss@3.3.3)
|
version: 0.5.9(tailwindcss@3.3.3)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.4.4
|
specifier: 20.4.4
|
||||||
version: 20.4.4
|
version: 20.4.4
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: 18.2.15
|
specifier: 18.2.16
|
||||||
version: 18.2.15
|
version: 18.2.16
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: 18.2.7
|
specifier: 18.2.7
|
||||||
version: 18.2.7
|
version: 18.2.7
|
||||||
@ -181,6 +175,14 @@ packages:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@heroicons/react@2.0.18(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16'
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@humanwhocodes/config-array@0.11.10:
|
/@humanwhocodes/config-array@0.11.10:
|
||||||
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
|
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
|
||||||
engines: {node: '>=10.10.0'}
|
engines: {node: '>=10.10.0'}
|
||||||
@ -359,22 +361,6 @@ packages:
|
|||||||
tslib: 2.6.0
|
tslib: 2.6.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@playwright/test@1.36.1:
|
|
||||||
resolution: {integrity: sha512-YK7yGWK0N3C2QInPU6iaf/L3N95dlGdbsezLya4n0ZCh3IL7VgPGxC6Gnznh9ApWdOmkJeleT2kMTcWPRZvzqg==}
|
|
||||||
engines: {node: '>=16'}
|
|
||||||
hasBin: true
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.4.4
|
|
||||||
playwright-core: 1.36.1
|
|
||||||
optionalDependencies:
|
|
||||||
fsevents: 2.3.2
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@resvg/resvg-wasm@2.4.1:
|
|
||||||
resolution: {integrity: sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@rushstack/eslint-patch@1.3.2:
|
/@rushstack/eslint-patch@1.3.2:
|
||||||
resolution: {integrity: sha512-V+MvGwaHH03hYhY+k6Ef/xKd6RYlc4q8WBx+2ANmipHJcKuktNcI/NgEsJgdSUF6Lw32njT6OnrRsKYCdgHjYw==}
|
resolution: {integrity: sha512-V+MvGwaHH03hYhY+k6Ef/xKd6RYlc4q8WBx+2ANmipHJcKuktNcI/NgEsJgdSUF6Lw32njT6OnrRsKYCdgHjYw==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -385,21 +371,20 @@ packages:
|
|||||||
ofetch: 1.1.1
|
ofetch: 1.1.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@shuding/opentype.js@1.4.0-beta.0:
|
|
||||||
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
|
|
||||||
engines: {node: '>= 8.0.0'}
|
|
||||||
hasBin: true
|
|
||||||
dependencies:
|
|
||||||
fflate: 0.7.4
|
|
||||||
string.prototype.codepointat: 0.2.1
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@swc/helpers@0.5.1:
|
/@swc/helpers@0.5.1:
|
||||||
resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==}
|
resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.6.0
|
tslib: 2.6.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@tailwindcss/container-queries@0.1.1(tailwindcss@3.3.3):
|
||||||
|
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
|
||||||
|
peerDependencies:
|
||||||
|
tailwindcss: '>=3.2.0'
|
||||||
|
dependencies:
|
||||||
|
tailwindcss: 3.3.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@tailwindcss/typography@0.5.9(tailwindcss@3.3.3):
|
/@tailwindcss/typography@0.5.9(tailwindcss@3.3.3):
|
||||||
resolution: {integrity: sha512-t8Sg3DyynFysV9f4JDOVISGsjazNb48AeIYQwcL+Bsq5uf4RYL75C1giZ43KISjeDGBaTN3Kxh7Xj/vRSMJUUg==}
|
resolution: {integrity: sha512-t8Sg3DyynFysV9f4JDOVISGsjazNb48AeIYQwcL+Bsq5uf4RYL75C1giZ43KISjeDGBaTN3Kxh7Xj/vRSMJUUg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -412,17 +397,6 @@ packages:
|
|||||||
tailwindcss: 3.3.3
|
tailwindcss: 3.3.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/cookie@0.3.3:
|
|
||||||
resolution: {integrity: sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@types/hoist-non-react-statics@3.3.1:
|
|
||||||
resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
|
|
||||||
dependencies:
|
|
||||||
'@types/react': 18.2.15
|
|
||||||
hoist-non-react-statics: 3.3.2
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@types/json5@0.0.29:
|
/@types/json5@0.0.29:
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -437,22 +411,25 @@ packages:
|
|||||||
|
|
||||||
/@types/prop-types@15.7.5:
|
/@types/prop-types@15.7.5:
|
||||||
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
|
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/react-dom@18.2.7:
|
/@types/react-dom@18.2.7:
|
||||||
resolution: {integrity: sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==}
|
resolution: {integrity: sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.2.15
|
'@types/react': 18.2.16
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/react@18.2.15:
|
/@types/react@18.2.16:
|
||||||
resolution: {integrity: sha512-oEjE7TQt1fFTFSbf8kkNuc798ahTUzn3Le67/PWjE8MAfYAD/qB7O8hSTcromLFqHCt9bcdOg5GXMokzTjJ5SA==}
|
resolution: {integrity: sha512-LLFWr12ZhBJ4YVw7neWLe6Pk7Ey5R9OCydfuMsz1L8bZxzaawJj2p06Q8/EFEHDeTBQNFLF62X+CG7B2zIyu0Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/prop-types': 15.7.5
|
'@types/prop-types': 15.7.5
|
||||||
'@types/scheduler': 0.16.3
|
'@types/scheduler': 0.16.3
|
||||||
csstype: 3.1.2
|
csstype: 3.1.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/scheduler@0.16.3:
|
/@types/scheduler@0.16.3:
|
||||||
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
|
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.1.6):
|
/@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.1.6):
|
||||||
resolution: {integrity: sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==}
|
resolution: {integrity: sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==}
|
||||||
@ -521,15 +498,6 @@ packages:
|
|||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@vercel/og@0.5.9:
|
|
||||||
resolution: {integrity: sha512-CtjaV/BVHtNCjRtxGqn8Q6AKFLqcG34Byxr91+mY+4eqyp/09LVe9jEeY9WXjbaKvu8syWPMteTpY+YQUQYzSg==}
|
|
||||||
engines: {node: '>=16'}
|
|
||||||
dependencies:
|
|
||||||
'@resvg/resvg-wasm': 2.4.1
|
|
||||||
satori: 0.10.1
|
|
||||||
yoga-wasm-web: 0.3.3
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/acorn-jsx@5.3.2(acorn@8.10.0):
|
/acorn-jsx@5.3.2(acorn@8.10.0):
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -733,11 +701,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/base64-js@0.0.8:
|
|
||||||
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
|
|
||||||
engines: {node: '>= 0.4'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/big-integer@1.6.51:
|
/big-integer@1.6.51:
|
||||||
resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==}
|
resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@ -816,10 +779,6 @@ packages:
|
|||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/camelize@1.0.1:
|
|
||||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/caniuse-lite@1.0.30001517:
|
/caniuse-lite@1.0.30001517:
|
||||||
resolution: {integrity: sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==}
|
resolution: {integrity: sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==}
|
||||||
|
|
||||||
@ -904,8 +863,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/clsx@1.2.1:
|
/clsx@2.0.0:
|
||||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
@ -928,6 +887,7 @@ packages:
|
|||||||
|
|
||||||
/color-name@1.1.4:
|
/color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/colorette@2.0.20:
|
/colorette@2.0.20:
|
||||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||||
@ -947,11 +907,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/cookie@0.4.2:
|
|
||||||
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/cross-spawn@7.0.3:
|
/cross-spawn@7.0.3:
|
||||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@ -961,27 +916,6 @@ packages:
|
|||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/css-background-parser@0.1.0:
|
|
||||||
resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/css-box-shadow@1.0.0-3:
|
|
||||||
resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/css-color-keywords@1.0.0:
|
|
||||||
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/css-to-react-native@3.2.0:
|
|
||||||
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
|
|
||||||
dependencies:
|
|
||||||
camelize: 1.0.1
|
|
||||||
css-color-keywords: 1.0.0
|
|
||||||
postcss-value-parser: 4.2.0
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/cssesc@3.0.0:
|
/cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -990,6 +924,7 @@ packages:
|
|||||||
|
|
||||||
/csstype@3.1.2:
|
/csstype@3.1.2:
|
||||||
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
|
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/damerau-levenshtein@1.0.8:
|
/damerau-levenshtein@1.0.8:
|
||||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||||
@ -1099,10 +1034,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-6M1qyhaJOt7rQtNti1lBA0GwclPH+oKCmsra/hkcWs5INLxfXXD/dtdnaKUYQu/pjOBP/8Osoe4mAcNvvzoFag==}
|
resolution: {integrity: sha512-6M1qyhaJOt7rQtNti1lBA0GwclPH+oKCmsra/hkcWs5INLxfXXD/dtdnaKUYQu/pjOBP/8Osoe4mAcNvvzoFag==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/emoji-regex@10.2.1:
|
|
||||||
resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/emoji-regex@8.0.0:
|
/emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -1199,10 +1130,6 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/escape-html@1.0.3:
|
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/escape-string-regexp@1.0.5:
|
/escape-string-regexp@1.0.5:
|
||||||
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
|
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
|
||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
@ -1577,10 +1504,6 @@ packages:
|
|||||||
reusify: 1.0.4
|
reusify: 1.0.4
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/fflate@0.7.4:
|
|
||||||
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/file-entry-cache@6.0.1:
|
/file-entry-cache@6.0.1:
|
||||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||||
engines: {node: ^10.12.0 || >=12.0.0}
|
engines: {node: ^10.12.0 || >=12.0.0}
|
||||||
@ -1836,17 +1759,6 @@ packages:
|
|||||||
function-bind: 1.1.1
|
function-bind: 1.1.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/hex-rgb@4.3.0:
|
|
||||||
resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
|
|
||||||
engines: {node: '>=6'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/hoist-non-react-statics@3.3.2:
|
|
||||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
|
||||||
dependencies:
|
|
||||||
react-is: 16.13.1
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/hosted-git-info@2.8.9:
|
/hosted-git-info@2.8.9:
|
||||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -1980,11 +1892,6 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/is-empty-iterable@3.0.0:
|
|
||||||
resolution: {integrity: sha512-ZXVNGZrRvda9spnGVME3nTYTyDNjCTrmRy3DfDjBaMQ7aftcPsy/vkJoLL47IwcAbgioIfGvjQJWdit8GiggPg==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/is-extglob@2.1.1:
|
/is-extglob@2.1.1:
|
||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2181,13 +2088,6 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/linebreak@1.1.0:
|
|
||||||
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
|
|
||||||
dependencies:
|
|
||||||
base64-js: 0.0.8
|
|
||||||
unicode-trie: 2.0.0
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/lines-and-columns@1.2.4:
|
/lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -2590,10 +2490,6 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/pako@0.2.9:
|
|
||||||
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/parent-module@1.0.1:
|
/parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -2601,13 +2497,6 @@ packages:
|
|||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/parse-css-color@0.2.1:
|
|
||||||
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
|
|
||||||
dependencies:
|
|
||||||
color-name: 1.1.4
|
|
||||||
hex-rgb: 4.3.0
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/parse-json@5.2.0:
|
/parse-json@5.2.0:
|
||||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2671,12 +2560,6 @@ packages:
|
|||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/playwright-core@1.36.1:
|
|
||||||
resolution: {integrity: sha512-7+tmPuMcEW4xeCL9cp9KxmYpQYHKkyjwoXRnoeTowaeNat8PoBMk/HwCYhqkH2fRkshfKEOiVus/IhID2Pg8kg==}
|
|
||||||
engines: {node: '>=16'}
|
|
||||||
hasBin: true
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/pluralize@8.0.0:
|
/pluralize@8.0.0:
|
||||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -2749,6 +2632,7 @@ packages:
|
|||||||
|
|
||||||
/postcss-value-parser@4.2.0:
|
/postcss-value-parser@4.2.0:
|
||||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/postcss@8.4.14:
|
/postcss@8.4.14:
|
||||||
resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==}
|
resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==}
|
||||||
@ -2850,17 +2734,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/react-cookie@4.1.1(react@18.2.0):
|
|
||||||
resolution: {integrity: sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>= 16.3.0'
|
|
||||||
dependencies:
|
|
||||||
'@types/hoist-non-react-statics': 3.3.1
|
|
||||||
hoist-non-react-statics: 3.3.2
|
|
||||||
react: 18.2.0
|
|
||||||
universal-cookie: 4.0.4
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/react-dom@18.2.0(react@18.2.0):
|
/react-dom@18.2.0(react@18.2.0):
|
||||||
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -3035,22 +2908,6 @@ packages:
|
|||||||
is-regex: 1.1.4
|
is-regex: 1.1.4
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/satori@0.10.1:
|
|
||||||
resolution: {integrity: sha512-F4bTCkDp931tLb7+UCNPBuSQwXhikrUkI4fBQo6fA8lF0Evqqgg3nDyUpRktQpR5Ry1DIiIVqLyEwkAms87ykg==}
|
|
||||||
engines: {node: '>=16'}
|
|
||||||
dependencies:
|
|
||||||
'@shuding/opentype.js': 1.4.0-beta.0
|
|
||||||
css-background-parser: 0.1.0
|
|
||||||
css-box-shadow: 1.0.0-3
|
|
||||||
css-to-react-native: 3.2.0
|
|
||||||
emoji-regex: 10.2.1
|
|
||||||
escape-html: 1.0.3
|
|
||||||
linebreak: 1.1.0
|
|
||||||
parse-css-color: 0.2.1
|
|
||||||
postcss-value-parser: 4.2.0
|
|
||||||
yoga-wasm-web: 0.3.3
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/scheduler@0.23.0:
|
/scheduler@0.23.0:
|
||||||
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
|
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3189,10 +3046,6 @@ packages:
|
|||||||
strip-ansi: 7.1.0
|
strip-ansi: 7.1.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/string.prototype.codepointat@0.2.1:
|
|
||||||
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/string.prototype.matchall@4.0.8:
|
/string.prototype.matchall@4.0.8:
|
||||||
resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==}
|
resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3387,10 +3240,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/tiny-inflate@1.0.3:
|
|
||||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/titleize@3.0.0:
|
/titleize@3.0.0:
|
||||||
resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==}
|
resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -3517,20 +3366,6 @@ packages:
|
|||||||
which-boxed-primitive: 1.0.2
|
which-boxed-primitive: 1.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/unicode-trie@2.0.0:
|
|
||||||
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
|
|
||||||
dependencies:
|
|
||||||
pako: 0.2.9
|
|
||||||
tiny-inflate: 1.0.3
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/universal-cookie@4.0.4:
|
|
||||||
resolution: {integrity: sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==}
|
|
||||||
dependencies:
|
|
||||||
'@types/cookie': 0.3.3
|
|
||||||
cookie: 0.4.2
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/untildify@4.0.0:
|
/untildify@4.0.0:
|
||||||
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
|
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -3637,10 +3472,6 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/yoga-wasm-web@0.3.3:
|
|
||||||
resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/zod@3.21.4:
|
/zod@3.21.4:
|
||||||
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
|
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -1,26 +1,13 @@
|
|||||||
const plugin = require('tailwindcss/plugin');
|
const plugin = require('tailwindcss/plugin');
|
||||||
const colors = require('tailwindcss/colors');
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
|
||||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
|
||||||
'./components/**/*.{js,ts,jsx,tsx}',
|
|
||||||
'./icons/**/*.{js,ts,jsx,tsx}',
|
|
||||||
'./app/**/*.{js,ts,jsx,tsx}'
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-inter)']
|
sans: ['var(--font-inter)']
|
||||||
},
|
},
|
||||||
colors: {
|
|
||||||
gray: colors.neutral,
|
|
||||||
hotPink: '#FF1966',
|
|
||||||
dark: '#111111',
|
|
||||||
light: '#FAFAFA',
|
|
||||||
violetDark: '#4c2889'
|
|
||||||
},
|
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
from: { opacity: 0 },
|
from: { opacity: 0 },
|
||||||
@ -47,6 +34,7 @@ module.exports = {
|
|||||||
hoverOnlyWhenSupported: true
|
hoverOnlyWhenSupported: true
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
require('@tailwindcss/container-queries'),
|
||||||
require('@tailwindcss/typography'),
|
require('@tailwindcss/typography'),
|
||||||
plugin(({ matchUtilities, theme }) => {
|
plugin(({ matchUtilities, theme }) => {
|
||||||
matchUtilities(
|
matchUtilities(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user