mirror of
https://github.com/vercel/commerce.git
synced 2025-05-15 14:06:59 +00:00
Merge pull request #3 from BrocksiNet/poc/react-nextjs
poc/react-nextjs
This commit is contained in:
commit
d9429da73c
16
.env.example
16
.env.example
@ -1,7 +1,9 @@
|
||||
COMPANY_NAME="Vercel Inc."
|
||||
TWITTER_CREATOR="@vercel"
|
||||
TWITTER_SITE="https://nextjs.org/commerce"
|
||||
SITE_NAME="Next.js Commerce"
|
||||
SHOPIFY_REVALIDATION_SECRET=
|
||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
SHOPIFY_STORE_DOMAIN=
|
||||
COMPANY_NAME="shopware AG"
|
||||
TWITTER_CREATOR="@shopware"
|
||||
TWITTER_SITE="https://www.shopware.com/en/solutions/shopware-composable-frontends/"
|
||||
SITE_NAME="Next.js Commerce with Shopware Composable Frontends"
|
||||
SHOPWARE_STORE_DOMAIN=""
|
||||
SHOPWARE_API_TYPE="store-api"
|
||||
SHOPWARE_ACCESS_TOKEN=""
|
||||
SHOPWARE_USE_SEO_URLS="false"
|
||||
SHOPWARE_REVALIDATION_SECRET=""
|
||||
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
version: 7
|
||||
version: 8
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v3
|
||||
|
5
.husky/pre-commit
Executable file
5
.husky/pre-commit
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm run lint
|
||||
npx lint-staged
|
245
README.md
245
README.md
@ -1,6 +1,4 @@
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
|
||||
|
||||
# Next.js Commerce
|
||||
# Next.js Commerce & Shopware Composable Frontends
|
||||
|
||||
A Next.js 13 and App Router-ready ecommerce template featuring:
|
||||
|
||||
@ -12,30 +10,23 @@ A Next.js 13 and App Router-ready ecommerce template featuring:
|
||||
- New fetching and caching paradigms
|
||||
- Dynamic OG images
|
||||
- Styling with Tailwind CSS
|
||||
- Checkout and payments with Shopify
|
||||
- Automatic light/dark mode based on system settings
|
||||
|
||||
<h3 id="v1-note"></h3>
|
||||
|
||||
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
|
||||
|
||||
## Providers
|
||||
## Prerequisites
|
||||
|
||||
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
|
||||
Next.js + Shopware requires a running [Shopware 6 Instance (Installation Guide)](https://developer.shopware.com/docs/guides/installation).
|
||||
|
||||
Vercel is more than happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
|
||||
|
||||
- Shopify (this repository)
|
||||
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
|
||||
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
|
||||
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
|
||||
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
|
||||
To get started, use this README and the example environment variable comments.
|
||||
|
||||
## Running locally
|
||||
|
||||
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
|
||||
|
||||
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
|
||||
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopware store.
|
||||
|
||||
1. Install Vercel CLI: `npm i -g vercel`
|
||||
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
||||
@ -48,230 +39,24 @@ pnpm dev
|
||||
|
||||
Your app should now be running on [localhost:3000](http://localhost:3000/).
|
||||
|
||||
<details>
|
||||
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
|
||||
## How to configure your Shopware store for Next.js Commerce
|
||||
|
||||
1. Run `vc link`.
|
||||
1. Select the `Vercel Solutions` scope.
|
||||
1. Connect to the existing `commerce-shopify` project.
|
||||
1. Run `vc env pull` to get environment variables.
|
||||
1. Run `pmpm dev` to ensure everything is working correctly.
|
||||
</details>
|
||||
You can find the `SHOPWARE_ACCESS_TOKEN` in the Admin at your SalesChannel configuration.
|
||||
|
||||
## How to configure your Shopify store for Next.js Commerce
|
||||
### Add Shopware domain to an environment variable
|
||||
|
||||
Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing).
|
||||
Copy `.env.example` file to `.env` and change the environment variables like descibed below.
|
||||
|
||||
> Note: Next.js Commerce will not work with a Shopify Starter plan as it does not allow installation of custom themes, which is required to run as a headless storefront.
|
||||
|
||||
### Add Shopify domain to an environment variable
|
||||
|
||||
Create a `SHOPIFY_STORE_DOMAIN` environment variable and use your Shopify domain as the the value (ie. `[your-shopify-store-subdomain].myshopify.com`).
|
||||
Create a `SHOPWARE_STORE_DOMAIN` environment variable and use your Shopware domain as the the value (ie. `demo-frontends.shopware.store`).
|
||||
|
||||
> Note: Do not include the `https://`.
|
||||
|
||||
### Accessing the Shopify Storefront API
|
||||
### Accessing the Shopware store API
|
||||
|
||||
Next.js Commerce utilizes [Shopify's Storefront API](https://shopify.dev/docs/api/storefront) to create unique customer experiences. The API offers a full range of commerce options making it possible for customers to control products, collections, menus, pages, cart, checkout, and more.
|
||||
Next.js Commerce utilizes [Shopware's store API](https://shopware.stoplight.io/docs/store-api/) to create unique customer experiences. The API offers a full range of commerce options making it possible for customers to control products, collections, menus, pages, cart, checkout, and more.
|
||||
|
||||
In order to use the Shopify's Storefront API, you need to install the [Headless app](https://apps.shopify.com/headless) in your Shopify store.
|
||||
In order to use the Shopware's store API, you need at least one _(Storefront)_ SalesChannel _(for speaking URL's)_ in your Shopware instance.
|
||||
|
||||
Once installed, you'll need to create a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable and use the public access token as the value.
|
||||
Once installed, you'll need to create a `SHOPWARE_ACCESS_TOKEN` environment variable and use the public access token as the value
|
||||
|
||||
> Note: Shopify does offer a Node.js Storefront API SDK. We use the Storefront API via GraphQL directly instead of the Node.js SDK so we have more control over fetching and caching.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/apps`.
|
||||
1. Click the green `Shopify App Store` button.
|
||||

|
||||
1. Search for `Headless` and click on the `Headless` app.
|
||||

|
||||
1. Click the black `Add app` button.
|
||||

|
||||
1. Click the green `Add sales channel` button.
|
||||

|
||||
1. Click the green `Create storefront` button.
|
||||

|
||||
1. Copy and paste the public access token and assign it to a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable.
|
||||

|
||||
1. If you ever need to reference the public access token again, you can navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/headless_storefronts`.
|
||||
</details>
|
||||
|
||||
### Install a headless theme
|
||||
|
||||
When using a headless Shopify setup, you normally don't want customers to access any of the theme pages except for checkout. However, you can't totally disable the theme and a lot of links will still point to the theme (e.g. links in emails, order details, plugins, checkout, etc.).
|
||||
|
||||
To enable a seamless flow between your headless site and Shopify, you can install the [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
|
||||
|
||||
Follow the installation instructions and configure the theme with your headless site's values.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
1. Download [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
|
||||

|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/themes`.
|
||||
1. Click `Add theme`, then `Upload zip file`.
|
||||

|
||||
1. Select the downloaded zip file from above, and click the green `Upload file` button.
|
||||

|
||||
1. Click `Customize`.
|
||||

|
||||
1. Click `Theme settings` (ie. the paintbrush icon), expand the `STOREFRONT` section, enter your headless store domain, click the gray `Publish` button.
|
||||

|
||||
1. Confirm the theme change by clicking the green `Save and publish` button.
|
||||

|
||||
1. The headless theme should now be your current active theme.
|
||||

|
||||
</details>
|
||||
|
||||
### Branding & Design
|
||||
|
||||
Since you're creating a headless Shopify store, you'll be in full control of your brand and design. However, there are still a few aspects we're leaving within Shopify's control.
|
||||
|
||||
- Checkout
|
||||
- Emails
|
||||
- Order status
|
||||
- Order history
|
||||
- Favicon (for any Shopify controlled pages)
|
||||
|
||||
You can use Shopify's admin to customize these pages to match your brand and design.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
#### Checkout, order status, and order history
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/checkout`.
|
||||
1. Click the green `Customize` button.
|
||||

|
||||
1. Click `Branding` (ie. the paintbrush icon) and customize your brand. Please note, there are three steps / pages to the checkout flow. Use the dropdown to change pages and adjust branding as needed on each page. Click `Save` when you are done.
|
||||

|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/branding`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Emails
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/email_settings`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Favicon
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/themes`.
|
||||
1. Click the green `Customize` button.
|
||||

|
||||
1. Click `Theme settings` (ie. the paintbrush icon), expand the `FAVICON` section, upload favicon, then click the `Save` button.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
### Configure webhooks for on-demand incremental static regeneration (ISR)
|
||||
|
||||
Utilizing [Shopify's webhooks](https://shopify.dev/docs/apps/webhooks), and listening for select [Shopify webhook event topics](https://shopify.dev/docs/api/admin-rest/2022-04/resources/webhook#event-topics), we can use [Next'js on-demand revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating#using-on-demand-revalidation) to keep data fetches indefinitely cached until certain events in the Shopify store occur.
|
||||
|
||||
Next.js is pre-configured to listen for the following Shopify webhook events and automatically revalidate fetches.
|
||||
|
||||
- `collections/create`
|
||||
- `collections/delete`
|
||||
- `collections/update`
|
||||
- `products/create`
|
||||
- `products/delete`
|
||||
- `products/update` (this also includes when variants are added, updated, and removed as well as when products are purchased so inventory and out of stocks can be updated)
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
#### Setup secret for secure revalidation
|
||||
|
||||
1. Create your own secret or [generate a random UUID](https://www.uuidgenerator.net/guid).
|
||||
1. Create a [Vercel Environment Variable](https://vercel.com/docs/concepts/projects/environment-variables) named `SHOPIFY_REVALIDATION_SECRET` and use the value from above.
|
||||
|
||||
#### Configure Shopify webhooks
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/notifications`.
|
||||
1. Add webhooks for all six event topics listed above. You can add more sets for other preview urls, environments, or local development. Append `?secret=[SECRET]` to each url, where `[SECRET]` is the secret you created above.
|
||||

|
||||

|
||||
|
||||
#### Testing webhooks during local development
|
||||
|
||||
The easiest way to test webhooks while developing locally is to use [ngrok](https://ngrok.com).
|
||||
|
||||
1. [Install and configure ngrok](https://ngrok.com/download) (you will need to create an account).
|
||||
1. Run your app locally, `npm run dev`.
|
||||
1. In a separate terminal session, run `ngrok http 3000`.
|
||||
1. Use the url generated by ngrok and add or update your webhook urls in Shopify.
|
||||

|
||||

|
||||
1. You can now make changes to your store and your local app should receive updates. You can also use the `Send test notification` button to trigger a generic webhook test.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
### Using Shopify as a CMS
|
||||
|
||||
Next.js Commerce is fully powered by Shopify in a truly headless and data driven way.
|
||||
|
||||
#### Products
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/products`
|
||||
|
||||
Only `Active` products are shown. `Draft` products will not be shown until they are marked as `Active`.
|
||||
|
||||
`Active` products can still be hidden and not seen by navigating the site, by adding a `nextjs-frontend-hidden` tag on the product. This tag will also tell search engines to not index or crawl the product. The product is still directly accessible via url. This feature is great for "secret" products you only want to people you share the url with.
|
||||
|
||||
Product options and option combinations are driven from Shopify options and variants. When selecting options on the product detail page, other option and variant combinations will be visually validated and verified for availability, like Amazon does.
|
||||
|
||||
Products that are active and "out of stock" are still shown on the site, but the ability to add the product to the cart is disabled.
|
||||
|
||||
#### Collections
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/collections`
|
||||
|
||||
Create whatever collections you want and configure them however you want. All available collections will show on the search page as filters on the left, with one exception...
|
||||
|
||||
Any collection names that start with the word "hidden" will not show up on the headless front end. The Next.js Commerce theme comes pre-configured to look for two hidden collections. Collections were chosen for this over tags so that order of products could be controlled (collections allow for manual ordering).
|
||||
|
||||
Create the following collections:
|
||||
|
||||
- `Hidden: Homepage Featured Items` -- Products in this collection are displayed in the three featured blocks on the homepage.
|
||||
- `Hidden: Homepage Carousel` -- Products in this collection are displayed in the auto-scrolling carousel section on the homepage.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Pages
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/pages`
|
||||
|
||||
Next.js Commerce contains a dynamic `[page]` route. It will use the value to look for a corresponding page in Shopify. If a page is found, it will display its rich content using Tailwind's prose. If a page is not found, a 404 page is displayed.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Navigation menus
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/menus`
|
||||
|
||||
Next.js Commerce's header and footer navigation is pre-configured to be controlled by Shopify navigation menus. This means you have full control over what links go here. They can be to collections, pages, external links, and more.
|
||||
|
||||
Create the following navigation menus:
|
||||
|
||||
- `Next.js Frontend Header Menu` -- Menu items to be shown in the headless frontend header.
|
||||
- `Next.js Frontend Footer Menu` -- Menu items to be shown in the headless frontend footer.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### SEO
|
||||
|
||||
Shopify's products, collections, pages, etc. allow you to create custom SEO titles and descriptions. Next.js Commerce is pre-configured to display these custom values, but also comes with sensible default fallbacks if they are not provided.
|
||||
|
||||

|
||||
> Note: We using an [api Client package](https://www.npmjs.com/package/@shopware/api-client) that helps you with types, endpoints, params and returns.
|
||||
|
@ -1,19 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import Prose from 'components/prose';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { getPage } from 'lib/shopware';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const revalidate = 43200; // 12 hours in seconds
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { page: string };
|
||||
}): Promise<Metadata> {
|
||||
const page = await getPage(params.page);
|
||||
export async function generateMetadata({ params }: { params: { cms: string } }): Promise<Metadata> {
|
||||
const page = await getPage(params.cms);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
@ -28,10 +24,14 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
export default async function Page({ params }: { params: { cms: string } }) {
|
||||
const page = await getPage(params.cms);
|
||||
|
||||
if (!page) return notFound();
|
||||
let date = page.createdAt;
|
||||
if (page.updatedAt !== '') {
|
||||
date = page.updatedAt;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -42,7 +42,7 @@ export default async function Page({ params }: { params: { page: string } }) {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(page.updatedAt))}.`}
|
||||
}).format(new Date(date))}.`}
|
||||
</p>
|
||||
</>
|
||||
);
|
@ -1,11 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { getPage } from 'lib/shopware';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
const title = page.seo?.title || page.title;
|
||||
const title = page ? page.seo?.title || page.title : '';
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { revalidate } from 'lib/shopify';
|
||||
import { revalidate } from 'lib/shopware';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
@ -7,7 +7,7 @@ const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
default: SITE_NAME,
|
||||
default: SITE_NAME || 'Shopware Composable Frontends',
|
||||
template: `%s | ${SITE_NAME}`
|
||||
},
|
||||
robots: {
|
||||
|
@ -6,7 +6,7 @@ import { Suspense } from 'react';
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const metadata = {
|
||||
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
|
||||
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopware.',
|
||||
openGraph: {
|
||||
type: 'website'
|
||||
}
|
||||
|
@ -7,8 +7,8 @@ import Footer from 'components/layout/footer';
|
||||
import { Gallery } from 'components/product/gallery';
|
||||
import { ProductDescription } from 'components/product/product-description';
|
||||
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||
import { getProduct, getProductRecommendations } from 'lib/shopify';
|
||||
import { Image } from 'lib/shopify/types';
|
||||
import { getProduct, getProductRecommendations } from 'lib/shopware';
|
||||
import { Image } from 'lib/shopware/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@ -18,6 +18,7 @@ export async function generateMetadata({
|
||||
}: {
|
||||
params: { handle: string };
|
||||
}): Promise<Metadata> {
|
||||
// @ToDo: create a simpler function and do not do the heavy options/variant stuff here
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
@ -118,10 +119,10 @@ async function RelatedProducts({ id }: { id: string }) {
|
||||
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product) => (
|
||||
<li
|
||||
key={product.handle}
|
||||
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.handle}`}>
|
||||
<Link className="relative h-full w-full" href={`/product/${product.path}`}>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
81
app/search/(collection)/[...collection]/page.tsx
Normal file
81
app/search/(collection)/[...collection]/page.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import Grid from 'components/grid';
|
||||
import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import Pagination from 'components/collection/pagination';
|
||||
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopware';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}): Promise<Metadata> {
|
||||
const collection = await getCollection(params.collection);
|
||||
|
||||
if (!collection) return notFound();
|
||||
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: { collection: string };
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const { sort, page } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
const { products, total, limit } = await getCollectionProducts({
|
||||
collection: params.collection,
|
||||
page: page ? parseInt(page) : 1,
|
||||
sortKey,
|
||||
reverse
|
||||
});
|
||||
|
||||
return (
|
||||
<section>
|
||||
{products.length === 0 ? (
|
||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||
) : (
|
||||
<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 w-full flex-none md:max-w-[125px]">
|
||||
<Collections collection={params.collection} />
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
{total > limit ? (
|
||||
<nav
|
||||
aria-label="Collection pagination"
|
||||
className="mb-2 mt-4 block items-center sm:flex"
|
||||
>
|
||||
<Pagination
|
||||
itemsPerPage={limit}
|
||||
itemsTotal={total}
|
||||
currentPage={page ? parseInt(page) - 1 : 0}
|
||||
/>
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getCollection } from 'lib/shopify';
|
||||
import { getCollection } from 'lib/shopware';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
@ -1,49 +0,0 @@
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopify';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}): Promise<Metadata> {
|
||||
const collection = await getCollection(params.collection);
|
||||
|
||||
if (!collection) return notFound();
|
||||
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: { collection: string };
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const { sort } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||
|
||||
return (
|
||||
<section>
|
||||
{products.length === 0 ? (
|
||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||
) : (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,21 +1,11 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
// @ToDo: We could use dynamic Layout per page, see https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts#with-typescript
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<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 w-full flex-none md:max-w-[125px]">
|
||||
<Collections />
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
|
@ -1,15 +1,28 @@
|
||||
import Grid from 'components/grid';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
{Array(12)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" />
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<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 w-full flex-none md:max-w-[125px]"></div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
{Array(12)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<Grid.Item
|
||||
key={index}
|
||||
className="animate-pulse bg-neutral-100 dark:bg-neutral-900"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
import { getProducts } from 'lib/shopify';
|
||||
import { getSearchCollectionProducts } from 'lib/shopware';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -18,23 +19,41 @@ export default async function SearchPage({
|
||||
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
const products = await getProducts({ sortKey, reverse, query: searchValue });
|
||||
const products = await getSearchCollectionProducts({ sortKey, reverse, query: searchValue });
|
||||
const resultsText = products.length > 1 ? 'results' : 'result';
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchValue ? (
|
||||
<p className="mb-4">
|
||||
{products.length === 0
|
||||
? 'There are no products that match '
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
{searchValue && products.length === 0 ? (
|
||||
<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 className="mb-4">
|
||||
{'There are no products that match '}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{products.length > 0 ? (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
<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 w-full flex-none md:max-w-[125px]">
|
||||
{searchValue ? (
|
||||
<p className="mb-4 text-sm text-neutral-500">
|
||||
{`Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="pt-4 text-xs text-neutral-500">
|
||||
Good place to add other suggested search terms ;)
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
||||
import { getProductSeoUrls, getMenu } from 'lib/shopware';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
type Route = {
|
||||
@ -16,31 +16,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
lastModified: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const collectionsPromise = getCollections().then((collections) =>
|
||||
collections.map((collection) => ({
|
||||
url: `${baseUrl}${collection.path}`,
|
||||
lastModified: collection.updatedAt
|
||||
const mainNavigationPromise = getMenu({ type: 'main-navigation' }).then((mainNavigation) =>
|
||||
mainNavigation.map((mainNavigationItem) => ({
|
||||
url: `${baseUrl}${mainNavigationItem.path}`,
|
||||
lastModified: new Date().toISOString()
|
||||
}))
|
||||
);
|
||||
|
||||
const productsPromise = getProducts({}).then((products) =>
|
||||
const footerNaivgationPromise = getMenu({ type: 'footer-navigation', depth: 2 }).then(
|
||||
(footerNavigation) =>
|
||||
footerNavigation.map((footerNavigationItem) => ({
|
||||
url: `${baseUrl}${footerNavigationItem.path}`,
|
||||
lastModified: new Date().toISOString()
|
||||
}))
|
||||
);
|
||||
// @ToDo: currently this points to variants, would be better to point to parent products
|
||||
const productsPromise = getProductSeoUrls().then((products) =>
|
||||
products.map((product) => ({
|
||||
url: `${baseUrl}/product/${product.handle}`,
|
||||
url: `${baseUrl}/product/${product.path}`,
|
||||
lastModified: product.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
const pagesPromise = getPages().then((pages) =>
|
||||
pages.map((page) => ({
|
||||
url: `${baseUrl}/${page.handle}`,
|
||||
lastModified: page.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
let fetchedRoutes: Route[] = [];
|
||||
|
||||
try {
|
||||
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
|
||||
fetchedRoutes = (
|
||||
await Promise.all([productsPromise, mainNavigationPromise, footerNaivgationPromise])
|
||||
).flat();
|
||||
} catch (error) {
|
||||
throw JSON.stringify(error, null, 2);
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import { getCollectionProducts } from 'lib/shopware';
|
||||
import { isSeoUrls } from 'lib/shopware/helpers';
|
||||
import Link from 'next/link';
|
||||
import { GridTileImage } from './grid/tile';
|
||||
|
||||
export async function Carousel() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||
const collectionName = isSeoUrls()
|
||||
? 'Summer-BBQ/Hidden-Carousel-Category'
|
||||
: 'ff7bf3c59f1342a685844fbf8fdf9dc8';
|
||||
const { products } = await getCollectionProducts({
|
||||
collection: collectionName
|
||||
});
|
||||
|
||||
if (!products?.length) return null;
|
||||
|
||||
@ -16,10 +21,10 @@ export async function Carousel() {
|
||||
<ul className="flex animate-carousel gap-4">
|
||||
{carouselProducts.map((product, i) => (
|
||||
<li
|
||||
key={`${product.handle}${i}`}
|
||||
key={`${product.path}${i}`}
|
||||
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||
>
|
||||
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
||||
<Link href={`/product/${product.path}`} className="relative h-full w-full">
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
|
@ -1,42 +1,116 @@
|
||||
'use server';
|
||||
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { ApiClientError } from '@shopware/api-client';
|
||||
import { getApiClient } from 'lib/shopware/api';
|
||||
import { ExtendedCart, ExtendedLineItem } from 'lib/shopware/api-extended';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const fetchCart = async function (cartId?: string): Promise<ExtendedCart | undefined> {
|
||||
try {
|
||||
const apiClient = getApiClient(cartId);
|
||||
const cart = await apiClient.invoke('readCart get /checkout/cart?name', {});
|
||||
|
||||
return cart;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
let cartId = cookies().get('sw-context-token')?.value;
|
||||
let cart;
|
||||
|
||||
if (cartId) {
|
||||
cart = await getCart(cartId);
|
||||
cart = await fetchCart(cartId);
|
||||
}
|
||||
|
||||
if (!cartId || !cart) {
|
||||
cart = await createCart();
|
||||
cartId = cart.id;
|
||||
cookies().set('cartId', cartId);
|
||||
cart = await fetchCart();
|
||||
if (cart && cart.token) {
|
||||
cartId = cart.token;
|
||||
cookies().set('sw-context-token', cartId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!variantId) {
|
||||
return new Error('Missing variantId');
|
||||
}
|
||||
|
||||
try {
|
||||
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
||||
} catch (e) {
|
||||
return new Error('Error adding item', { cause: e });
|
||||
let quantity = 1;
|
||||
const apiClient = getApiClient(cartId);
|
||||
|
||||
// this part allows us to click multiple times on addToCart and increase the qty with that
|
||||
const itemInCart = cart?.lineItems?.filter((item) => item.id === variantId) as
|
||||
| ExtendedLineItem
|
||||
| undefined;
|
||||
if (itemInCart && itemInCart.quantity) {
|
||||
quantity = itemInCart.quantity + 1;
|
||||
}
|
||||
|
||||
const response = await apiClient.invoke('addLineItem post /checkout/cart/line-item', {
|
||||
items: [
|
||||
{
|
||||
id: variantId,
|
||||
quantity: quantity,
|
||||
referencedId: variantId,
|
||||
type: 'product'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const errorMessage = alertErrorMessages(response);
|
||||
if (errorMessage !== '') {
|
||||
return { message: errorMessage } as Error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
const cartId = cookies().get('sw-context-token')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
|
||||
try {
|
||||
await removeFromCart(cartId, [lineId]);
|
||||
} catch (e) {
|
||||
return new Error('Error removing item', { cause: e });
|
||||
const apiClient = getApiClient(cartId);
|
||||
await apiClient.invoke('deleteLineItem delete /checkout/cart/line-item?id[]={ids}', {
|
||||
ids: [lineId]
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -49,20 +123,29 @@ export const updateItemQuantity = async ({
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
const cartId = cookies().get('sw-context-token')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineId,
|
||||
merchandiseId: variantId,
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
} catch (e) {
|
||||
return new Error('Error updating item quantity', { cause: e });
|
||||
const apiClient = getApiClient(cartId);
|
||||
await apiClient.invoke('updateLineItem patch /checkout/cart/line-item', {
|
||||
items: [
|
||||
{
|
||||
id: lineId,
|
||||
referencedId: variantId,
|
||||
quantity: quantity
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -4,21 +4,23 @@ import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { ProductVariant } from 'lib/shopify/types';
|
||||
import { ProductVariant, Product } from 'lib/shopware/types';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export function AddToCart({
|
||||
product,
|
||||
variants,
|
||||
availableForSale
|
||||
}: {
|
||||
variants: ProductVariant[];
|
||||
availableForSale: boolean;
|
||||
product: Product;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : product.id;
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
@ -44,7 +46,7 @@ export function AddToCart({
|
||||
const error = await addItem(selectedVariantId);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import type { CartItem } from 'lib/shopware/types';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
|
@ -5,7 +5,7 @@ import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import type { CartItem } from 'lib/shopware/types';
|
||||
|
||||
export default function EditItemQuantityButton({
|
||||
item,
|
||||
|
@ -1,13 +1,23 @@
|
||||
import { getCart } from 'lib/shopify';
|
||||
import { fetchCart } from 'components/cart/actions';
|
||||
import { cookies } from 'next/headers';
|
||||
import CartModal from './modal';
|
||||
import { transformCart } from 'lib/shopware/transform';
|
||||
|
||||
export default async function Cart() {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
let cart;
|
||||
let resCart;
|
||||
const cartId = cookies().get('sw-context-token')?.value;
|
||||
|
||||
if (cartId) {
|
||||
cart = await getCart(cartId);
|
||||
resCart = await fetchCart(cartId);
|
||||
}
|
||||
|
||||
let cart;
|
||||
if (resCart) {
|
||||
cart = transformCart(resCart);
|
||||
}
|
||||
|
||||
if (!cart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <CartModal cart={cart} />;
|
||||
|
@ -4,7 +4,7 @@ import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||
import Price from 'components/price';
|
||||
import { DEFAULT_OPTION } from 'lib/constants';
|
||||
import type { Cart } from 'lib/shopify/types';
|
||||
import type { Cart } from 'lib/shopware/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@ -91,7 +91,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
});
|
||||
|
||||
const merchandiseUrl = createUrl(
|
||||
`/product/${item.merchandise.product.handle}`,
|
||||
`/product/${item.merchandise.product.path}`,
|
||||
new URLSearchParams(merchandiseSearchParams)
|
||||
);
|
||||
|
||||
|
84
components/collection/pagination.tsx
Normal file
84
components/collection/pagination.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
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 { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
||||
|
||||
export default function Pagination({
|
||||
itemsPerPage,
|
||||
itemsTotal,
|
||||
currentPage
|
||||
}: {
|
||||
itemsPerPage: number;
|
||||
itemsTotal: number;
|
||||
currentPage: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const currentParams = useSearchParams();
|
||||
const q = currentParams.get('q');
|
||||
const sort = currentParams.get('sort');
|
||||
const pageCount = Math.ceil(itemsTotal / itemsPerPage);
|
||||
|
||||
// Invoke when user click to request another page. test
|
||||
const handlePageClick = (event: clickEvent) => {
|
||||
const page = event.selected;
|
||||
const newPage = page + 1;
|
||||
let newUrl = createUrl(
|
||||
pathname,
|
||||
new URLSearchParams({
|
||||
...(q && { q }),
|
||||
...(sort && { sort })
|
||||
})
|
||||
);
|
||||
if (page !== 0) {
|
||||
newUrl = createUrl(
|
||||
pathname,
|
||||
new URLSearchParams({
|
||||
...(q && { q }),
|
||||
...(sort && { sort }),
|
||||
page: newPage.toString()
|
||||
})
|
||||
);
|
||||
}
|
||||
router.replace(newUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactPaginate
|
||||
onPageChange={handlePageClick}
|
||||
pageRangeDisplayed={itemsPerPage}
|
||||
initialPage={currentPage}
|
||||
pageCount={pageCount}
|
||||
breakLabel="..."
|
||||
nextLabel=<ArrowRightIcon className="h-5" />
|
||||
previousLabel=<ArrowLeftIcon className="h-5" />
|
||||
renderOnZeroPageCount={null}
|
||||
containerClassName="inline sm:flex text-base h-10 mx-auto"
|
||||
activeClassName="active"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type clickEvent = {
|
||||
index: number | null;
|
||||
selected: number;
|
||||
nextSelectedPage: number | undefined;
|
||||
event: object;
|
||||
isPrevious: boolean;
|
||||
isNext: boolean;
|
||||
isBreak: boolean;
|
||||
isActive: boolean;
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import type { Product } from 'lib/shopify/types';
|
||||
import { getCollectionProducts } from 'lib/shopware';
|
||||
import { isSeoUrls } from 'lib/shopware/helpers';
|
||||
import type { Product } from 'lib/shopware/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
function ThreeItemGridItem({
|
||||
@ -16,7 +17,7 @@ function ThreeItemGridItem({
|
||||
<div
|
||||
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||
>
|
||||
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
|
||||
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.path}`}>
|
||||
<GridTileImage
|
||||
src={item.featuredImage.url}
|
||||
fill
|
||||
@ -39,8 +40,11 @@ function ThreeItemGridItem({
|
||||
|
||||
export async function ThreeItemGrid() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const homepageItems = await getCollectionProducts({
|
||||
collection: 'hidden-homepage-featured-items'
|
||||
const collectionName = isSeoUrls()
|
||||
? 'Summer-BBQ/Hidden-Category'
|
||||
: '4ab73c06d90d4a5cb312209a64480d87';
|
||||
const { products: homepageItems } = await getCollectionProducts({
|
||||
collection: collectionName
|
||||
});
|
||||
|
||||
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
||||
|
@ -32,7 +32,8 @@ export function GridTileImage({
|
||||
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
|
||||
<Image
|
||||
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,
|
||||
'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}
|
||||
/>
|
||||
|
@ -1,16 +1,18 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
|
||||
export type ImageProps = {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function LogoIcon(props: ImageProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label={`${process.env.SITE_NAME} logo`}
|
||||
viewBox="0 0 32 28"
|
||||
{...props}
|
||||
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)}
|
||||
>
|
||||
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
|
||||
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
|
||||
</svg>
|
||||
<Image
|
||||
src="https://www.shopware.com/media/pages/solutions/shopware-frontends/shopware-frontends-intro-graphic-base.svg"
|
||||
alt="Shopware Composable Frontends Logo"
|
||||
width={Number(props.width)}
|
||||
height={Number(props.height)}
|
||||
></Image>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { Menu } from 'lib/shopware/types';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
@ -34,13 +34,25 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
|
||||
export default function FooterMenu({ menu }: { menu: Menu[] }) {
|
||||
if (!menu.length) return null;
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<ul>
|
||||
{menu.map((item: Menu) => {
|
||||
return <FooterMenuItem key={item.title} item={item} />;
|
||||
})}
|
||||
</ul>
|
||||
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>
|
||||
);
|
||||
));
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
import GitHubIcon from 'components/icons/github';
|
||||
import FooterMenu from 'components/layout/footer-menu';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { getMenu } from 'lib/shopware';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const { COMPANY_NAME, SITE_NAME } = process.env;
|
||||
@ -12,41 +13,43 @@ export default async function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
|
||||
const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
|
||||
const menu = await getMenu('next-js-frontend-footer-menu');
|
||||
const menu = await getMenu({ type: 'footer-navigation', depth: 2 });
|
||||
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||
|
||||
return (
|
||||
<footer className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<footer className="text-sm text-neutral-400 dark:text-neutral-600">
|
||||
<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>
|
||||
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
|
||||
<LogoSquare size="sm" />
|
||||
<span className="uppercase">{SITE_NAME}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[188px] w-[200px] flex-col gap-2">
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FooterMenu menu={menu} />
|
||||
</Suspense>
|
||||
<div className="md:ml-auto">
|
||||
<a
|
||||
className="flex items-center gap-2 text-lg hover:text-black dark:hover:text-neutral-300 md:text-sm"
|
||||
aria-label="Github Repository"
|
||||
href="https://github.com/vercel/commerce"
|
||||
<div className="grid grid-cols-1 gap-8 transition-colors duration-150 lg:grid-cols-12">
|
||||
<div className="col-span-1 lg:col-span-3">
|
||||
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
|
||||
<LogoSquare size="sm" />
|
||||
<span className="uppercase">{SITE_NAME}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[188px] w-[200px] flex-col gap-2">
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
<div className={skeleton} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<GitHubIcon className="h-6" />
|
||||
<p>Source</p>
|
||||
</a>
|
||||
<FooterMenu menu={menu} />
|
||||
</Suspense>
|
||||
<div className="md:ml-auto">
|
||||
<a
|
||||
aria-label="Github Repository"
|
||||
href="https://github.com/shopware/frontends"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubIcon className="h-6" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
|
||||
@ -56,13 +59,25 @@ export default async function Footer() {
|
||||
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
|
||||
</p>
|
||||
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
|
||||
<p>Designed in California</p>
|
||||
<p className="md:ml-auto">
|
||||
Crafted by{' '}
|
||||
<a href="https://vercel.com" className="text-black dark:text-white">
|
||||
▲ Vercel
|
||||
<p>Created by Shopware Composable Frontends</p>
|
||||
<div className="md:ml-auto">
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
href="https://frontends.shopware.com/"
|
||||
aria-label="Shopware Composable Frontends Link"
|
||||
target="_blank"
|
||||
className="text-black dark:text-white"
|
||||
>
|
||||
<div className="ml-4 h-auto w-10">
|
||||
<Image
|
||||
src="https://www.shopware.com/media/pages/solutions/shopware-frontends/shopware-frontends-intro-graphic-base.svg"
|
||||
alt="Shopware Composable Frontends Logo"
|
||||
width={40}
|
||||
height={40}
|
||||
></Image>
|
||||
</div>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
@ -1,16 +1,15 @@
|
||||
import Cart from 'components/cart';
|
||||
import OpenCart from 'components/cart/open-cart';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { getMenu } from 'lib/shopware';
|
||||
import { Menu } from 'lib/shopware/types';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
import MobileMenu from './mobile-menu';
|
||||
import Search from './search';
|
||||
const { SITE_NAME } = process.env;
|
||||
|
||||
export default async function Navbar() {
|
||||
const menu = await getMenu('next-js-frontend-header-menu');
|
||||
const menu = await getMenu({ type: 'main-navigation' });
|
||||
|
||||
return (
|
||||
<nav className="relative flex items-center justify-between p-4 lg:px-6">
|
||||
@ -18,16 +17,13 @@ export default async function Navbar() {
|
||||
<MobileMenu menu={menu} />
|
||||
</div>
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex w-full md:w-1/3">
|
||||
<div className="flex w-full md:w-4/6">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Go back home"
|
||||
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
||||
>
|
||||
<LogoSquare />
|
||||
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
|
||||
{SITE_NAME}
|
||||
</div>
|
||||
</Link>
|
||||
{menu.length ? (
|
||||
<ul className="hidden gap-6 text-sm md:flex md:items-center">
|
||||
@ -44,10 +40,10 @@ export default async function Navbar() {
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden justify-center md:flex md:w-1/3">
|
||||
<div className="hidden justify-center md:flex md:w-1/6">
|
||||
<Search />
|
||||
</div>
|
||||
<div className="flex justify-end md:w-1/3">
|
||||
<div className="flex justify-end md:w-1/6">
|
||||
<Suspense fallback={<OpenCart />}>
|
||||
<Cart />
|
||||
</Suspense>
|
||||
|
@ -6,7 +6,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { Menu } from 'lib/shopware/types';
|
||||
import Search from './search';
|
||||
|
||||
export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
|
@ -1,14 +1,14 @@
|
||||
import Grid from 'components/grid';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { Product } from 'lib/shopware/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ProductGridItems({ products }: { products: Product[] }) {
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<Grid.Item key={product.handle} className="animate-fadeIn">
|
||||
<Link className="relative inline-block h-full w-full" href={`/product/${product.handle}`}>
|
||||
<Grid.Item key={product.path} className="animate-fadeIn">
|
||||
<Link className="relative inline-block h-full w-full" href={`/product/${product.path}`}>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
|
@ -1,19 +1,23 @@
|
||||
import clsx from 'clsx';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { getCollections } from 'lib/shopify';
|
||||
import { getSubCollections } from 'lib/shopware';
|
||||
import FilterList from './filter';
|
||||
import { transformCollectionToList } from 'lib/shopware/transform';
|
||||
|
||||
async function CollectionList() {
|
||||
const collections = await getCollections();
|
||||
return <FilterList list={collections} title="Collections" />;
|
||||
async function CollectionList({ collection }: { collection: string }) {
|
||||
const collections = await getSubCollections(collection);
|
||||
if (collections) {
|
||||
const list = transformCollectionToList(collections);
|
||||
if (list.length > 0) return <FilterList list={list} title="Sub-Collections" />;
|
||||
}
|
||||
}
|
||||
|
||||
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
|
||||
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
|
||||
const items = 'bg-neutral-400 dark:bg-neutral-700';
|
||||
|
||||
export default function Collections() {
|
||||
export default function Collections({ collection }: { collection: string }) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
@ -31,7 +35,7 @@ export default function Collections() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CollectionList />
|
||||
<CollectionList collection={collection} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
@ -38,13 +38,18 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||
const searchParams = useSearchParams();
|
||||
const active = searchParams.get('sort') === item.slug;
|
||||
const q = searchParams.get('q');
|
||||
const href = createUrl(
|
||||
pathname,
|
||||
new URLSearchParams({
|
||||
...(q && { q }),
|
||||
...(item.slug && item.slug.length && { sort: item.slug })
|
||||
})
|
||||
);
|
||||
const page = searchParams.get('page');
|
||||
const href =
|
||||
item.slug && item.slug.length
|
||||
? createUrl(
|
||||
pathname,
|
||||
new URLSearchParams({
|
||||
...(q && { q }),
|
||||
...(page && { page }),
|
||||
sort: item.slug
|
||||
})
|
||||
)
|
||||
: pathname;
|
||||
const DynamicTag = active ? 'p' : Link;
|
||||
|
||||
return (
|
||||
|
@ -13,6 +13,8 @@ export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
|
||||
)}
|
||||
>
|
||||
<LogoIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={clsx({
|
||||
'h-[16px] w-[16px]': !size,
|
||||
'h-[10px] w-[10px]': size === 'sm'
|
||||
|
@ -17,7 +17,7 @@ export default async function OpengraphImage(props?: Props): Promise<ImageRespon
|
||||
(
|
||||
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
||||
<div tw="flex flex-none items-center justify-center border border-neutral-700 h-[160px] w-[160px] rounded-3xl">
|
||||
<LogoIcon width="64" height="58" fill="white" />
|
||||
<LogoIcon width="64" height="58" />
|
||||
</div>
|
||||
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
|
||||
</div>
|
||||
|
@ -27,7 +27,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
'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 (
|
||||
<>
|
||||
<div className="mr-8 h-full">
|
||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
|
||||
{images[imageIndex] && (
|
||||
<Image
|
||||
@ -84,8 +84,8 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
width={80}
|
||||
height={80}
|
||||
width={200}
|
||||
height={200}
|
||||
active={isActive}
|
||||
/>
|
||||
</Link>
|
||||
@ -94,6 +94,6 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Price from 'components/price';
|
||||
import Prose from 'components/prose';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { Product } from 'lib/shopware/types';
|
||||
import { VariantSelector } from './variant-selector';
|
||||
|
||||
export function ProductDescription({ product }: { product: Product }) {
|
||||
@ -18,14 +18,18 @@ export function ProductDescription({ product }: { product: Product }) {
|
||||
</div>
|
||||
<VariantSelector options={product.options} variants={product.variants} />
|
||||
|
||||
<AddToCart
|
||||
product={product}
|
||||
variants={product.variants}
|
||||
availableForSale={product.availableForSale}
|
||||
/>
|
||||
|
||||
{product.descriptionHtml ? (
|
||||
<Prose
|
||||
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
|
||||
className="m-6 max-h-96 overflow-y-auto text-sm leading-tight dark:text-white/[60%]"
|
||||
html={product.descriptionHtml}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||
import { ProductOption, ProductVariant } from 'lib/shopware/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
@ -27,4 +27,3 @@ export const TAGS = {
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
||||
|
@ -1,53 +0,0 @@
|
||||
import productFragment from './product';
|
||||
|
||||
const cartFragment = /* GraphQL */ `
|
||||
fragment cart on Cart {
|
||||
id
|
||||
checkoutUrl
|
||||
cost {
|
||||
subtotalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
totalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
totalTaxAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
lines(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
quantity
|
||||
cost {
|
||||
totalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
id
|
||||
title
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
product {
|
||||
...product
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
totalQuantity
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
|
||||
export default cartFragment;
|
@ -1,10 +0,0 @@
|
||||
const imageFragment = /* GraphQL */ `
|
||||
fragment image on Image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
`;
|
||||
|
||||
export default imageFragment;
|
@ -1,64 +0,0 @@
|
||||
import imageFragment from './image';
|
||||
import seoFragment from './seo';
|
||||
|
||||
const productFragment = /* GraphQL */ `
|
||||
fragment product on Product {
|
||||
id
|
||||
handle
|
||||
availableForSale
|
||||
title
|
||||
description
|
||||
descriptionHtml
|
||||
options {
|
||||
id
|
||||
name
|
||||
values
|
||||
}
|
||||
priceRange {
|
||||
maxVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
minVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
variants(first: 250) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
availableForSale
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
featuredImage {
|
||||
...image
|
||||
}
|
||||
images(first: 20) {
|
||||
edges {
|
||||
node {
|
||||
...image
|
||||
}
|
||||
}
|
||||
}
|
||||
seo {
|
||||
...seo
|
||||
}
|
||||
tags
|
||||
updatedAt
|
||||
}
|
||||
${imageFragment}
|
||||
${seoFragment}
|
||||
`;
|
||||
|
||||
export default productFragment;
|
@ -1,8 +0,0 @@
|
||||
const seoFragment = /* GraphQL */ `
|
||||
fragment seo on SEO {
|
||||
description
|
||||
title
|
||||
}
|
||||
`;
|
||||
|
||||
export default seoFragment;
|
@ -1,445 +0,0 @@
|
||||
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
addToCartMutation,
|
||||
createCartMutation,
|
||||
editCartItemsMutation,
|
||||
removeFromCartMutation
|
||||
} from './mutations/cart';
|
||||
import { getCartQuery } from './queries/cart';
|
||||
import {
|
||||
getCollectionProductsQuery,
|
||||
getCollectionQuery,
|
||||
getCollectionsQuery
|
||||
} from './queries/collection';
|
||||
import { getMenuQuery } from './queries/menu';
|
||||
import { getPageQuery, getPagesQuery } from './queries/page';
|
||||
import {
|
||||
getProductQuery,
|
||||
getProductRecommendationsQuery,
|
||||
getProductsQuery
|
||||
} from './queries/product';
|
||||
import {
|
||||
Cart,
|
||||
Collection,
|
||||
Connection,
|
||||
Image,
|
||||
Menu,
|
||||
Page,
|
||||
Product,
|
||||
ShopifyAddToCartOperation,
|
||||
ShopifyCart,
|
||||
ShopifyCartOperation,
|
||||
ShopifyCollection,
|
||||
ShopifyCollectionOperation,
|
||||
ShopifyCollectionProductsOperation,
|
||||
ShopifyCollectionsOperation,
|
||||
ShopifyCreateCartOperation,
|
||||
ShopifyMenuOperation,
|
||||
ShopifyPageOperation,
|
||||
ShopifyPagesOperation,
|
||||
ShopifyProduct,
|
||||
ShopifyProductOperation,
|
||||
ShopifyProductRecommendationsOperation,
|
||||
ShopifyProductsOperation,
|
||||
ShopifyRemoveFromCartOperation,
|
||||
ShopifyUpdateCartOperation
|
||||
} from './types';
|
||||
|
||||
const domain = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
|
||||
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||
|
||||
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
||||
|
||||
export async function shopifyFetch<T>({
|
||||
cache = 'force-cache',
|
||||
headers,
|
||||
query,
|
||||
tags,
|
||||
variables
|
||||
}: {
|
||||
cache?: RequestCache;
|
||||
headers?: HeadersInit;
|
||||
query: string;
|
||||
tags?: string[];
|
||||
variables?: ExtractVariables<T>;
|
||||
}): Promise<{ status: number; body: T } | never> {
|
||||
try {
|
||||
const result = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shopify-Storefront-Access-Token': key,
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(query && { query }),
|
||||
...(variables && { variables })
|
||||
}),
|
||||
cache,
|
||||
...(tags && { next: { tags } })
|
||||
});
|
||||
|
||||
const body = await result.json();
|
||||
|
||||
if (body.errors) {
|
||||
throw body.errors[0];
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
};
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
throw {
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const removeEdgesAndNodes = (array: Connection<any>) => {
|
||||
return array.edges.map((edge) => edge?.node);
|
||||
};
|
||||
|
||||
const reshapeCart = (cart: ShopifyCart): Cart => {
|
||||
if (!cart.cost?.totalTaxAmount) {
|
||||
cart.cost.totalTaxAmount = {
|
||||
amount: '0.0',
|
||||
currencyCode: 'USD'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cart,
|
||||
lines: removeEdgesAndNodes(cart.lines)
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
|
||||
if (!collection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...collection,
|
||||
path: `/search/${collection.handle}`
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeCollections = (collections: ShopifyCollection[]) => {
|
||||
const reshapedCollections = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection) {
|
||||
const reshapedCollection = reshapeCollection(collection);
|
||||
|
||||
if (reshapedCollection) {
|
||||
reshapedCollections.push(reshapedCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { images, variants, ...rest } = product;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
images: reshapeImages(images, product.title),
|
||||
variants: removeEdgesAndNodes(variants)
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeProducts = (products: ShopifyProduct[]) => {
|
||||
const reshapedProducts = [];
|
||||
|
||||
for (const product of products) {
|
||||
if (product) {
|
||||
const reshapedProduct = reshapeProduct(product);
|
||||
|
||||
if (reshapedProduct) {
|
||||
reshapedProducts.push(reshapedProduct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reshapedProducts;
|
||||
};
|
||||
|
||||
export async function createCart(): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyCreateCartOperation>({
|
||||
query: createCartMutation,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartCreate.cart);
|
||||
}
|
||||
|
||||
export async function addToCart(
|
||||
cartId: string,
|
||||
lines: { merchandiseId: string; quantity: number }[]
|
||||
): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
||||
query: addToCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
||||
}
|
||||
|
||||
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
|
||||
query: removeFromCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lineIds
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesRemove.cart);
|
||||
}
|
||||
|
||||
export async function updateCart(
|
||||
cartId: string,
|
||||
lines: { id: string; merchandiseId: string; quantity: number }[]
|
||||
): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
|
||||
query: editCartItemsMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesUpdate.cart);
|
||||
}
|
||||
|
||||
export async function getCart(cartId: string): Promise<Cart | undefined> {
|
||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||
query: getCartQuery,
|
||||
variables: { cartId },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
// Old carts becomes `null` when you checkout.
|
||||
if (!res.body.data.cart) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return reshapeCart(res.body.data.cart);
|
||||
}
|
||||
|
||||
export async function getCollection(handle: string): Promise<Collection | undefined> {
|
||||
const res = await shopifyFetch<ShopifyCollectionOperation>({
|
||||
query: getCollectionQuery,
|
||||
tags: [TAGS.collections],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeCollection(res.body.data.collection);
|
||||
}
|
||||
|
||||
export async function getCollectionProducts({
|
||||
collection,
|
||||
reverse,
|
||||
sortKey
|
||||
}: {
|
||||
collection: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
|
||||
query: getCollectionProductsQuery,
|
||||
tags: [TAGS.collections, TAGS.products],
|
||||
variables: {
|
||||
handle: collection,
|
||||
reverse,
|
||||
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.body.data.collection) {
|
||||
console.log(`No collection found for \`${collection}\``);
|
||||
return [];
|
||||
}
|
||||
|
||||
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
|
||||
}
|
||||
|
||||
export async function getCollections(): Promise<Collection[]> {
|
||||
const res = await shopifyFetch<ShopifyCollectionsOperation>({
|
||||
query: getCollectionsQuery,
|
||||
tags: [TAGS.collections]
|
||||
});
|
||||
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
|
||||
const collections = [
|
||||
{
|
||||
handle: '',
|
||||
title: 'All',
|
||||
description: 'All products',
|
||||
seo: {
|
||||
title: 'All',
|
||||
description: 'All products'
|
||||
},
|
||||
path: '/search',
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
// Filter out the `hidden` collections.
|
||||
// Collections that start with `hidden-*` need to be hidden on the search page.
|
||||
...reshapeCollections(shopifyCollections).filter(
|
||||
(collection) => !collection.handle.startsWith('hidden')
|
||||
)
|
||||
];
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
export async function getMenu(handle: string): Promise<Menu[]> {
|
||||
const res = await shopifyFetch<ShopifyMenuOperation>({
|
||||
query: getMenuQuery,
|
||||
tags: [TAGS.collections],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
|
||||
title: item.title,
|
||||
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPage(handle: string): Promise<Page> {
|
||||
const res = await shopifyFetch<ShopifyPageOperation>({
|
||||
query: getPageQuery,
|
||||
variables: { handle }
|
||||
});
|
||||
|
||||
return res.body.data.pageByHandle;
|
||||
}
|
||||
|
||||
export async function getPages(): Promise<Page[]> {
|
||||
const res = await shopifyFetch<ShopifyPagesOperation>({
|
||||
query: getPagesQuery
|
||||
});
|
||||
|
||||
return removeEdgesAndNodes(res.body.data.pages);
|
||||
}
|
||||
|
||||
export async function getProduct(handle: string): Promise<Product | undefined> {
|
||||
const res = await shopifyFetch<ShopifyProductOperation>({
|
||||
query: getProductQuery,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeProduct(res.body.data.product, false);
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
|
||||
query: getProductRecommendationsQuery,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
productId
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeProducts(res.body.data.productRecommendations);
|
||||
}
|
||||
|
||||
export async function getProducts({
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
}: {
|
||||
query?: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyProductsOperation>({
|
||||
query: getProductsQuery,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
}
|
||||
});
|
||||
|
||||
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() });
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const addToCartMutation = /* GraphQL */ `
|
||||
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
|
||||
cartLinesAdd(cartId: $cartId, lines: $lines) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
|
||||
export const createCartMutation = /* GraphQL */ `
|
||||
mutation createCart($lineItems: [CartLineInput!]) {
|
||||
cartCreate(input: { lines: $lineItems }) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
|
||||
export const editCartItemsMutation = /* GraphQL */ `
|
||||
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
|
||||
cartLinesUpdate(cartId: $cartId, lines: $lines) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
|
||||
export const removeFromCartMutation = /* GraphQL */ `
|
||||
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
|
||||
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
@ -1,56 +0,0 @@
|
||||
import productFragment from '../fragments/product';
|
||||
import seoFragment from '../fragments/seo';
|
||||
|
||||
const collectionFragment = /* GraphQL */ `
|
||||
fragment collection on Collection {
|
||||
handle
|
||||
title
|
||||
description
|
||||
seo {
|
||||
...seo
|
||||
}
|
||||
updatedAt
|
||||
}
|
||||
${seoFragment}
|
||||
`;
|
||||
|
||||
export const getCollectionQuery = /* GraphQL */ `
|
||||
query getCollection($handle: String!) {
|
||||
collection(handle: $handle) {
|
||||
...collection
|
||||
}
|
||||
}
|
||||
${collectionFragment}
|
||||
`;
|
||||
|
||||
export const getCollectionsQuery = /* GraphQL */ `
|
||||
query getCollections {
|
||||
collections(first: 100, sortKey: TITLE) {
|
||||
edges {
|
||||
node {
|
||||
...collection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${collectionFragment}
|
||||
`;
|
||||
|
||||
export const getCollectionProductsQuery = /* GraphQL */ `
|
||||
query getCollectionProducts(
|
||||
$handle: String!
|
||||
$sortKey: ProductCollectionSortKeys
|
||||
$reverse: Boolean
|
||||
) {
|
||||
collection(handle: $handle) {
|
||||
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
|
||||
edges {
|
||||
node {
|
||||
...product
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
export const getMenuQuery = /* GraphQL */ `
|
||||
query getMenu($handle: String!) {
|
||||
menu(handle: $handle) {
|
||||
items {
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
@ -1,41 +0,0 @@
|
||||
import seoFragment from '../fragments/seo';
|
||||
|
||||
const pageFragment = /* GraphQL */ `
|
||||
fragment page on Page {
|
||||
... on Page {
|
||||
id
|
||||
title
|
||||
handle
|
||||
body
|
||||
bodySummary
|
||||
seo {
|
||||
...seo
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
${seoFragment}
|
||||
`;
|
||||
|
||||
export const getPageQuery = /* GraphQL */ `
|
||||
query getPage($handle: String!) {
|
||||
pageByHandle(handle: $handle) {
|
||||
...page
|
||||
}
|
||||
}
|
||||
${pageFragment}
|
||||
`;
|
||||
|
||||
export const getPagesQuery = /* GraphQL */ `
|
||||
query getPages {
|
||||
pages(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
...page
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${pageFragment}
|
||||
`;
|
@ -1,32 +0,0 @@
|
||||
import productFragment from '../fragments/product';
|
||||
|
||||
export const getProductQuery = /* GraphQL */ `
|
||||
query getProduct($handle: String!) {
|
||||
product(handle: $handle) {
|
||||
...product
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
|
||||
export const getProductsQuery = /* GraphQL */ `
|
||||
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
|
||||
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
|
||||
edges {
|
||||
node {
|
||||
...product
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
|
||||
export const getProductRecommendationsQuery = /* GraphQL */ `
|
||||
query getProductRecommendations($productId: ID!) {
|
||||
productRecommendations(productId: $productId) {
|
||||
...product
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
@ -1,265 +0,0 @@
|
||||
export type Maybe<T> = T | null;
|
||||
|
||||
export type Connection<T> = {
|
||||
edges: Array<Edge<T>>;
|
||||
};
|
||||
|
||||
export type Edge<T> = {
|
||||
node: T;
|
||||
};
|
||||
|
||||
export type Cart = Omit<ShopifyCart, 'lines'> & {
|
||||
lines: CartItem[];
|
||||
};
|
||||
|
||||
export type CartItem = {
|
||||
id: string;
|
||||
quantity: number;
|
||||
cost: {
|
||||
totalAmount: Money;
|
||||
};
|
||||
merchandise: {
|
||||
id: string;
|
||||
title: string;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
product: Product;
|
||||
};
|
||||
};
|
||||
|
||||
export type Collection = ShopifyCollection & {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Image = {
|
||||
url: string;
|
||||
altText: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type Menu = {
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Money = {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
export type Page = {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
body: string;
|
||||
bodySummary: string;
|
||||
seo?: SEO;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
|
||||
variants: ProductVariant[];
|
||||
images: Image[];
|
||||
};
|
||||
|
||||
export type ProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type ProductVariant = {
|
||||
id: string;
|
||||
title: string;
|
||||
availableForSale: boolean;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
price: Money;
|
||||
};
|
||||
|
||||
export type SEO = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ShopifyCart = {
|
||||
id: string;
|
||||
checkoutUrl: string;
|
||||
cost: {
|
||||
subtotalAmount: Money;
|
||||
totalAmount: Money;
|
||||
totalTaxAmount: Money;
|
||||
};
|
||||
lines: Connection<CartItem>;
|
||||
totalQuantity: number;
|
||||
};
|
||||
|
||||
export type ShopifyCollection = {
|
||||
handle: string;
|
||||
title: string;
|
||||
description: string;
|
||||
seo: SEO;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ShopifyProduct = {
|
||||
id: string;
|
||||
handle: string;
|
||||
availableForSale: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
options: ProductOption[];
|
||||
priceRange: {
|
||||
maxVariantPrice: Money;
|
||||
minVariantPrice: Money;
|
||||
};
|
||||
variants: Connection<ProductVariant>;
|
||||
featuredImage: Image;
|
||||
images: Connection<Image>;
|
||||
seo: SEO;
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ShopifyCartOperation = {
|
||||
data: {
|
||||
cart: ShopifyCart;
|
||||
};
|
||||
variables: {
|
||||
cartId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyCreateCartOperation = {
|
||||
data: { cartCreate: { cart: ShopifyCart } };
|
||||
};
|
||||
|
||||
export type ShopifyAddToCartOperation = {
|
||||
data: {
|
||||
cartLinesAdd: {
|
||||
cart: ShopifyCart;
|
||||
};
|
||||
};
|
||||
variables: {
|
||||
cartId: string;
|
||||
lines: {
|
||||
merchandiseId: string;
|
||||
quantity: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyRemoveFromCartOperation = {
|
||||
data: {
|
||||
cartLinesRemove: {
|
||||
cart: ShopifyCart;
|
||||
};
|
||||
};
|
||||
variables: {
|
||||
cartId: string;
|
||||
lineIds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyUpdateCartOperation = {
|
||||
data: {
|
||||
cartLinesUpdate: {
|
||||
cart: ShopifyCart;
|
||||
};
|
||||
};
|
||||
variables: {
|
||||
cartId: string;
|
||||
lines: {
|
||||
id: string;
|
||||
merchandiseId: string;
|
||||
quantity: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyCollectionOperation = {
|
||||
data: {
|
||||
collection: ShopifyCollection;
|
||||
};
|
||||
variables: {
|
||||
handle: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyCollectionProductsOperation = {
|
||||
data: {
|
||||
collection: {
|
||||
products: Connection<ShopifyProduct>;
|
||||
};
|
||||
};
|
||||
variables: {
|
||||
handle: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyCollectionsOperation = {
|
||||
data: {
|
||||
collections: Connection<ShopifyCollection>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyMenuOperation = {
|
||||
data: {
|
||||
menu?: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
variables: {
|
||||
handle: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyPageOperation = {
|
||||
data: { pageByHandle: Page };
|
||||
variables: { handle: string };
|
||||
};
|
||||
|
||||
export type ShopifyPagesOperation = {
|
||||
data: {
|
||||
pages: Connection<Page>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyProductOperation = {
|
||||
data: { product: ShopifyProduct };
|
||||
variables: {
|
||||
handle: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyProductRecommendationsOperation = {
|
||||
data: {
|
||||
productRecommendations: ShopifyProduct[];
|
||||
};
|
||||
variables: {
|
||||
productId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyProductsOperation = {
|
||||
data: {
|
||||
products: Connection<ShopifyProduct>;
|
||||
};
|
||||
variables: {
|
||||
query?: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
};
|
||||
};
|
398
lib/shopware/api-extended.ts
Normal file
398
lib/shopware/api-extended.ts
Normal file
@ -0,0 +1,398 @@
|
||||
import { operations, operationPaths, components } from '@shopware/api-client/api-types';
|
||||
|
||||
type schemas = components['schemas'];
|
||||
type operationsWithoutOriginal = Omit<
|
||||
operations,
|
||||
| 'readCategory'
|
||||
| 'readCategoryList'
|
||||
| 'readNavigation'
|
||||
| 'readProduct'
|
||||
| 'readProductCrossSellings'
|
||||
| 'readProductListing'
|
||||
| 'searchPage'
|
||||
| 'addLineItem'
|
||||
| 'readCart'
|
||||
| 'deleteLineItem'
|
||||
>;
|
||||
export type extendedPaths =
|
||||
| 'readCategory post /category/{navigationId}?slots'
|
||||
| 'readCategoryList post /category'
|
||||
| 'readNavigation post /navigation/{activeId}/{rootId} sw-include-seo-urls'
|
||||
| 'readProduct post /product'
|
||||
| 'readProductCrossSellings post /product/{productId}/cross-selling'
|
||||
| 'readProductListing post /product-listing/{categoryId}'
|
||||
| 'searchPage post /search'
|
||||
| 'addLineItem post /checkout/cart/line-item'
|
||||
| 'readCart get /checkout/cart?name'
|
||||
| 'deleteLineItem delete /checkout/cart/line-item?id[]={ids}'
|
||||
| operationPaths;
|
||||
export type extendedOperations = operationsWithoutOriginal & {
|
||||
readCategory: extendedReadCategory;
|
||||
readCategoryList: extendedReadCategoryList;
|
||||
readNavigation: extendedReadNavigation;
|
||||
readProduct: extendedReadProduct;
|
||||
readProductCrossSellings: extendedReadProductCrossSellings;
|
||||
readProductListing: extendedReadProductListing;
|
||||
searchPage: extendedSearchPage;
|
||||
addLineItem: extendedAddLineItem;
|
||||
readCart: extendedReadCart;
|
||||
deleteLineItem: extendedDeleteLineItem;
|
||||
};
|
||||
|
||||
export type ExtendedCart = Omit<schemas['Cart'], 'lineItems'> & {
|
||||
lineItems?: ExtendedLineItem[];
|
||||
};
|
||||
|
||||
export type ExtendedLineItem = schemas['LineItem'] & {
|
||||
payload: {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
price: ProductPrice;
|
||||
cover?: schemas['Media'];
|
||||
};
|
||||
|
||||
type ProductPrice = {
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
totalPrice: number;
|
||||
calculatedTaxes: ProductCalculatedTaxes[];
|
||||
taxRules: ProductTaxRules[];
|
||||
referencePrice?: number;
|
||||
listPrice?: number;
|
||||
regulationPrice?: number;
|
||||
apiAlias: string;
|
||||
};
|
||||
|
||||
type ProductCalculatedTaxes = {
|
||||
tax: number;
|
||||
taxRate: number;
|
||||
price: number;
|
||||
apiAlias: string;
|
||||
};
|
||||
|
||||
type ProductTaxRules = {
|
||||
taxRate: number;
|
||||
percentage: number;
|
||||
apiAlias: string;
|
||||
};
|
||||
|
||||
export type ExtendedCmsBlock = Omit<schemas['CmsBlock'], 'slots'> & {
|
||||
slots?: schemas['CmsSlot'][];
|
||||
};
|
||||
|
||||
export type ExtendedCmsSection = Omit<schemas['CmsSection'], 'blocks'> & {
|
||||
blocks?: ExtendedCmsBlock[];
|
||||
};
|
||||
|
||||
export type ExtendedCmsPage = Omit<schemas['CmsPage'], 'sections'> & {
|
||||
sections?: ExtendedCmsSection[];
|
||||
};
|
||||
|
||||
export type ExtendedProduct = Omit<
|
||||
schemas['Product'],
|
||||
'children' | 'seoUrls' | 'options' | 'media'
|
||||
> & {
|
||||
children?: ExtendedProduct[];
|
||||
seoUrls?: schemas['SeoUrl'][];
|
||||
options?: schemas['PropertyGroupOption'][];
|
||||
media?: schemas['ProductMedia'][];
|
||||
};
|
||||
|
||||
export type ExtendedProductListingResult = Omit<schemas['ProductListingResult'], 'elements'> & {
|
||||
elements?: ExtendedProduct[];
|
||||
};
|
||||
|
||||
export type ExtendedCrossSellingElementCollection = Omit<
|
||||
schemas['CrossSellingElementCollection'],
|
||||
'products'
|
||||
> &
|
||||
{
|
||||
products?: ExtendedProduct[];
|
||||
}[];
|
||||
|
||||
export type ExtendedCategory = Omit<schemas['Category'], 'children' | 'seoUrls' | 'cmsPage'> & {
|
||||
children?: ExtendedCategory[];
|
||||
seoUrls?: schemas['SeoUrl'][];
|
||||
cmsPage?: ExtendedCmsPage;
|
||||
};
|
||||
|
||||
export type ExtendedCriteria = Omit<schemas['Criteria'], 'filter'> & {
|
||||
filter?: {
|
||||
field: string;
|
||||
type: string;
|
||||
value: string | boolean | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
type extendedReadProduct = {
|
||||
requestBody?: {
|
||||
content: {
|
||||
'application/json': ExtendedCriteria;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** Entity search result containing products */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
elements?: ExtendedProduct[];
|
||||
} & schemas['EntitySearchResult'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type extendedReadProductCrossSellings = {
|
||||
parameters: {
|
||||
path: {
|
||||
/** Product ID */
|
||||
productId: string;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** Found cross sellings */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExtendedCrossSellingElementCollection;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ExtendedProductCriteria = ExtendedCriteria & {
|
||||
/** Number of items per result page. If not set, the limit will be set according to the default products per page, defined in the system settings. */
|
||||
limit?: number;
|
||||
/** Filter by manufacturers. List of manufacturer identifiers separated by a `|`. */
|
||||
manufacturer?: string;
|
||||
/**
|
||||
* Enables/disabled filtering by manufacturer. If set to false, the `manufacturer` filter will be ignored. Also the `aggregations[manufacturer]` key will be removed from the response.
|
||||
* @default true
|
||||
*/
|
||||
'manufacturer-filter'?: boolean;
|
||||
/**
|
||||
* Filters by a maximum product price. Has to be higher than the `min-price` filter.
|
||||
* @default 0
|
||||
*/
|
||||
'max-price'?: number;
|
||||
/**
|
||||
* Filters by a minimum product price. Has to be lower than the `max-price` filter.
|
||||
* @default 0
|
||||
*/
|
||||
'min-price'?: number;
|
||||
/** Specifies the sorting of the products by `availableSortings`. If not set, the default sorting will be set according to the shop settings. The available sorting options are sent within the response under the `availableSortings` key. In order to sort by a field, consider using the `sort` parameter from the listing criteria. Do not use both parameters together, as it might lead to unexpected results. */
|
||||
order?: string;
|
||||
/**
|
||||
* Search result page
|
||||
* @default 1
|
||||
*/
|
||||
p?: number;
|
||||
/**
|
||||
* Enables/disabled filtering by price. If set to false, the `min-price` and `max-price` filter will be ignored. Also the `aggregations[price]` key will be removed from the response.
|
||||
* @default true
|
||||
*/
|
||||
'price-filter'?: boolean;
|
||||
/** Filters products by their properties. List of property identifiers separated by a `|`. */
|
||||
properties?: string;
|
||||
/**
|
||||
* Enables/disabled filtering by properties products. If set to false, the `properties` filter will be ignored. Also the `aggregations[properties]` key will be removed from the response.
|
||||
* @default true
|
||||
*/
|
||||
'property-filter'?: boolean;
|
||||
/** A whitelist of property identifiers which can be used for filtering. List of property identifiers separated by a `|`. The `property-filter` must be `true`, otherwise the whitelist has no effect. */
|
||||
'property-whitelist'?: string;
|
||||
/** Filter products with a minimum average rating. */
|
||||
rating?: number;
|
||||
/**
|
||||
* Enables/disabled filtering by rating. If set to false, the `rating` filter will be ignored. Also the `aggregations[rating]` key will be removed from the response.
|
||||
* @default true
|
||||
*/
|
||||
'rating-filter'?: boolean;
|
||||
/** By sending the parameter `reduce-aggregations` , the post-filters that were applied by the customer, are also applied to the aggregations. This has the consequence that only values are returned in the aggregations that would lead to further filter results. This parameter is a flag, the value has no effect. */
|
||||
'reduce-aggregations'?: string | null;
|
||||
/**
|
||||
* Filters products that are marked as shipping-free.
|
||||
* @default false
|
||||
*/
|
||||
'shipping-free'?: boolean;
|
||||
/**
|
||||
* Enables/disabled filtering by shipping-free products. If set to false, the `shipping-free` filter will be ignored. Also the `aggregations[shipping-free]` key will be removed from the response.
|
||||
* @default true
|
||||
*/
|
||||
'shipping-free-filter'?: boolean;
|
||||
};
|
||||
|
||||
type extendedSearchPage = {
|
||||
requestBody?: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** Using the search parameter, the server performs a text search on all records based on their data model and weighting as defined in the entity definition using the SearchRanking flag. */
|
||||
search: string;
|
||||
} & ExtendedProductCriteria &
|
||||
schemas['ProductListingFlags'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** Returns a product listing containing all products and additional fields to display a listing. */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExtendedProductListingResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type extendedReadNavigation = {
|
||||
parameters: {
|
||||
header?: {
|
||||
/** Instructs Shopware to try and resolve SEO URLs for the given navigation item */
|
||||
'sw-include-seo-urls'?: boolean;
|
||||
};
|
||||
path: {
|
||||
/** Identifier of the active category in the navigation tree (if not used, just set to the same as rootId). */
|
||||
activeId: string;
|
||||
/** Identifier of the root category for your desired navigation tree. You can use it to fetch sub-trees of your navigation tree. */
|
||||
rootId: string;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': ExtendedCriteria & {
|
||||
/** Return the categories as a tree or as a flat list. */
|
||||
buildTree?: unknown;
|
||||
/** Determines the depth of fetched navigation levels. */
|
||||
depth?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** All available navigations */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExtendedCategory[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type extendedReadCategory = {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** Resolves only the given slot identifiers. The identifiers have to be seperated by a '|' character */
|
||||
slots?: string;
|
||||
};
|
||||
path: {
|
||||
/** Identifier of the category to be fetched */
|
||||
navigationId: string;
|
||||
};
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
'application/json': ExtendedCriteria &
|
||||
Omit<schemas['ProductListingCriteria'], 'filter'> &
|
||||
ExtendedCriteria;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** The loaded category with cms page */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExtendedCategory;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type extendedReadCategoryList = {
|
||||
requestBody?: {
|
||||
content: {
|
||||
'application/json': ExtendedCriteria;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** Entity search result containing categories. */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
elements?: ExtendedCategory[];
|
||||
} & components['schemas']['EntitySearchResult'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type extendedReadProductListing = {
|
||||
parameters: {
|
||||
path: {
|
||||
/** Identifier of a category. */
|
||||
categoryId: string;
|
||||
};
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
'application/json': ExtendedProductCriteria & schemas['ProductListingFlags'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** Returns a product listing containing all products and additional fields to display a listing. */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExtendedProductListingResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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 = {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** The name of the new cart. This parameter will only be used when creating a new cart. */
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** Cart */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExtendedCart;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type extendedDeleteLineItem = {
|
||||
parameters: {
|
||||
query: {
|
||||
/** A list of product identifiers. */
|
||||
ids: string[];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** The updated cart. */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExtendedCart;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
242
lib/shopware/api.ts
Normal file
242
lib/shopware/api.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { createAPIClient, RequestReturnType, ApiClientError } from '@shopware/api-client';
|
||||
import { operations } from '@shopware/api-client/api-types';
|
||||
import {
|
||||
ExtendedCategory,
|
||||
ExtendedCriteria,
|
||||
ExtendedCrossSellingElementCollection,
|
||||
extendedOperations,
|
||||
extendedPaths,
|
||||
ExtendedProductListingResult
|
||||
} from './api-extended';
|
||||
import {
|
||||
CategoryListingResultSW,
|
||||
ProductListingCriteria,
|
||||
RouteNames,
|
||||
SeoURLResultSW,
|
||||
StoreNavigationTypeSW
|
||||
} from './types';
|
||||
import { getStoreDomainWithApiType, getAccessToken, getApiType } from 'lib/shopware/helpers';
|
||||
|
||||
export function getApiClient(cartId?: string) {
|
||||
const apiClientParams = {
|
||||
baseURL: getStoreDomainWithApiType(),
|
||||
accessToken: getAccessToken(),
|
||||
apiType: getApiType(),
|
||||
contextToken: cartId
|
||||
};
|
||||
|
||||
return createAPIClient<extendedOperations, extendedPaths>(apiClientParams);
|
||||
}
|
||||
|
||||
// reimport operations return types to use it in application
|
||||
export type ApiReturnType<OPERATION_NAME extends keyof operations> = RequestReturnType<
|
||||
OPERATION_NAME,
|
||||
operations
|
||||
>;
|
||||
|
||||
export async function requestNavigation(
|
||||
type: StoreNavigationTypeSW,
|
||||
depth: number
|
||||
): Promise<ExtendedCategory[] | undefined> {
|
||||
try {
|
||||
return await getApiClient().invoke(
|
||||
'readNavigation post /navigation/{activeId}/{rootId} sw-include-seo-urls',
|
||||
{
|
||||
activeId: type,
|
||||
rootId: type,
|
||||
depth: depth
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCategory(
|
||||
categoryId: string,
|
||||
criteria?: Partial<ProductListingCriteria>
|
||||
): Promise<ExtendedCategory | undefined> {
|
||||
try {
|
||||
return await getApiClient().invoke('readCategory post /category/{navigationId}?slots', {
|
||||
navigationId: categoryId,
|
||||
criteria
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCategoryList(
|
||||
criteria: Partial<ExtendedCriteria>
|
||||
): Promise<CategoryListingResultSW | undefined> {
|
||||
try {
|
||||
return await getApiClient().invoke('readCategoryList post /category', criteria);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestProductsCollection(
|
||||
criteria: Partial<ProductListingCriteria>
|
||||
): Promise<ExtendedProductListingResult | undefined> {
|
||||
try {
|
||||
return await getApiClient().invoke('readProduct post /product', criteria);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCategoryProductsCollection(
|
||||
categoryId: string,
|
||||
criteria: Partial<ProductListingCriteria>
|
||||
): Promise<ExtendedProductListingResult | undefined> {
|
||||
try {
|
||||
return await getApiClient().invoke('readProductListing post /product-listing/{categoryId}', {
|
||||
...criteria,
|
||||
categoryId: categoryId
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestSearchCollectionProducts(
|
||||
criteria?: Partial<ProductListingCriteria>
|
||||
): Promise<ExtendedProductListingResult | undefined> {
|
||||
try {
|
||||
return await getApiClient().invoke('searchPage post /search', {
|
||||
search: encodeURIComponent(criteria?.query || ''),
|
||||
...criteria
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestSeoUrls(routeName: RouteNames, page: number = 1, limit: number = 100) {
|
||||
try {
|
||||
return await getApiClient().invoke('readSeoUrl post /seo-url', {
|
||||
page: page,
|
||||
limit: limit,
|
||||
filter: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'routeName',
|
||||
value: routeName
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestSeoUrl(
|
||||
handle: string,
|
||||
page: number = 1,
|
||||
limit: number = 1
|
||||
): Promise<SeoURLResultSW | undefined> {
|
||||
try {
|
||||
const criteriaSeoUrls = {
|
||||
page: page,
|
||||
limit: limit,
|
||||
filter: [
|
||||
{
|
||||
type: 'multi',
|
||||
operator: 'or',
|
||||
queries: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'seoPathInfo',
|
||||
value: handle + '/'
|
||||
},
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'seoPathInfo',
|
||||
value: handle
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
// @ts-ignore
|
||||
return await getApiClient().invoke('readSeoUrl post /seo-url', criteriaSeoUrls);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCrossSell(
|
||||
productId: string,
|
||||
criteria?: Partial<ProductListingCriteria>
|
||||
): Promise<ExtendedCrossSellingElementCollection | undefined> {
|
||||
try {
|
||||
return await getApiClient().invoke(
|
||||
'readProductCrossSellings post /product/{productId}/cross-selling',
|
||||
{
|
||||
productId: productId,
|
||||
...criteria
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestContext(cartId?: string) {
|
||||
try {
|
||||
return getApiClient(cartId).invoke('readContext get /context', {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
console.error(error);
|
||||
console.error('Details:', error.details);
|
||||
} else {
|
||||
console.error('==>', error);
|
||||
}
|
||||
}
|
||||
}
|
267
lib/shopware/criteria.ts
Normal file
267
lib/shopware/criteria.ts
Normal file
@ -0,0 +1,267 @@
|
||||
export function getDefaultProductsCriteria(page: number = 1, limit: number = 15) {
|
||||
return {
|
||||
p: page,
|
||||
limit: limit,
|
||||
associations: getDefaultProductAssociations(),
|
||||
filter: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'parentId',
|
||||
value: null
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultProductCriteria(productId: string, page: number = 1, limit: number = 1) {
|
||||
return {
|
||||
page: page,
|
||||
limit: limit,
|
||||
associations: getDefaultProductAssociations(),
|
||||
filter: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'id',
|
||||
value: productId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultSearchProductsCriteria(
|
||||
query: string,
|
||||
page: number = 1,
|
||||
limit: number = 100
|
||||
) {
|
||||
return {
|
||||
page: page,
|
||||
limit: limit,
|
||||
query: query,
|
||||
associations: {
|
||||
options: {},
|
||||
media: {},
|
||||
seoUrls: {},
|
||||
children: {
|
||||
associations: {
|
||||
options: {},
|
||||
media: {},
|
||||
seoUrls: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultProductAssociations() {
|
||||
return {
|
||||
options: {
|
||||
associations: {
|
||||
group: {}
|
||||
}
|
||||
},
|
||||
media: {},
|
||||
seoUrls: {},
|
||||
children: {
|
||||
associations: {
|
||||
options: {
|
||||
associations: {
|
||||
group: {}
|
||||
}
|
||||
},
|
||||
media: {},
|
||||
seoUrls: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultCategoryCriteria(page: number = 1, limit: number = 1) {
|
||||
return {
|
||||
page: page,
|
||||
limit: limit,
|
||||
associations: {
|
||||
cmsPage: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultCategoryWithCmsCriteria(page: number = 1, limit: number = 1) {
|
||||
return {
|
||||
page: page,
|
||||
limit: limit,
|
||||
associations: {
|
||||
cmsPage: {
|
||||
associations: {
|
||||
sections: {
|
||||
associations: {
|
||||
blocks: {
|
||||
associations: {
|
||||
slots: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getStaticCollectionCriteria(page: number = 1, limit: number = 20) {
|
||||
return {
|
||||
page: page,
|
||||
limit: limit,
|
||||
associations: {
|
||||
cmsPage: {},
|
||||
seoUrls: {}
|
||||
},
|
||||
filter: [
|
||||
{
|
||||
type: 'not',
|
||||
operator: 'or',
|
||||
queries: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'level',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'active',
|
||||
value: false
|
||||
},
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'cmsPage.type',
|
||||
value: 'landingpage'
|
||||
},
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'cmsPage.type',
|
||||
value: 'page'
|
||||
},
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'type',
|
||||
value: 'link'
|
||||
},
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'childCount',
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
type: 'contains',
|
||||
field: 'breadcrumb',
|
||||
value: 'Footer'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// ToDo: Can be used later for dynamic collections depending on parent collection (only show next level of categories, next to products)
|
||||
export function getDefaultSubCategoriesCriteria(
|
||||
categoryId: string,
|
||||
page: number = 1,
|
||||
limit: number = 1
|
||||
) {
|
||||
return {
|
||||
page: page,
|
||||
limit: limit,
|
||||
associations: {
|
||||
cmsPage: {},
|
||||
children: {
|
||||
associations: {
|
||||
seoUrls: {}
|
||||
},
|
||||
filter: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'active',
|
||||
value: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
filter: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'id',
|
||||
value: categoryId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultCrossSellingCriteria(page: number = 1, limit: number = 1) {
|
||||
return {
|
||||
page: page,
|
||||
limit: limit,
|
||||
associations: {
|
||||
options: {},
|
||||
media: {},
|
||||
seoUrls: {}
|
||||
},
|
||||
filter: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'active',
|
||||
value: true
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function getSortingCriteria(sortKey?: string, reverse?: boolean) {
|
||||
switch (true) {
|
||||
case sortKey === 'CREATED_AT' && reverse === true:
|
||||
return {
|
||||
sort: [
|
||||
{
|
||||
field: 'createdAt',
|
||||
order: 'DESC'
|
||||
}
|
||||
]
|
||||
};
|
||||
case sortKey === 'PRICE' && reverse === true:
|
||||
return {
|
||||
sort: [
|
||||
{
|
||||
field: 'price',
|
||||
order: 'DESC'
|
||||
}
|
||||
]
|
||||
};
|
||||
case sortKey === 'PRICE' && reverse === false:
|
||||
return {
|
||||
sort: [
|
||||
{
|
||||
field: 'price',
|
||||
order: 'ASC'
|
||||
}
|
||||
]
|
||||
};
|
||||
case sortKey === 'BEST_SELLING' && reverse === false:
|
||||
return {
|
||||
sort: [
|
||||
{
|
||||
field: 'sales',
|
||||
order: 'DESC'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// sortKey === 'RELEVANCE' && reverse === false
|
||||
default:
|
||||
return {
|
||||
sort: [
|
||||
{
|
||||
field: 'availableStock',
|
||||
order: 'DESC'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
25
lib/shopware/helpers.ts
Normal file
25
lib/shopware/helpers.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export function getAccessToken(): string {
|
||||
return `${process.env.SHOPWARE_ACCESS_TOKEN}`;
|
||||
}
|
||||
|
||||
export function getStoreDomainWithApiType(): string {
|
||||
return getStoreDomain() + '/' + getApiType();
|
||||
}
|
||||
|
||||
export function getStoreDomain(protocol: boolean = true): string {
|
||||
return protocol
|
||||
? `https://${process.env.SHOPWARE_STORE_DOMAIN!}`
|
||||
: `${process.env.SHOPWARE_STORE_DOMAIN!}`;
|
||||
}
|
||||
|
||||
export function getApiType(): 'store-api' | 'admin-api' {
|
||||
if (`${process.env.SHOPWARE_API_TYPE!}` === 'admin-api') {
|
||||
return 'admin-api';
|
||||
}
|
||||
|
||||
return 'store-api';
|
||||
}
|
||||
|
||||
export function isSeoUrls(): boolean {
|
||||
return `${process.env.SHOPWARE_USE_SEO_URLS}` === 'true';
|
||||
}
|
334
lib/shopware/index.ts
Normal file
334
lib/shopware/index.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { TAGS } from 'lib/constants';
|
||||
import {
|
||||
requestCategory,
|
||||
requestCategoryList,
|
||||
requestCategoryProductsCollection,
|
||||
requestCrossSell,
|
||||
requestNavigation,
|
||||
requestProductsCollection,
|
||||
requestSearchCollectionProducts,
|
||||
requestSeoUrl,
|
||||
requestSeoUrls
|
||||
} from './api';
|
||||
import { ExtendedCategory, ExtendedProduct, ExtendedProductListingResult } from './api-extended';
|
||||
import {
|
||||
getDefaultCategoryCriteria,
|
||||
getDefaultCategoryWithCmsCriteria,
|
||||
getDefaultCrossSellingCriteria,
|
||||
getDefaultProductCriteria,
|
||||
getDefaultProductsCriteria,
|
||||
getDefaultSearchProductsCriteria,
|
||||
getDefaultSubCategoriesCriteria,
|
||||
getSortingCriteria
|
||||
} from './criteria';
|
||||
import {
|
||||
transformCollection,
|
||||
transformHandle,
|
||||
transformMenu,
|
||||
transformPage,
|
||||
transformProduct,
|
||||
transformProducts,
|
||||
transformSubCollection
|
||||
} from './transform';
|
||||
import {
|
||||
ApiSchemas,
|
||||
CategoryListingResultSW,
|
||||
Menu,
|
||||
Page,
|
||||
Product,
|
||||
ProductListingCriteria,
|
||||
StoreNavigationTypeSW
|
||||
} from './types';
|
||||
import { isSeoUrls } from 'lib/shopware/helpers';
|
||||
|
||||
export async function getMenu(params?: {
|
||||
type?: StoreNavigationTypeSW;
|
||||
depth?: number;
|
||||
}): Promise<Menu[]> {
|
||||
const type = params?.type || 'main-navigation';
|
||||
const depth = params?.depth || 1;
|
||||
const res = await requestNavigation(type, depth);
|
||||
|
||||
return res ? transformMenu(res, type) : [];
|
||||
}
|
||||
|
||||
export async function getPage(handle: string | []): Promise<Page | undefined> {
|
||||
let seoUrlElement;
|
||||
let pageIdOrHandle = decodeURIComponent(transformHandle(handle)).replace('cms/', '');
|
||||
|
||||
if (isSeoUrls()) {
|
||||
seoUrlElement = await getFirstSeoUrlElement(pageIdOrHandle);
|
||||
if (seoUrlElement) {
|
||||
pageIdOrHandle = seoUrlElement.foreignKey;
|
||||
}
|
||||
|
||||
if (!seoUrlElement) {
|
||||
console.log('[getPage] Did not found any seoUrl element with page handle:', pageIdOrHandle);
|
||||
}
|
||||
}
|
||||
|
||||
const category = await getCategory(pageIdOrHandle);
|
||||
if (!category) {
|
||||
console.log('[getPage] Did not found any category with handle:', pageIdOrHandle);
|
||||
}
|
||||
|
||||
return category ? transformPage(category, seoUrlElement) : undefined;
|
||||
}
|
||||
|
||||
export async function getFirstSeoUrlElement(
|
||||
handle: string
|
||||
): Promise<ApiSchemas['SeoUrl'] | undefined> {
|
||||
const seoURL = await requestSeoUrl(handle);
|
||||
if (seoURL && seoURL.elements && seoURL.elements.length > 0 && seoURL.elements[0]) {
|
||||
return seoURL.elements[0];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFirstProduct(productId: string): Promise<ExtendedProduct | undefined> {
|
||||
const productCriteria = getDefaultProductCriteria(productId);
|
||||
const listing: ExtendedProductListingResult | undefined = await requestProductsCollection(
|
||||
productCriteria
|
||||
);
|
||||
if (listing && listing.elements && listing.elements.length > 0 && listing.elements[0]) {
|
||||
return listing.elements[0];
|
||||
}
|
||||
}
|
||||
|
||||
// ToDo: should be more dynamic (depending on handle), should work with server and not client see generateStaticParams from next.js
|
||||
export async function getSubCollections(collection: string) {
|
||||
const collectionName = decodeURIComponent(transformHandle(collection ?? ''));
|
||||
let criteria = getDefaultSubCategoriesCriteria(collectionName);
|
||||
let res: CategoryListingResultSW | undefined = undefined;
|
||||
const parentCollectionName =
|
||||
Array.isArray(collection) && collection[0] ? collection[0] : undefined;
|
||||
|
||||
if (isSeoUrls()) {
|
||||
const seoUrlElement = await getFirstSeoUrlElement(collectionName);
|
||||
if (seoUrlElement) {
|
||||
criteria = getDefaultSubCategoriesCriteria(seoUrlElement.foreignKey);
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
res = await requestCategoryList(criteria);
|
||||
|
||||
return res ? transformSubCollection(res, parentCollectionName) : [];
|
||||
}
|
||||
|
||||
export async function getSearchCollectionProducts(params?: {
|
||||
query?: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
categoryId?: string;
|
||||
defaultSearchCriteria?: Partial<ProductListingCriteria>;
|
||||
}) {
|
||||
const searchQuery = params?.query ?? '';
|
||||
const criteria = getDefaultSearchProductsCriteria(searchQuery);
|
||||
const sorting = getSortingCriteria(params?.sortKey, params?.reverse);
|
||||
const searchCriteria = { ...criteria, ...sorting };
|
||||
|
||||
const search = await requestSearchCollectionProducts(searchCriteria);
|
||||
if (isSeoUrls() && search) {
|
||||
search.elements = await changeVariantUrlToParentUrl(search);
|
||||
}
|
||||
|
||||
return search ? transformProducts(search) : [];
|
||||
}
|
||||
|
||||
export async function changeVariantUrlToParentUrl(
|
||||
collection: ExtendedProductListingResult
|
||||
): Promise<ExtendedProduct[]> {
|
||||
const newElements: ExtendedProduct[] = [];
|
||||
if (collection.elements && collection.elements.length > 0) {
|
||||
await Promise.all(
|
||||
collection.elements.map(async (item) => {
|
||||
if (item.parentId && item.seoUrls && item.seoUrls[0]) {
|
||||
const parentProduct = await getFirstProduct(item.parentId);
|
||||
if (parentProduct && parentProduct.seoUrls && parentProduct.seoUrls[0]) {
|
||||
item.seoUrls[0].seoPathInfo = parentProduct.seoUrls[0].seoPathInfo;
|
||||
}
|
||||
}
|
||||
|
||||
newElements.push(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return newElements;
|
||||
}
|
||||
|
||||
export async function getCollectionProducts(params?: {
|
||||
collection: string;
|
||||
page?: number;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
categoryId?: string;
|
||||
defaultSearchCriteria?: Partial<ProductListingCriteria>;
|
||||
}): Promise<{ products: Product[]; total: number; limit: number }> {
|
||||
let products;
|
||||
let category = params?.categoryId;
|
||||
const collectionName = decodeURIComponent(transformHandle(params?.collection ?? ''));
|
||||
const sorting = getSortingCriteria(params?.sortKey, params?.reverse);
|
||||
|
||||
if (isSeoUrls() && !category && collectionName !== '') {
|
||||
const seoUrlElement = await getFirstSeoUrlElement(collectionName);
|
||||
if (seoUrlElement) {
|
||||
category = seoUrlElement.foreignKey;
|
||||
}
|
||||
if (!category) {
|
||||
console.log(
|
||||
'[useListing][search] Did not found any category with collection name:',
|
||||
collectionName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSeoUrls()) {
|
||||
category = collectionName ?? undefined;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
const criteria = !params?.defaultSearchCriteria
|
||||
? getDefaultProductsCriteria(params?.page)
|
||||
: params?.defaultSearchCriteria;
|
||||
const productsCriteria = { ...criteria, ...sorting };
|
||||
products = await requestCategoryProductsCollection(category, productsCriteria);
|
||||
if (products) {
|
||||
products.elements = await changeVariantUrlToParentUrl(products);
|
||||
}
|
||||
}
|
||||
|
||||
return products
|
||||
? {
|
||||
products: transformProducts(products),
|
||||
total: products.total ?? 0,
|
||||
limit: products.limit ?? 0
|
||||
}
|
||||
: { products: [], total: 0, limit: 0 };
|
||||
}
|
||||
|
||||
export async function getCategory(
|
||||
categoryId: string,
|
||||
cms: boolean = false
|
||||
): Promise<ExtendedCategory | undefined> {
|
||||
const criteria = cms ? getDefaultCategoryWithCmsCriteria() : getDefaultCategoryCriteria();
|
||||
return await requestCategory(categoryId, criteria);
|
||||
}
|
||||
|
||||
// This function is only used for generateMetadata at app/search/(collection)/[...collection]/page.tsx
|
||||
export async function getCollection(handle: string | []) {
|
||||
let path;
|
||||
let seoUrlElement;
|
||||
let categoryIdOrHandle = decodeURIComponent(transformHandle(handle));
|
||||
|
||||
if (isSeoUrls()) {
|
||||
seoUrlElement = await getFirstSeoUrlElement(categoryIdOrHandle);
|
||||
if (seoUrlElement) {
|
||||
categoryIdOrHandle = seoUrlElement.foreignKey;
|
||||
path = seoUrlElement.seoPathInfo ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
const category = await getCategory(categoryIdOrHandle);
|
||||
if (category) {
|
||||
const collection = transformCollection(category, seoUrlElement);
|
||||
path = path ?? category.id ?? '';
|
||||
|
||||
return {
|
||||
...collection,
|
||||
path: `/search/${path}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductSeoUrls() {
|
||||
const productSeoUrls: { path: string; updatedAt: string }[] = [];
|
||||
const seoUrls = await requestSeoUrls('frontend.detail.page');
|
||||
|
||||
if (seoUrls && seoUrls.elements && seoUrls.elements.length > 0) {
|
||||
seoUrls.elements.map((item) =>
|
||||
productSeoUrls.push({ path: item.seoPathInfo, updatedAt: item.updatedAt ?? item.createdAt })
|
||||
);
|
||||
}
|
||||
|
||||
return productSeoUrls;
|
||||
}
|
||||
|
||||
export async function getProduct(handle: string | []): Promise<Product | undefined> {
|
||||
let productSW: ExtendedProduct | undefined;
|
||||
let productId: string | undefined;
|
||||
const productHandle = decodeURIComponent(transformHandle(handle));
|
||||
productId = productHandle; // if we do not use seoUrls the handle should be the product id
|
||||
|
||||
if (isSeoUrls()) {
|
||||
const seoUrlElement = await getFirstSeoUrlElement(productHandle);
|
||||
if (seoUrlElement) {
|
||||
productId = seoUrlElement.foreignKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!productId) {
|
||||
console.log('[getProduct][search] Did not found any product with handle:', handle);
|
||||
}
|
||||
|
||||
if (productId) {
|
||||
const firstProduct = await getFirstProduct(productId);
|
||||
if (firstProduct) {
|
||||
productSW = firstProduct;
|
||||
}
|
||||
}
|
||||
|
||||
return productSW ? transformProduct(productSW) : undefined;
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
const products: ExtendedProductListingResult = {};
|
||||
|
||||
const res = await requestCrossSell(productId, getDefaultCrossSellingCriteria());
|
||||
// @ToDo: Make this more dynamic to merge multiple Cross-Sellings, at the moment we only get the first one
|
||||
if (res && res[0] && res[0].products) {
|
||||
products.elements = res[0].products;
|
||||
}
|
||||
|
||||
return products ? transformProducts(products) : [];
|
||||
}
|
||||
|
||||
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
|
||||
export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.json({
|
||||
status: 200,
|
||||
message: 'This is currently not working and was never tested.',
|
||||
now: Date.now()
|
||||
});
|
||||
// We always need to respond with a 200 status code,
|
||||
// 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-shopware-topic') || 'unknown';
|
||||
const secret = req.nextUrl.searchParams.get('secret');
|
||||
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
||||
const isProductUpdate = productWebhooks.includes(topic);
|
||||
|
||||
if (!secret || secret !== process.env.SHOPWARE_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() });
|
||||
}
|
419
lib/shopware/transform.ts
Normal file
419
lib/shopware/transform.ts
Normal file
@ -0,0 +1,419 @@
|
||||
import {
|
||||
ApiSchemas,
|
||||
Cart,
|
||||
CartItem,
|
||||
CategoryListingResultSW,
|
||||
Collection,
|
||||
Menu,
|
||||
Page,
|
||||
Product,
|
||||
ProductOption,
|
||||
ProductVariant
|
||||
} from './types';
|
||||
import {
|
||||
ExtendedCart,
|
||||
ExtendedCategory,
|
||||
ExtendedCmsPage,
|
||||
ExtendedLineItem,
|
||||
ExtendedProduct,
|
||||
ExtendedProductListingResult
|
||||
} from './api-extended';
|
||||
import { ListItem } from 'components/layout/search/filter';
|
||||
import { isSeoUrls } from 'lib/shopware/helpers';
|
||||
|
||||
export function transformMenu(res: ExtendedCategory[], type: string) {
|
||||
const menu: Menu[] = [];
|
||||
|
||||
res.map((item) => menu.push(transformMenuItem(item, type)));
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
function transformMenuItem(item: ExtendedCategory, type: string): Menu {
|
||||
const path = isSeoUrls()
|
||||
? item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo
|
||||
? type === 'footer-navigation'
|
||||
? '/cms/' + item.seoUrls[0].seoPathInfo
|
||||
: '/search/' + item.seoUrls[0].seoPathInfo
|
||||
: ''
|
||||
: type === 'footer-navigation'
|
||||
? '/cms/' + item.id ?? ''
|
||||
: '/search/' + item.id ?? '';
|
||||
|
||||
// @ToDo: currently only footer-navigation is used for cms pages, this need to be more dynamic (shoud depending on the item)
|
||||
return {
|
||||
id: item.id ?? '',
|
||||
title: item.name,
|
||||
children: item.children?.map((item) => transformMenuItem(item, type)) ?? [],
|
||||
path: path,
|
||||
type: item.children && item.children.length > 0 ? 'headline' : 'link'
|
||||
};
|
||||
}
|
||||
|
||||
export function transformPage(
|
||||
category: ExtendedCategory,
|
||||
seoUrlElement?: ApiSchemas['SeoUrl']
|
||||
): Page {
|
||||
let plainHtmlContent;
|
||||
if (category.cmsPage) {
|
||||
const cmsPage: ExtendedCmsPage = category.cmsPage;
|
||||
plainHtmlContent = transformToPlainHtmlContent(cmsPage);
|
||||
}
|
||||
|
||||
return {
|
||||
id: seoUrlElement?.id ?? category.id ?? '',
|
||||
title: category.translated?.metaTitle ?? category.name ?? '',
|
||||
handle: seoUrlElement?.seoPathInfo ?? category.id ?? '',
|
||||
body: plainHtmlContent ?? category.description ?? '',
|
||||
bodySummary: category.translated?.metaDescription ?? category.description ?? '',
|
||||
seo: {
|
||||
title: category.translated?.metaTitle ?? category.name ?? '',
|
||||
description: category.translated?.metaDescription ?? category.description ?? ''
|
||||
},
|
||||
createdAt: seoUrlElement?.createdAt ?? category.createdAt ?? '',
|
||||
updatedAt: seoUrlElement?.updatedAt ?? category.updatedAt ?? '',
|
||||
routeName: seoUrlElement?.routeName,
|
||||
originalCmsPage: category.cmsPage,
|
||||
foreignKey: seoUrlElement?.foreignKey ?? category.id
|
||||
};
|
||||
}
|
||||
|
||||
export function transformToPlainHtmlContent(cmsPage: ExtendedCmsPage): string {
|
||||
let plainHtmlContent = '';
|
||||
|
||||
cmsPage.sections?.map((section) => {
|
||||
section.blocks?.map((block) => {
|
||||
block.slots?.map((slot) => {
|
||||
if (slot.slot === 'content' && slot.config?.content) {
|
||||
const currentContent: string = slot.config.content.value + '';
|
||||
// we do not add content with h1, because will be added via template already
|
||||
if (!currentContent.match(/(<\/?h)([1])/)) {
|
||||
plainHtmlContent += currentContent;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return plainHtmlContent;
|
||||
}
|
||||
|
||||
export function transformCollection(
|
||||
resCategory: ExtendedCategory,
|
||||
seoUrlElement?: ApiSchemas['SeoUrl']
|
||||
) {
|
||||
return {
|
||||
handle: seoUrlElement?.seoPathInfo ?? resCategory.id ?? '',
|
||||
title: resCategory.translated?.metaTitle ?? resCategory.name ?? '',
|
||||
description: resCategory.description ?? '',
|
||||
seo: {
|
||||
title: resCategory.translated?.metaTitle ?? resCategory.name ?? '',
|
||||
description: resCategory.translated?.metaDescription ?? resCategory.description ?? ''
|
||||
},
|
||||
updatedAt:
|
||||
seoUrlElement?.updatedAt ??
|
||||
seoUrlElement?.createdAt ??
|
||||
resCategory.updatedAt ??
|
||||
resCategory.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
export function transformSubCollection(
|
||||
category: CategoryListingResultSW,
|
||||
parentCollectionName?: string
|
||||
): Collection[] {
|
||||
const collection: Collection[] = [];
|
||||
|
||||
if (category.elements && category.elements[0] && category.elements[0].children) {
|
||||
// we do not support type links at the moment and show only visible categories
|
||||
category.elements[0].children
|
||||
.filter((item) => item.visible)
|
||||
.filter((item) => item.type !== 'link')
|
||||
.map((item) => {
|
||||
const handle =
|
||||
isSeoUrls() && item.seoUrls ? findHandle(item.seoUrls, parentCollectionName) : item.id;
|
||||
if (handle) {
|
||||
collection.push({
|
||||
handle: handle,
|
||||
title: item.translated?.metaTitle ?? item.name ?? '',
|
||||
description: item.description ?? '',
|
||||
seo: {
|
||||
title: item.translated?.metaTitle ?? item.name ?? '',
|
||||
description: item.translated?.metaDescription ?? item.description ?? ''
|
||||
},
|
||||
childCount: item.childCount ?? 0,
|
||||
updatedAt: item.updatedAt ?? item.createdAt ?? ''
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
// small function to find longest handle and to make sure parent collection name is in the path
|
||||
function findHandle(seoUrls: ApiSchemas['SeoUrl'][], parentCollectionName?: string): string {
|
||||
let handle: string = '';
|
||||
seoUrls.map((item) => {
|
||||
if (
|
||||
!item.isDeleted &&
|
||||
item.isCanonical &&
|
||||
item.seoPathInfo &&
|
||||
item.seoPathInfo.length > handle.length &&
|
||||
item.seoPathInfo.includes(parentCollectionName ?? '')
|
||||
) {
|
||||
handle = item.seoPathInfo;
|
||||
}
|
||||
});
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function transformCollectionToList(collection: Collection[]): ListItem[] {
|
||||
const listItem: ListItem[] = [];
|
||||
|
||||
if (collection && collection.length > 0) {
|
||||
collection.map((item) => {
|
||||
// we asume that when there is not product child count it must be a cms page
|
||||
const pagePrefix = item.childCount === 0 ? '/cms' : '/search';
|
||||
const newHandle = item.handle.replace('Main-navigation/', '');
|
||||
listItem.push({
|
||||
title: item.title,
|
||||
path: `${pagePrefix}/${newHandle}`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return listItem;
|
||||
}
|
||||
|
||||
export function transformProducts(res: ExtendedProductListingResult): Product[] {
|
||||
const products: Product[] = [];
|
||||
|
||||
if (res.elements && res.elements.length > 0) {
|
||||
res.elements.map((item) => products.push(transformProduct(item)));
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
export function transformProduct(item: ExtendedProduct): Product {
|
||||
const productOptions = transformOptions(item);
|
||||
const productVariants = transformVariants(item);
|
||||
|
||||
let path = item.parentId ?? item.id ?? '';
|
||||
if (isSeoUrls()) {
|
||||
path =
|
||||
item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo
|
||||
? item.seoUrls[0].seoPathInfo
|
||||
: '';
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id ?? '',
|
||||
path: path,
|
||||
availableForSale: item.available ?? false,
|
||||
title: item.translated ? item.translated.name ?? '' : item.name,
|
||||
description: item.translated?.metaDescription
|
||||
? item.translated.metaDescription ?? ''
|
||||
: item.metaDescription ?? '',
|
||||
descriptionHtml: item.translated?.description
|
||||
? item.translated.description ?? ''
|
||||
: item.description ?? '',
|
||||
options: productOptions,
|
||||
priceRange: {
|
||||
maxVariantPrice: {
|
||||
amount: item.calculatedPrice?.totalPrice ? String(item.calculatedPrice?.totalPrice) : '0',
|
||||
currencyCode: 'EUR'
|
||||
},
|
||||
minVariantPrice: {
|
||||
amount: item.calculatedCheapestPrice?.totalPrice
|
||||
? String(item.calculatedPrice?.totalPrice)
|
||||
: '0',
|
||||
currencyCode: 'EUR'
|
||||
}
|
||||
},
|
||||
variants: productVariants,
|
||||
featuredImage: {
|
||||
url: item.cover?.media?.url ?? '',
|
||||
altText: item.cover?.media?.translated?.alt ?? '',
|
||||
width: item.cover?.media?.metaData?.width ? Number(item.cover?.media?.metaData?.width) : 0,
|
||||
height: item.cover?.media?.metaData?.width ? Number(item.cover?.media?.metaData?.height) : 0
|
||||
},
|
||||
images: item.media
|
||||
? item.media.map((img) => ({
|
||||
url: img.media?.url ?? '',
|
||||
altText: img.media?.translated?.alt ?? '',
|
||||
width: img.media?.metaData?.width ? Number(img.media?.metaData?.width) : 0,
|
||||
height: img.media?.metaData?.width ? Number(img.media?.metaData?.height) : 0
|
||||
}))
|
||||
: [],
|
||||
seo: {
|
||||
title: item.translated?.metaTitle ?? item.translated?.name ?? item.name ?? '',
|
||||
description: item.translated?.metaDescription ?? ''
|
||||
},
|
||||
tags: [''], // @ToDo: Add keywords or do we have tags?
|
||||
updatedAt: item.updatedAt ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function transformOptions(parent: ExtendedProduct): ProductOption[] {
|
||||
// we only transform options for parents with children, ignore child products with options
|
||||
const productOptions: ProductOption[] = [];
|
||||
if (parent.children && parent.parentId === null && parent.children.length > 0) {
|
||||
const group: { [key: string]: string[] } = {};
|
||||
const groupId: { [key: string]: string } = {};
|
||||
parent.children.map((child) => {
|
||||
child.options?.map((option) => {
|
||||
if (option && option.group) {
|
||||
groupId[option.group.name] = option.groupId;
|
||||
group[option.group.name] = group[option.group.name]
|
||||
? [...new Set([...(group[option.group.name] as []), ...[option.name]])]
|
||||
: [option.name];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(group)) {
|
||||
for (const [currentGroupName, currentGroupId] of Object.entries(groupId)) {
|
||||
if (key === currentGroupName) {
|
||||
productOptions.push({
|
||||
id: currentGroupId,
|
||||
name: key,
|
||||
values: value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return productOptions;
|
||||
}
|
||||
|
||||
function transformVariants(parent: ExtendedProduct): ProductVariant[] {
|
||||
const productVariants: ProductVariant[] = [];
|
||||
if (parent.children && parent.parentId === null && parent.children.length > 0) {
|
||||
parent.children.map((child) => {
|
||||
if (child.id) {
|
||||
const selectedOptions: { name: string; value: string }[] = [];
|
||||
child.options?.map((option) => {
|
||||
if (option.group) {
|
||||
selectedOptions.push({
|
||||
name: option.group.name,
|
||||
value: option.name
|
||||
});
|
||||
}
|
||||
});
|
||||
const currentVariant: ProductVariant = {
|
||||
id: child.id,
|
||||
title: child.name,
|
||||
availableForSale: child.available ?? false,
|
||||
selectedOptions: selectedOptions,
|
||||
price: {
|
||||
amount: child.calculatedPrice?.totalPrice
|
||||
? String(child.calculatedPrice?.totalPrice)
|
||||
: '0',
|
||||
currencyCode: 'EUR'
|
||||
}
|
||||
};
|
||||
|
||||
productVariants.push(currentVariant);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return productVariants;
|
||||
}
|
||||
|
||||
export function transformHandle(handle: string | []): string {
|
||||
let collectionName: string | [] | undefined = handle;
|
||||
if (Array.isArray(collectionName)) {
|
||||
collectionName = collectionName.join('/');
|
||||
}
|
||||
|
||||
return collectionName ?? '';
|
||||
}
|
||||
|
||||
export function transformCart(resCart: ExtendedCart): Cart {
|
||||
return {
|
||||
checkoutUrl: 'https://frontends-demo.vercel.app',
|
||||
cost: {
|
||||
subtotalAmount: {
|
||||
amount: resCart.price?.positionPrice?.toString() || '0',
|
||||
currencyCode: 'EUR'
|
||||
},
|
||||
totalAmount: {
|
||||
amount: resCart.price?.totalPrice?.toString() || '0',
|
||||
currencyCode: 'EUR'
|
||||
},
|
||||
totalTaxAmount: {
|
||||
amount: '0',
|
||||
currencyCode: 'EUR'
|
||||
}
|
||||
},
|
||||
id: resCart.token ?? '',
|
||||
lines:
|
||||
resCart.lineItems?.map((lineItem: ExtendedLineItem) => transformLineItem(lineItem)) || [],
|
||||
totalQuantity: resCart.lineItems ? calculateTotalCartQuantity(resCart.lineItems) : 0
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTotalCartQuantity(lineItems: ExtendedLineItem[]) {
|
||||
let totalQuantity = 0;
|
||||
lineItems.forEach((lineItem) => {
|
||||
totalQuantity += lineItem.quantity ?? 0;
|
||||
});
|
||||
|
||||
return totalQuantity;
|
||||
}
|
||||
|
||||
function transformLineItem(resLineItem: ExtendedLineItem): CartItem {
|
||||
return {
|
||||
id: resLineItem.id || '',
|
||||
quantity: resLineItem.quantity ?? 0,
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: resLineItem.price?.totalPrice.toString() || '',
|
||||
currencyCode: 'EUR'
|
||||
}
|
||||
},
|
||||
merchandise: {
|
||||
id: resLineItem.referencedId ?? '',
|
||||
title: resLineItem.label ?? '',
|
||||
selectedOptions: [],
|
||||
product: {
|
||||
description: resLineItem.description ?? '',
|
||||
descriptionHtml: resLineItem.description ?? '',
|
||||
id: resLineItem.referencedId ?? '',
|
||||
images: [],
|
||||
path: resLineItem.referencedId ?? '',
|
||||
seo: {
|
||||
description: resLineItem.description ?? '',
|
||||
title: resLineItem.label ?? ''
|
||||
},
|
||||
availableForSale: true,
|
||||
featuredImage: {
|
||||
url: resLineItem.cover?.url ?? '',
|
||||
altText: resLineItem.cover?.translated?.alt ?? resLineItem.cover?.alt ?? '',
|
||||
width: Number(resLineItem.cover?.metaData?.width) ?? 0,
|
||||
height: Number(resLineItem.cover?.metaData?.height) ?? 0
|
||||
},
|
||||
options: [],
|
||||
variants: [],
|
||||
priceRange: {
|
||||
minVariantPrice: {
|
||||
amount: '', // @ToDo: should be correct value
|
||||
currencyCode: 'EUR'
|
||||
},
|
||||
maxVariantPrice: {
|
||||
amount: '', // @ToDo: should be correct value
|
||||
currencyCode: 'EUR'
|
||||
}
|
||||
},
|
||||
tags: [],
|
||||
title: resLineItem.label ?? '',
|
||||
updatedAt: resLineItem.payload?.updatedAt ?? resLineItem.payload?.createdAt ?? ''
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
134
lib/shopware/types.ts
Normal file
134
lib/shopware/types.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { components } from '@shopware/api-client/api-types';
|
||||
import { ExtendedCriteria, ExtendedCategory, ExtendedCmsPage } from './api-extended';
|
||||
|
||||
/** Shopware Types */
|
||||
|
||||
/** Schemas */
|
||||
export type ApiSchemas = components['schemas'];
|
||||
export type StoreNavigationTypeSW = 'main-navigation' | 'footer-navigation' | 'service-navigation';
|
||||
export type ProductListingCriteria = {
|
||||
query: string;
|
||||
} & Omit<ApiSchemas['ProductListingCriteria'], 'filter'> &
|
||||
ExtendedCriteria;
|
||||
export type RouteNames =
|
||||
| 'frontend.navigation.page'
|
||||
| 'frontend.detail.page'
|
||||
| 'frontend.account.customer-group-registration.page'
|
||||
| 'frontend.landing.page';
|
||||
|
||||
/** Return Types */
|
||||
export type CategoryListingResultSW = {
|
||||
elements?: ExtendedCategory[];
|
||||
} & ApiSchemas['EntitySearchResult'];
|
||||
export type SeoURLResultSW = {
|
||||
elements?: ApiSchemas['SeoUrl'][];
|
||||
} & ApiSchemas['EntitySearchResult'];
|
||||
|
||||
/** Vercel Commerce Types */
|
||||
export type Menu = { id: string; title: string; path: string; type: string; children: Menu[] };
|
||||
|
||||
export type Page = {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
body: string;
|
||||
bodySummary: string;
|
||||
seo?: SEO;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
routeName?: string;
|
||||
foreignKey?: string;
|
||||
originalCmsPage?: ExtendedCmsPage;
|
||||
};
|
||||
|
||||
export type ProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type SEO = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type Money = {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
export type ProductVariant = {
|
||||
id: string;
|
||||
title: string;
|
||||
availableForSale: boolean;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
price: Money;
|
||||
};
|
||||
|
||||
export type Image = {
|
||||
url: string;
|
||||
altText: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
path: string;
|
||||
availableForSale: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
options: ProductOption[];
|
||||
priceRange: {
|
||||
maxVariantPrice: Money;
|
||||
minVariantPrice: Money;
|
||||
};
|
||||
variants: ProductVariant[];
|
||||
featuredImage: Image;
|
||||
images: Image[];
|
||||
seo: SEO;
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Collection = {
|
||||
handle: string;
|
||||
title: string;
|
||||
description: string;
|
||||
seo: SEO;
|
||||
childCount: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Cart = {
|
||||
id: string;
|
||||
checkoutUrl: string;
|
||||
cost: {
|
||||
subtotalAmount: Money;
|
||||
totalAmount: Money;
|
||||
totalTaxAmount: Money;
|
||||
};
|
||||
lines: CartItem[];
|
||||
totalQuantity: number;
|
||||
};
|
||||
|
||||
export type CartItem = {
|
||||
id: string;
|
||||
quantity: number;
|
||||
cost: {
|
||||
totalAmount: Money;
|
||||
};
|
||||
merchandise: {
|
||||
id: string;
|
||||
title: string;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
product: Product;
|
||||
};
|
||||
};
|
@ -1,26 +1,3 @@
|
||||
export interface ShopifyErrorLike {
|
||||
status: number;
|
||||
message: Error;
|
||||
}
|
||||
|
||||
export const isObject = (object: unknown): object is Record<string, unknown> => {
|
||||
return typeof object === 'object' && object !== null && !Array.isArray(object);
|
||||
};
|
||||
|
||||
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
|
||||
if (!isObject(error)) return false;
|
||||
|
||||
if (error instanceof Error) return true;
|
||||
|
||||
return findError(error);
|
||||
};
|
||||
|
||||
function findError<T extends object>(error: T): boolean {
|
||||
if (Object.prototype.toString.call(error) === '[object Error]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(error) as T | null;
|
||||
|
||||
return prototype === null ? false : findError(prototype);
|
||||
}
|
||||
|
@ -8,12 +8,12 @@ module.exports = {
|
||||
serverActions: true
|
||||
},
|
||||
images: {
|
||||
// also other fromats like jpg, jpeg, png are working without adding them here
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.shopify.com',
|
||||
pathname: '/s/files/**'
|
||||
hostname: 'cdn.shopware.store'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
14
package.json
14
package.json
@ -6,28 +6,27 @@
|
||||
"pnpm": ">=7"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "next dev",
|
||||
"lint": "next lint",
|
||||
"lint-staged": "lint-staged",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier --write --ignore-unknown .",
|
||||
"prettier:check": "prettier --check --ignore-unknown .",
|
||||
"start": "next start",
|
||||
"test": "pnpm lint && pnpm prettier:check"
|
||||
},
|
||||
"git": {
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@shopware/api-client": "0.0.0-canary-20230801081457",
|
||||
"clsx": "^2.0.0",
|
||||
"next": "13.4.12",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
"react-dom": "18.2.0",
|
||||
"react-paginate": "^8.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@ -41,6 +40,7 @@
|
||||
"eslint-config-next": "^13.4.12",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.2.3",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.0",
|
||||
|
215
pnpm-lock.yaml
generated
215
pnpm-lock.yaml
generated
@ -1,5 +1,9 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@headlessui/react':
|
||||
specifier: ^1.7.15
|
||||
@ -7,6 +11,9 @@ dependencies:
|
||||
'@heroicons/react':
|
||||
specifier: ^2.0.18
|
||||
version: 2.0.18(react@18.2.0)
|
||||
'@shopware/api-client':
|
||||
specifier: 0.0.0-canary-20230801081457
|
||||
version: 0.0.0-canary-20230801081457
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
@ -19,6 +26,9 @@ dependencies:
|
||||
react-dom:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
react-paginate:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0(react@18.2.0)
|
||||
|
||||
devDependencies:
|
||||
'@tailwindcss/container-queries':
|
||||
@ -54,6 +64,9 @@ devDependencies:
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^48.0.0
|
||||
version: 48.0.0(eslint@8.45.0)
|
||||
husky:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
lint-staged:
|
||||
specifier: ^13.2.3
|
||||
version: 13.2.3
|
||||
@ -120,16 +133,16 @@ packages:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
dependencies:
|
||||
eslint: 8.45.0
|
||||
eslint-visitor-keys: 3.4.1
|
||||
eslint-visitor-keys: 3.4.2
|
||||
dev: true
|
||||
|
||||
/@eslint-community/regexpp@4.5.1:
|
||||
resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==}
|
||||
/@eslint-community/regexpp@4.6.2:
|
||||
resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==}
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
dev: true
|
||||
|
||||
/@eslint/eslintrc@2.1.0:
|
||||
resolution: {integrity: sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==}
|
||||
/@eslint/eslintrc@2.1.1:
|
||||
resolution: {integrity: sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
@ -352,6 +365,12 @@ packages:
|
||||
resolution: {integrity: sha512-V+MvGwaHH03hYhY+k6Ef/xKd6RYlc4q8WBx+2ANmipHJcKuktNcI/NgEsJgdSUF6Lw32njT6OnrRsKYCdgHjYw==}
|
||||
dev: true
|
||||
|
||||
/@shopware/api-client@0.0.0-canary-20230801081457:
|
||||
resolution: {integrity: sha512-YlHBa0eTYeFQg1QlBIcIWfy6x9KAmksJsmqpvEF5FQGJtUMcOvn4t6Q9tTrB4itV/8137xw2xo27sZn+JNxgOg==}
|
||||
dependencies:
|
||||
ofetch: 1.1.1
|
||||
dev: false
|
||||
|
||||
/@swc/helpers@0.5.1:
|
||||
resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==}
|
||||
dependencies:
|
||||
@ -471,7 +490,7 @@ packages:
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 5.62.0
|
||||
eslint-visitor-keys: 3.4.1
|
||||
eslint-visitor-keys: 3.4.2
|
||||
dev: true
|
||||
|
||||
/@vercel/git-hooks@1.0.0:
|
||||
@ -595,6 +614,17 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/array.prototype.findlastindex@1.2.2:
|
||||
resolution: {integrity: sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
define-properties: 1.2.0
|
||||
es-abstract: 1.22.1
|
||||
es-shim-unscopables: 1.0.0
|
||||
get-intrinsic: 1.2.1
|
||||
dev: true
|
||||
|
||||
/array.prototype.flat@1.3.1:
|
||||
resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -653,8 +683,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
dependencies:
|
||||
browserslist: 4.21.9
|
||||
caniuse-lite: 1.0.30001513
|
||||
browserslist: 4.21.10
|
||||
caniuse-lite: 1.0.30001519
|
||||
fraction.js: 4.2.0
|
||||
normalize-range: 0.1.2
|
||||
picocolors: 1.0.0
|
||||
@ -713,15 +743,15 @@ packages:
|
||||
fill-range: 7.0.1
|
||||
dev: true
|
||||
|
||||
/browserslist@4.21.9:
|
||||
resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==}
|
||||
/browserslist@4.21.10:
|
||||
resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001513
|
||||
electron-to-chromium: 1.4.453
|
||||
caniuse-lite: 1.0.30001519
|
||||
electron-to-chromium: 1.4.484
|
||||
node-releases: 2.0.13
|
||||
update-browserslist-db: 1.0.11(browserslist@4.21.9)
|
||||
update-browserslist-db: 1.0.11(browserslist@4.21.10)
|
||||
dev: true
|
||||
|
||||
/builtin-modules@3.3.0:
|
||||
@ -760,13 +790,8 @@ packages:
|
||||
engines: {node: '>= 6'}
|
||||
dev: true
|
||||
|
||||
/caniuse-lite@1.0.30001513:
|
||||
resolution: {integrity: sha512-pnjGJo7SOOjAGytZZ203Em95MRM8Cr6jhCXNF/FAXTpCTRTECnqQWLpiTRqrFtdYcth8hf4WECUpkezuYsMVww==}
|
||||
dev: true
|
||||
|
||||
/caniuse-lite@1.0.30001517:
|
||||
resolution: {integrity: sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==}
|
||||
dev: false
|
||||
/caniuse-lite@1.0.30001519:
|
||||
resolution: {integrity: sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==}
|
||||
|
||||
/chalk@2.4.2:
|
||||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||
@ -957,7 +982,7 @@ packages:
|
||||
dependencies:
|
||||
bundle-name: 3.0.0
|
||||
default-browser-id: 3.0.0
|
||||
execa: 7.1.1
|
||||
execa: 7.2.0
|
||||
titleize: 3.0.0
|
||||
dev: true
|
||||
|
||||
@ -979,6 +1004,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/destr@2.0.1:
|
||||
resolution: {integrity: sha512-M1Ob1zPSIvlARiJUkKqvAZ3VAqQY6Jcuth/pBKQ2b1dX/Qx0OnJ8Vux6J2H5PTMQeRzWrrbTu70VxBfv/OPDJA==}
|
||||
dev: false
|
||||
|
||||
/didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
dev: true
|
||||
@ -1012,8 +1041,8 @@ packages:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
dev: true
|
||||
|
||||
/electron-to-chromium@1.4.453:
|
||||
resolution: {integrity: sha512-BU8UtQz6CB3T7RIGhId4BjmjJVXQDujb0+amGL8jpcluFJr6lwspBOvkUbnttfpZCm4zFMHmjrX1QrdPWBBMjQ==}
|
||||
/electron-to-chromium@1.4.484:
|
||||
resolution: {integrity: sha512-nO3ZEomTK2PO/3TUXgEx0A97xZTpKVf4p427lABHuCpT1IQ2N+njVh29DkQkCk6Q4m2wjU+faK4xAcfFndwjvw==}
|
||||
dev: true
|
||||
|
||||
/emoji-regex@8.0.0:
|
||||
@ -1136,10 +1165,10 @@ packages:
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.1.6)
|
||||
eslint: 8.45.0
|
||||
eslint-import-resolver-node: 0.3.7
|
||||
eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5)(eslint@8.45.0)
|
||||
eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.45.0)
|
||||
eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.28.0)(eslint@8.45.0)
|
||||
eslint-plugin-import: 2.28.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.45.0)
|
||||
eslint-plugin-jsx-a11y: 6.7.1(eslint@8.45.0)
|
||||
eslint-plugin-react: 7.33.0(eslint@8.45.0)
|
||||
eslint-plugin-react: 7.33.1(eslint@8.45.0)
|
||||
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.45.0)
|
||||
typescript: 5.1.6
|
||||
transitivePeerDependencies:
|
||||
@ -1166,7 +1195,7 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5)(eslint@8.45.0):
|
||||
/eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.28.0)(eslint@8.45.0):
|
||||
resolution: {integrity: sha512-TdJqPHs2lW5J9Zpe17DZNQuDnox4xo2o+0tE7Pggain9Rbc19ik8kFtXdxZ250FVx2kF4vlt2RSf4qlUpG7bhw==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
@ -1177,7 +1206,7 @@ packages:
|
||||
enhanced-resolve: 5.15.0
|
||||
eslint: 8.45.0
|
||||
eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.45.0)
|
||||
eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.45.0)
|
||||
eslint-plugin-import: 2.28.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.45.0)
|
||||
get-tsconfig: 4.6.2
|
||||
globby: 13.2.2
|
||||
is-core-module: 2.12.1
|
||||
@ -1215,13 +1244,13 @@ packages:
|
||||
debug: 3.2.7
|
||||
eslint: 8.45.0
|
||||
eslint-import-resolver-node: 0.3.7
|
||||
eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5)(eslint@8.45.0)
|
||||
eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.28.0)(eslint@8.45.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.45.0):
|
||||
resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==}
|
||||
/eslint-plugin-import@2.28.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.45.0):
|
||||
resolution: {integrity: sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==}
|
||||
engines: {node: '>=4'}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': '*'
|
||||
@ -1232,6 +1261,7 @@ packages:
|
||||
dependencies:
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.1.6)
|
||||
array-includes: 3.1.6
|
||||
array.prototype.findlastindex: 1.2.2
|
||||
array.prototype.flat: 1.3.1
|
||||
array.prototype.flatmap: 1.3.1
|
||||
debug: 3.2.7
|
||||
@ -1243,8 +1273,10 @@ packages:
|
||||
is-core-module: 2.12.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
object.fromentries: 2.0.6
|
||||
object.groupby: 1.0.0
|
||||
object.values: 1.1.6
|
||||
resolve: 1.22.2
|
||||
resolve: 1.22.3
|
||||
semver: 6.3.1
|
||||
tsconfig-paths: 3.14.2
|
||||
transitivePeerDependencies:
|
||||
@ -1270,7 +1302,7 @@ packages:
|
||||
emoji-regex: 9.2.2
|
||||
eslint: 8.45.0
|
||||
has: 1.0.3
|
||||
jsx-ast-utils: 3.3.4
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.5
|
||||
minimatch: 3.1.2
|
||||
object.entries: 1.1.6
|
||||
@ -1287,8 +1319,8 @@ packages:
|
||||
eslint: 8.45.0
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-react@7.33.0(eslint@8.45.0):
|
||||
resolution: {integrity: sha512-qewL/8P34WkY8jAqdQxsiL82pDUeT7nhs8IsuXgfgnsEloKCT4miAV9N9kGtx7/KM9NH/NCGUE7Edt9iGxLXFw==}
|
||||
/eslint-plugin-react@7.33.1(eslint@8.45.0):
|
||||
resolution: {integrity: sha512-L093k0WAMvr6VhNwReB8VgOq5s2LesZmrpPdKz/kZElQDzqS7G7+DnKoqT+w4JwuiGeAhAvHO0fvy0Eyk4ejDA==}
|
||||
engines: {node: '>=4'}
|
||||
peerDependencies:
|
||||
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
|
||||
@ -1299,7 +1331,7 @@ packages:
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.45.0
|
||||
estraverse: 5.3.0
|
||||
jsx-ast-utils: 3.3.4
|
||||
jsx-ast-utils: 3.3.5
|
||||
minimatch: 3.1.2
|
||||
object.entries: 1.1.6
|
||||
object.fromentries: 2.0.6
|
||||
@ -1335,16 +1367,16 @@ packages:
|
||||
strip-indent: 3.0.0
|
||||
dev: true
|
||||
|
||||
/eslint-scope@7.2.1:
|
||||
resolution: {integrity: sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==}
|
||||
/eslint-scope@7.2.2:
|
||||
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
dependencies:
|
||||
esrecurse: 4.3.0
|
||||
estraverse: 5.3.0
|
||||
dev: true
|
||||
|
||||
/eslint-visitor-keys@3.4.1:
|
||||
resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==}
|
||||
/eslint-visitor-keys@3.4.2:
|
||||
resolution: {integrity: sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
dev: true
|
||||
|
||||
@ -1354,8 +1386,8 @@ packages:
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0)
|
||||
'@eslint-community/regexpp': 4.5.1
|
||||
'@eslint/eslintrc': 2.1.0
|
||||
'@eslint-community/regexpp': 4.6.2
|
||||
'@eslint/eslintrc': 2.1.1
|
||||
'@eslint/js': 8.44.0
|
||||
'@humanwhocodes/config-array': 0.11.10
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
@ -1366,8 +1398,8 @@ packages:
|
||||
debug: 4.3.4
|
||||
doctrine: 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 7.2.1
|
||||
eslint-visitor-keys: 3.4.1
|
||||
eslint-scope: 7.2.2
|
||||
eslint-visitor-keys: 3.4.2
|
||||
espree: 9.6.1
|
||||
esquery: 1.5.0
|
||||
esutils: 2.0.3
|
||||
@ -1400,7 +1432,7 @@ packages:
|
||||
dependencies:
|
||||
acorn: 8.10.0
|
||||
acorn-jsx: 5.3.2(acorn@8.10.0)
|
||||
eslint-visitor-keys: 3.4.1
|
||||
eslint-visitor-keys: 3.4.2
|
||||
dev: true
|
||||
|
||||
/esquery@1.5.0:
|
||||
@ -1442,8 +1474,8 @@ packages:
|
||||
strip-final-newline: 2.0.0
|
||||
dev: true
|
||||
|
||||
/execa@7.1.1:
|
||||
resolution: {integrity: sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==}
|
||||
/execa@7.2.0:
|
||||
resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==}
|
||||
engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0}
|
||||
dependencies:
|
||||
cross-spawn: 7.0.3
|
||||
@ -1461,17 +1493,6 @@ packages:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
dev: true
|
||||
|
||||
/fast-glob@3.3.0:
|
||||
resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
'@nodelib/fs.walk': 1.2.8
|
||||
glob-parent: 5.1.2
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.5
|
||||
dev: true
|
||||
|
||||
/fast-glob@3.3.1:
|
||||
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@ -1766,6 +1787,12 @@ packages:
|
||||
engines: {node: '>=14.18.0'}
|
||||
dev: true
|
||||
|
||||
/husky@8.0.3:
|
||||
resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/ignore@5.2.4:
|
||||
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
|
||||
engines: {node: '>= 4'}
|
||||
@ -2042,8 +2069,8 @@ packages:
|
||||
minimist: 1.2.8
|
||||
dev: true
|
||||
|
||||
/jsx-ast-utils@3.3.4:
|
||||
resolution: {integrity: sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==}
|
||||
/jsx-ast-utils@3.3.5:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
dependencies:
|
||||
array-includes: 3.1.6
|
||||
@ -2088,7 +2115,7 @@ packages:
|
||||
cli-truncate: 3.1.0
|
||||
commander: 10.0.1
|
||||
debug: 4.3.4
|
||||
execa: 7.1.1
|
||||
execa: 7.2.0
|
||||
lilconfig: 2.1.0
|
||||
listr2: 5.0.8
|
||||
micromatch: 4.0.5
|
||||
@ -2262,7 +2289,7 @@ packages:
|
||||
'@next/env': 13.4.12
|
||||
'@swc/helpers': 0.5.1
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001517
|
||||
caniuse-lite: 1.0.30001519
|
||||
postcss: 8.4.14
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@ -2284,6 +2311,10 @@ packages:
|
||||
- babel-plugin-macros
|
||||
dev: false
|
||||
|
||||
/node-fetch-native@1.2.0:
|
||||
resolution: {integrity: sha512-5IAMBTl9p6PaAjYCnMv5FmqIF6GcZnawAVnzaCG0rX2aYZJ4CxEkZNtVPuTRug7fL7wyM5BQYTlAzcyMPi6oTQ==}
|
||||
dev: false
|
||||
|
||||
/node-releases@2.0.13:
|
||||
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==}
|
||||
dev: true
|
||||
@ -2324,7 +2355,6 @@ packages:
|
||||
/object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
@ -2368,6 +2398,15 @@ packages:
|
||||
es-abstract: 1.22.1
|
||||
dev: true
|
||||
|
||||
/object.groupby@1.0.0:
|
||||
resolution: {integrity: sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==}
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
define-properties: 1.2.0
|
||||
es-abstract: 1.22.1
|
||||
get-intrinsic: 1.2.1
|
||||
dev: true
|
||||
|
||||
/object.hasown@1.1.2:
|
||||
resolution: {integrity: sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==}
|
||||
dependencies:
|
||||
@ -2384,6 +2423,14 @@ packages:
|
||||
es-abstract: 1.22.1
|
||||
dev: true
|
||||
|
||||
/ofetch@1.1.1:
|
||||
resolution: {integrity: sha512-SSMoktrp9SNLi20BWfB/BnnKcL0RDigXThD/mZBeQxkIRv1xrd9183MtLdsqRYLYSqW0eTr5t8w8MqjNhvoOQQ==}
|
||||
dependencies:
|
||||
destr: 2.0.1
|
||||
node-fetch-native: 1.2.0
|
||||
ufo: 1.2.0
|
||||
dev: false
|
||||
|
||||
/once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
dependencies:
|
||||
@ -2700,7 +2747,6 @@ packages:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
dev: true
|
||||
|
||||
/punycode@2.3.0:
|
||||
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
|
||||
@ -2723,7 +2769,15 @@ packages:
|
||||
|
||||
/react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
dev: true
|
||||
|
||||
/react-paginate@8.2.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-sJCz1PW+9PNIjUSn919nlcRVuleN2YPoFBOvL+6TPgrH/3lwphqiSOgdrLafLdyLDxsgK+oSgviqacF4hxsDIw==}
|
||||
peerDependencies:
|
||||
react: ^16 || ^17 || ^18
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react@18.2.0:
|
||||
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
|
||||
@ -2807,6 +2861,15 @@ packages:
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
dev: true
|
||||
|
||||
/resolve@1.22.3:
|
||||
resolution: {integrity: sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
is-core-module: 2.12.1
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
dev: true
|
||||
|
||||
/resolve@2.0.0-next.4:
|
||||
resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==}
|
||||
hasBin: true
|
||||
@ -2856,7 +2919,7 @@ packages:
|
||||
/rxjs@7.8.1:
|
||||
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
|
||||
dependencies:
|
||||
tslib: 2.6.0
|
||||
tslib: 2.6.1
|
||||
dev: true
|
||||
|
||||
/safe-array-concat@1.0.0:
|
||||
@ -3111,8 +3174,8 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/sucrase@3.33.0:
|
||||
resolution: {integrity: sha512-ARGC7vbufOHfpvyGcZZXFaXCMZ9A4fffOGC5ucOW7+WHDGlAe8LJdf3Jts1sWhDeiI1RSWrKy5Hodl+JWGdW2A==}
|
||||
/sucrase@3.34.0:
|
||||
resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
@ -3162,7 +3225,7 @@ packages:
|
||||
chokidar: 3.5.3
|
||||
didyoumean: 1.2.2
|
||||
dlv: 1.1.3
|
||||
fast-glob: 3.3.0
|
||||
fast-glob: 3.3.1
|
||||
glob-parent: 6.0.2
|
||||
is-glob: 4.0.3
|
||||
jiti: 1.19.1
|
||||
@ -3178,7 +3241,7 @@ packages:
|
||||
postcss-nested: 6.0.1(postcss@8.4.27)
|
||||
postcss-selector-parser: 6.0.13
|
||||
resolve: 1.22.2
|
||||
sucrase: 3.33.0
|
||||
sucrase: 3.34.0
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
dev: true
|
||||
@ -3238,10 +3301,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
dev: true
|
||||
|
||||
/tslib@2.6.0:
|
||||
resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==}
|
||||
dev: true
|
||||
|
||||
/tslib@2.6.1:
|
||||
resolution: {integrity: sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==}
|
||||
|
||||
@ -3326,6 +3385,10 @@ packages:
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/ufo@1.2.0:
|
||||
resolution: {integrity: sha512-RsPyTbqORDNDxqAdQPQBpgqhWle1VcTSou/FraClYlHf6TZnQcGslpLcAphNR+sQW4q5lLWLbOsRlh9j24baQg==}
|
||||
dev: false
|
||||
|
||||
/unbox-primitive@1.0.2:
|
||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||
dependencies:
|
||||
@ -3340,13 +3403,13 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/update-browserslist-db@1.0.11(browserslist@4.21.9):
|
||||
/update-browserslist-db@1.0.11(browserslist@4.21.10):
|
||||
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
dependencies:
|
||||
browserslist: 4.21.9
|
||||
browserslist: 4.21.10
|
||||
escalade: 3.1.1
|
||||
picocolors: 1.0.0
|
||||
dev: true
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "es6",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
Loading…
x
Reference in New Issue
Block a user