mirror of
https://github.com/vercel/commerce.git
synced 2025-05-19 07:56:59 +00:00
feat(poc): react nextjs initial
This commit is contained in:
parent
8c8240956a
commit
c9cf57c933
12
.env.example
12
.env.example
@ -1,6 +1,6 @@
|
||||
TWITTER_CREATOR="@vercel"
|
||||
TWITTER_SITE="https://nextjs.org/commerce"
|
||||
SITE_NAME="Next.js Commerce"
|
||||
SHOPIFY_REVALIDATION_SECRET=
|
||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
SHOPIFY_STORE_DOMAIN=
|
||||
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=""
|
||||
|
234
README.md
234
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=SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SHOPIFY_STORE_DOMAIN,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,27 +10,21 @@ 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
|
||||
|
||||
> 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/))
|
||||
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`
|
||||
@ -45,220 +37,24 @@ pnpm dev
|
||||
|
||||
Your app should now be running on [localhost:3000](http://localhost:3000/).
|
||||
|
||||
## How to configure your Shopify store for Next.js Commerce
|
||||
## How to configure your Shopware store for Next.js Commerce
|
||||
|
||||
Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing).
|
||||
You can find the `SHOPWARE_ACCESS_TOKEN` in the Admin at your SalesChannel configuration.
|
||||
|
||||
> 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 Shopware domain to an environment variable
|
||||
|
||||
### Add Shopify domain to an environment variable
|
||||
Copy `.env.example` file to `.env` and change the environment variables like descibed below.
|
||||
|
||||
Create a `SHOPIFY_STORE_DOMAIN` environment variable and use your Shopify domain as the the value (ie. `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://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://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://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://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://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/branding`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Emails
|
||||
|
||||
1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/email_settings`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Favicon
|
||||
|
||||
1. Navigate to `https://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://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://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://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://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://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,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,7 +1,7 @@
|
||||
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';
|
||||
@ -39,6 +39,10 @@ export default async function Page({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
let date = page.createdAt;
|
||||
if (page.updatedAt !== '') {
|
||||
date = page.updatedAt;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -49,7 +53,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>
|
||||
</>
|
||||
);
|
||||
|
@ -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: {
|
||||
images: [
|
||||
{
|
||||
|
@ -10,8 +10,8 @@ import { Gallery } from 'components/product/gallery';
|
||||
import { VariantSelector } from 'components/product/variant-selector';
|
||||
import Prose from 'components/prose';
|
||||
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';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -20,6 +20,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();
|
@ -1,4 +1,4 @@
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopify';
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopware';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@ -33,7 +33,12 @@ export default async function CategoryPage({
|
||||
}) {
|
||||
const { sort } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||
|
||||
const products = await getCollectionProducts({
|
||||
collection: params.collection,
|
||||
sortKey,
|
||||
reverse
|
||||
});
|
||||
|
||||
return (
|
||||
<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,7 +1,7 @@
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
import { getProducts } from 'lib/shopify';
|
||||
import { getSearchCollectionProducts } from 'lib/shopware';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -18,7 +18,7 @@ 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 (
|
||||
|
@ -1,39 +1,41 @@
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import { getCollectionProducts } from 'lib/shopware';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export async function Carousel() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||
// const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||
|
||||
if (!products?.length) return null;
|
||||
// if (!products?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden bg-black dark:bg-white">
|
||||
<div className="flex animate-carousel">
|
||||
{[...products, ...products].map((product, i) => (
|
||||
<Link
|
||||
key={`${product.handle}${i}`}
|
||||
href={`/product/${product.handle}`}
|
||||
className="relative h-[30vh] w-1/2 flex-none md:w-1/3"
|
||||
>
|
||||
{product.featuredImage ? (
|
||||
<Image
|
||||
alt={product.title}
|
||||
className="h-full object-contain"
|
||||
fill
|
||||
sizes="33vw"
|
||||
src={product.featuredImage.url}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-y-0 right-0 flex items-center justify-center">
|
||||
<div className="inline-flex bg-white p-4 text-xl font-semibold text-black dark:bg-black dark:text-white">
|
||||
{product.title}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
|
||||
// return (
|
||||
// <div className="relative w-full overflow-hidden bg-black dark:bg-white">
|
||||
// <div className="flex animate-carousel">
|
||||
// {[...products, ...products].map((product, i) => (
|
||||
// <Link
|
||||
// key={`${product.handle}${i}`}
|
||||
// href={`/product/${product.handle}`}
|
||||
// className="relative h-[30vh] w-1/2 flex-none md:w-1/3"
|
||||
// >
|
||||
// {product.featuredImage ? (
|
||||
// <Image
|
||||
// alt={product.title}
|
||||
// className="h-full object-contain"
|
||||
// fill
|
||||
// sizes="33vw"
|
||||
// src={product.featuredImage.url}
|
||||
// />
|
||||
// ) : null}
|
||||
// <div className="absolute inset-y-0 right-0 flex items-center justify-center">
|
||||
// <div className="inline-flex bg-white p-4 text-xl font-semibold text-black dark:bg-black dark:text-white">
|
||||
// {product.title}
|
||||
// </div>
|
||||
// </div>
|
||||
// </Link>
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
@ -3,21 +3,18 @@ import { cookies } from 'next/headers';
|
||||
import CartModal from './modal';
|
||||
|
||||
export default async function Cart() {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
let cartIdUpdated = false;
|
||||
let cart;
|
||||
|
||||
if (cartId) {
|
||||
cart = await getCart(cartId);
|
||||
}
|
||||
|
||||
// If the `cartId` from the cookie is not set or the cart is empty
|
||||
// (old carts becomes `null` when you checkout), then get a new `cartId`
|
||||
// and re-fetch the cart.
|
||||
if (!cartId || !cart) {
|
||||
cart = await createCart();
|
||||
cartIdUpdated = true;
|
||||
}
|
||||
|
||||
return <CartModal cart={cart} cartIdUpdated={cartIdUpdated} />;
|
||||
// const cartId = cookies().get('cartId')?.value;
|
||||
// let cartIdUpdated = false;
|
||||
// let cart;
|
||||
// if (cartId) {
|
||||
// cart = await getCart(cartId);
|
||||
// }
|
||||
// // If the `cartId` from the cookie is not set or the cart is empty
|
||||
// // (old carts becomes `null` when you checkout), then get a new `cartId`
|
||||
// // and re-fetch the cart.
|
||||
// if (!cartId || !cart) {
|
||||
// cart = await createCart();
|
||||
// cartIdUpdated = true;
|
||||
// }
|
||||
// return <CartModal cart={cart} cartIdUpdated={cartIdUpdated} />;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import type { Product } from 'lib/shopify/types';
|
||||
import { getCollectionProducts } from 'lib/shopware';
|
||||
import type { Product } from 'lib/shopware/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
function ThreeItemGridItem({
|
||||
@ -16,7 +16,7 @@ function ThreeItemGridItem({
|
||||
<div
|
||||
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'}
|
||||
>
|
||||
<Link className="block h-full" href={`/product/${item.handle}`}>
|
||||
<Link className="block h-full" href={`/product/${item.path}`}>
|
||||
<GridTileImage
|
||||
src={item.featuredImage.url}
|
||||
width={size === 'full' ? 1080 : 540}
|
||||
@ -38,7 +38,7 @@ function ThreeItemGridItem({
|
||||
export async function ThreeItemGrid() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const homepageItems = await getCollectionProducts({
|
||||
collection: 'hidden-homepage-featured-items'
|
||||
collection: 'Summer-BBQ/Hidden-Category'
|
||||
});
|
||||
|
||||
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
||||
|
@ -2,16 +2,15 @@ import Link from 'next/link';
|
||||
|
||||
import GitHubIcon from 'components/icons/github';
|
||||
import LogoIcon from 'components/icons/logo';
|
||||
import VercelIcon from 'components/icons/vercel';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { getMenu } from 'lib/shopware';
|
||||
import { Menu } from 'lib/shopware/types';
|
||||
|
||||
const { SITE_NAME } = process.env;
|
||||
|
||||
export default async function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
|
||||
const menu = await getMenu('next-js-frontend-footer-menu');
|
||||
const menu = await getMenu({ type: 'footer-navigation' });
|
||||
|
||||
return (
|
||||
<footer className="border-t border-gray-700 bg-white text-black dark:bg-black dark:text-white">
|
||||
@ -52,15 +51,22 @@ export default async function Footer() {
|
||||
© {copyrightDate} {SITE_NAME}. All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center text-sm text-white dark:text-black">
|
||||
<span className="text-black dark:text-white">Created by</span>
|
||||
<span className="text-black dark:text-white">
|
||||
Created by Shopware Composable Frontends
|
||||
</span>
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
href="https://vercel.com"
|
||||
aria-label="Vercel.com Link"
|
||||
href="https://frontends.shopware.com/"
|
||||
aria-label="Shopware Composable Frontends Link"
|
||||
target="_blank"
|
||||
className="text-black dark:text-white"
|
||||
>
|
||||
<VercelIcon className="ml-3 inline-block h-6" />
|
||||
<div className="ml-4 h-auto w-10">
|
||||
<img
|
||||
src="https://www.shopware.com/media/pages/solutions/shopware-frontends/shopware-frontends-intro-graphic-base.svg"
|
||||
alt="Shopware Composable Frontends Logo"
|
||||
></img>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,20 +4,20 @@ import { Suspense } from 'react';
|
||||
import Cart from 'components/cart';
|
||||
import CartIcon from 'components/icons/cart';
|
||||
import LogoIcon from 'components/icons/logo';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { getMenu } from 'lib/shopware';
|
||||
import { Menu } from 'lib/shopware/types';
|
||||
import MobileMenu from './mobile-menu';
|
||||
import Search from './search';
|
||||
|
||||
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 bg-white p-4 dark:bg-black lg:px-6">
|
||||
<div className="block w-1/3 md:hidden">
|
||||
<MobileMenu menu={menu} />
|
||||
</div>
|
||||
<div className="flex justify-self-center md:w-1/3 md:justify-self-start">
|
||||
<div className="flex justify-self-center md:w-6/12 md:justify-self-start">
|
||||
<div className="md:mr-4">
|
||||
<Link href="/" aria-label="Go back home">
|
||||
<LogoIcon className="h-8 transition-transform hover:scale-110" />
|
||||
@ -38,11 +38,11 @@ export default async function Navbar() {
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden w-1/3 md:block">
|
||||
<div className="hidden w-3/12 md:block">
|
||||
<Search />
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/3 justify-end">
|
||||
<div className="flex w-3/12 justify-end">
|
||||
<Suspense fallback={<CartIcon className="h-6" />}>
|
||||
<Cart />
|
||||
</Suspense>
|
||||
|
@ -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="h-full w-full" href={`/product/${product.handle}`}>
|
||||
<Grid.Item key={product.path} className="animate-fadeIn">
|
||||
<Link className="h-full w-full" href={`/product/${product.path}`}>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
labels={{
|
||||
|
@ -1,12 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { getCollections } from 'lib/shopify';
|
||||
import { getStaticCollections } from 'lib/shopware';
|
||||
import FilterList from './filter';
|
||||
import { transformStaticCollectionToList } from 'lib/shopware/transform';
|
||||
|
||||
async function CollectionList() {
|
||||
const collections = await getCollections();
|
||||
return <FilterList list={collections} title="Collections" />;
|
||||
const collections = await getStaticCollections();
|
||||
if (collections) {
|
||||
const list = transformStaticCollectionToList(collections);
|
||||
return <FilterList list={list} title="Collections" />;
|
||||
}
|
||||
}
|
||||
|
||||
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
|
||||
|
7733
lib/shopware/api-types/apiTypes-6.5.2.0.d.ts
vendored
Normal file
7733
lib/shopware/api-types/apiTypes-6.5.2.0.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
111
lib/shopware/api.ts
Normal file
111
lib/shopware/api.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { createAPIClient } from '@shopware/api-client';
|
||||
import { operations, operationPaths } from './api-types/apiTypes-6.5.2.0';
|
||||
import {
|
||||
ApiSchemas,
|
||||
CategoryListingResultSW,
|
||||
ProductListingCriteria,
|
||||
SeoURLResultSW,
|
||||
StoreNavigationTypeSW
|
||||
} from './types';
|
||||
|
||||
const domainSW = `https://${process.env.SHOPWARE_STORE_DOMAIN!}/${process.env.SHOPWARE_API_TYPE!}`;
|
||||
const accessTokenSW = `${process.env.SHOPWARE_ACCESS_TOKEN}`;
|
||||
|
||||
const apiInstance = createAPIClient<operations, operationPaths>({
|
||||
baseURL: domainSW,
|
||||
accessToken: accessTokenSW,
|
||||
apiType: 'store-api'
|
||||
});
|
||||
|
||||
export async function requestNavigation(
|
||||
type: StoreNavigationTypeSW
|
||||
): Promise<ApiSchemas['NavigationRouteResponse']> {
|
||||
return await apiInstance.invoke(
|
||||
'readNavigation post /navigation/{activeId}/{rootId} sw-include-seo-urls',
|
||||
{
|
||||
activeId: type,
|
||||
rootId: type,
|
||||
depth: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestCategory(
|
||||
categoryId: string,
|
||||
criteria?: Partial<ProductListingCriteria>
|
||||
): Promise<ApiSchemas['Category']> {
|
||||
return await apiInstance.invoke('readCategory post /category/{navigationId}?slots', {
|
||||
navigationId: categoryId,
|
||||
criteria
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestCategoryList(
|
||||
criteria: Partial<ApiSchemas['Criteria']>
|
||||
): Promise<CategoryListingResultSW> {
|
||||
return await apiInstance.invoke('readCategoryList post /category', criteria);
|
||||
}
|
||||
|
||||
export async function requestProductsCollection(
|
||||
criteria: Partial<ProductListingCriteria>
|
||||
): Promise<ApiSchemas['ProductListingResult']> {
|
||||
return await apiInstance.invoke('readProduct post /product', criteria);
|
||||
}
|
||||
|
||||
export async function requestCategoryProductsCollection(
|
||||
categoryId: string,
|
||||
criteria: Partial<ProductListingCriteria>
|
||||
): Promise<ApiSchemas['ProductListingResult']> {
|
||||
return await apiInstance.invoke('readProductListing post /product-listing/{categoryId}', {
|
||||
...criteria,
|
||||
categoryId: categoryId
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestSearchCollectionProducts(
|
||||
criteria?: Partial<ProductListingCriteria>
|
||||
): Promise<ApiSchemas['ProductListingResult']> {
|
||||
return await apiInstance.invoke('searchPage post /search', {
|
||||
search: encodeURIComponent(criteria?.query || ''),
|
||||
...criteria
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestSeoUrl(handle: string): Promise<SeoURLResultSW> {
|
||||
return await apiInstance.invoke('readSeoUrl post /seo-url', {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
filter: [
|
||||
{
|
||||
type: 'multi',
|
||||
// @ts-ignore
|
||||
operator: 'or',
|
||||
queries: [
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'seoPathInfo',
|
||||
value: handle + '/'
|
||||
},
|
||||
{
|
||||
type: 'equals',
|
||||
field: 'seoPathInfo',
|
||||
value: handle
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestCrossSell(
|
||||
productId: string,
|
||||
criteria?: Partial<ProductListingCriteria>
|
||||
): Promise<ApiSchemas['CrossSellingElementCollection']> {
|
||||
return await apiInstance.invoke(
|
||||
'readProductCrossSellings post /product/{productId}/cross-selling',
|
||||
{
|
||||
productId: productId,
|
||||
...criteria
|
||||
}
|
||||
);
|
||||
}
|
245
lib/shopware/criteria.ts
Normal file
245
lib/shopware/criteria.ts
Normal file
@ -0,0 +1,245 @@
|
||||
export function getDefaultProductsCriteria(page: number = 1, limit: number = 15) {
|
||||
return {
|
||||
page: 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 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 = 10
|
||||
) {
|
||||
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'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
199
lib/shopware/index.ts
Normal file
199
lib/shopware/index.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import {
|
||||
ApiSchemas,
|
||||
Menu,
|
||||
Page,
|
||||
Product,
|
||||
ProductListingCriteria,
|
||||
StoreNavigationTypeSW
|
||||
} from './types';
|
||||
import {
|
||||
getDefaultCategoryCriteria,
|
||||
getDefaultCrossSellingCriteria,
|
||||
getDefaultProductCriteria,
|
||||
getDefaultProductsCriteria,
|
||||
getDefaultSearchProductsCriteria,
|
||||
getSortingCriteria,
|
||||
getStaticCollectionCriteria
|
||||
} from './criteria';
|
||||
import {
|
||||
requestCategory,
|
||||
requestCategoryList,
|
||||
requestCategoryProductsCollection,
|
||||
requestCrossSell,
|
||||
requestNavigation,
|
||||
requestProductsCollection,
|
||||
requestSearchCollectionProducts,
|
||||
requestSeoUrl
|
||||
} from './api';
|
||||
import {
|
||||
transformMenu,
|
||||
transformPage,
|
||||
transformProduct,
|
||||
transformProducts,
|
||||
transformCollection,
|
||||
transformStaticCollection
|
||||
} from './transform';
|
||||
|
||||
export async function getMenu(params?: { type?: StoreNavigationTypeSW }): Promise<Menu[]> {
|
||||
const type = params?.type || 'main-navigation';
|
||||
const res = await requestNavigation(type);
|
||||
|
||||
return res ? transformMenu(res) : [];
|
||||
}
|
||||
|
||||
export async function getPage(handle: string): Promise<Page | undefined> {
|
||||
const seoUrlElement = await getFirstSeoUrlElement(handle);
|
||||
if (seoUrlElement) {
|
||||
const resCategory = await requestCategory(
|
||||
seoUrlElement.foreignKey,
|
||||
getDefaultCategoryCriteria()
|
||||
);
|
||||
|
||||
return resCategory ? transformPage(seoUrlElement, resCategory) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFirstSeoUrlElement(
|
||||
handle: string
|
||||
): Promise<ApiSchemas['SeoUrl'] | undefined> {
|
||||
const resSeoUrl = await requestSeoUrl(handle);
|
||||
if (resSeoUrl.elements && resSeoUrl.elements.length > 0 && resSeoUrl.elements[0]) {
|
||||
return resSeoUrl.elements[0];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFirstProduct(
|
||||
productId: string
|
||||
): Promise<ApiSchemas['Product'] | undefined> {
|
||||
const productCriteria = getDefaultProductCriteria(productId);
|
||||
const res: ApiSchemas['ProductListingResult'] = await requestProductsCollection(productCriteria);
|
||||
if (res.elements && res.elements.length > 0 && res.elements[0]) {
|
||||
return res.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 getStaticCollections() {
|
||||
// @ToDo: This is an example about multi-filter with new store API client
|
||||
// @ts-ignore
|
||||
const resCategory = await requestCategoryList(getStaticCollectionCriteria());
|
||||
|
||||
return resCategory ? transformStaticCollection(resCategory) : [];
|
||||
}
|
||||
|
||||
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 res = await requestSearchCollectionProducts(searchCriteria);
|
||||
|
||||
return res ? transformProducts(res) : [];
|
||||
}
|
||||
|
||||
export async function getCollectionProducts(params?: {
|
||||
collection: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
categoryId?: string;
|
||||
defaultSearchCriteria?: Partial<ProductListingCriteria>;
|
||||
}): Promise<Product[]> {
|
||||
let res;
|
||||
let collectionName: string | [] | undefined = params?.collection;
|
||||
let category = params?.categoryId;
|
||||
const sorting = getSortingCriteria(params?.sortKey, params?.reverse);
|
||||
|
||||
if (!category && collectionName) {
|
||||
// this part is important for the catch all collection handle/slug
|
||||
if (Array.isArray(collectionName)) {
|
||||
collectionName = collectionName.join('/');
|
||||
}
|
||||
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 (category) {
|
||||
const criteria = !params?.defaultSearchCriteria
|
||||
? getDefaultProductsCriteria()
|
||||
: params?.defaultSearchCriteria;
|
||||
const productsCriteria = { ...criteria, ...sorting };
|
||||
res = await requestCategoryProductsCollection(category, productsCriteria);
|
||||
}
|
||||
|
||||
return res ? transformProducts(res) : [];
|
||||
}
|
||||
|
||||
// This function is only used for generateMetadata at app/search/(collection)/[...collection]/page.tsx
|
||||
export async function getCollection(handle: string | []) {
|
||||
let collectionName: string | [] | undefined = handle;
|
||||
if (Array.isArray(collectionName)) {
|
||||
collectionName = collectionName.join('/');
|
||||
}
|
||||
const seoUrlElement = await getFirstSeoUrlElement(collectionName);
|
||||
if (seoUrlElement) {
|
||||
const resCategory = await requestCategory(
|
||||
seoUrlElement.foreignKey,
|
||||
getDefaultCategoryCriteria()
|
||||
);
|
||||
const path = seoUrlElement.seoPathInfo ?? '';
|
||||
const collection = transformCollection(seoUrlElement, resCategory);
|
||||
|
||||
return {
|
||||
...collection,
|
||||
path: `/search/${path}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProduct(handle: string | []): Promise<Product | undefined> {
|
||||
let productSW: ApiSchemas['Product'] | undefined;
|
||||
let productId: string | undefined;
|
||||
let productHandle: string | [] | undefined = handle;
|
||||
if (Array.isArray(productHandle)) {
|
||||
productHandle = productHandle.join('/');
|
||||
}
|
||||
|
||||
const seoUrlElement = await getFirstSeoUrlElement(productHandle);
|
||||
if (seoUrlElement) {
|
||||
productId = seoUrlElement.foreignKey;
|
||||
}
|
||||
|
||||
if (!productId) {
|
||||
console.log('[getProduct][search] Did not found any product with handle:', productHandle);
|
||||
}
|
||||
if (productId) {
|
||||
const firstProduct = await getFirstProduct(productId);
|
||||
if (firstProduct) {
|
||||
productSW = firstProduct;
|
||||
}
|
||||
}
|
||||
|
||||
return productSW ? transformProduct(productSW) : undefined;
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
let products: ApiSchemas['ProductListingResult'] = {};
|
||||
|
||||
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) : [];
|
||||
}
|
203
lib/shopware/transform.ts
Normal file
203
lib/shopware/transform.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import {
|
||||
ApiSchemas,
|
||||
CategoryListingResultSW,
|
||||
Collection,
|
||||
Menu,
|
||||
Product,
|
||||
ProductOption,
|
||||
ProductVariant
|
||||
} from './types';
|
||||
import { ListItem } from 'components/layout/search/filter';
|
||||
|
||||
export function transformMenu(res: ApiSchemas['NavigationRouteResponse']) {
|
||||
let menu: Menu[] = [];
|
||||
|
||||
res.map((item) =>
|
||||
menu.push({
|
||||
id: item.id ?? '',
|
||||
title: item.name,
|
||||
path:
|
||||
item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo
|
||||
? '/search/' + item.seoUrls[0].seoPathInfo
|
||||
: ''
|
||||
})
|
||||
);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
export function transformPage(
|
||||
seoUrlElement: ApiSchemas['SeoUrl'],
|
||||
resCategory: ApiSchemas['Category']
|
||||
) {
|
||||
return {
|
||||
id: seoUrlElement.id ?? '',
|
||||
title: resCategory.translated?.metaTitle ?? resCategory.name ?? '',
|
||||
handle: seoUrlElement.seoPathInfo,
|
||||
body: resCategory.description ?? '',
|
||||
bodySummary: resCategory.translated?.metaDescription ?? resCategory.description ?? '',
|
||||
seo: {
|
||||
title: resCategory.translated?.metaTitle ?? resCategory.name ?? '',
|
||||
description: resCategory.translated?.metaDescription ?? resCategory.description ?? ''
|
||||
},
|
||||
createdAt: seoUrlElement.createdAt ?? '',
|
||||
updatedAt: seoUrlElement.updatedAt ?? '',
|
||||
routeName: seoUrlElement.routeName,
|
||||
foreignKey: seoUrlElement.foreignKey
|
||||
};
|
||||
}
|
||||
|
||||
export function transformCollection(
|
||||
seoUrlElement: ApiSchemas['SeoUrl'],
|
||||
resCategory: ApiSchemas['Category']
|
||||
) {
|
||||
return {
|
||||
handle: seoUrlElement.seoPathInfo,
|
||||
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 ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export function transformStaticCollection(resCategory: CategoryListingResultSW): Collection[] {
|
||||
let collection: Collection[] = [];
|
||||
|
||||
if (resCategory.elements && resCategory.elements.length > 0) {
|
||||
resCategory.elements.map((item) =>
|
||||
collection.push({
|
||||
handle:
|
||||
item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo
|
||||
? item.seoUrls[0].seoPathInfo
|
||||
: '',
|
||||
title: item.translated?.metaTitle ?? item.name ?? '',
|
||||
description: item.description ?? '',
|
||||
seo: {
|
||||
title: item.translated?.metaTitle ?? item.name ?? '',
|
||||
description: item.translated?.metaDescription ?? item.description ?? ''
|
||||
},
|
||||
updatedAt: item.updatedAt ?? item.createdAt ?? ''
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
export function transformStaticCollectionToList(collection: Collection[]): ListItem[] {
|
||||
let listItem: ListItem[] = [];
|
||||
|
||||
if (collection && collection.length > 0) {
|
||||
collection.map((item) =>
|
||||
listItem.push({
|
||||
title: item.title,
|
||||
path: `/search/${item.handle}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return listItem;
|
||||
}
|
||||
|
||||
export function transformProducts(res: ApiSchemas['ProductListingResult']): Product[] {
|
||||
let products: Product[] = [];
|
||||
|
||||
if (res.elements && res.elements.length > 0) {
|
||||
res.elements.map((item) => products.push(transformProduct(item)));
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
export function transformProduct(item: ApiSchemas['Product']): Product {
|
||||
const productOptions = transformOptions(item);
|
||||
const productVariants = transformVariants(item);
|
||||
|
||||
return {
|
||||
id: item.id ?? '',
|
||||
path:
|
||||
item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo
|
||||
? item.seoUrls[0].seoPathInfo
|
||||
: '',
|
||||
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: [],
|
||||
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: ApiSchemas['Product']): ProductOption[] {
|
||||
// we only transform options for parents with children, ignore child products with options
|
||||
let productOptions: ProductOption[] = [];
|
||||
if (parent.children && parent.parentId === null && parent.children.length > 0) {
|
||||
let group: { [key: string]: string[] } = {};
|
||||
parent.children.map((child) => {
|
||||
child.options?.map((option) => {
|
||||
if (option && option.group) {
|
||||
group[option.group.name] = group[option.group.name]
|
||||
? [...new Set([...(group[option.group.name] as []), ...[option.name]])]
|
||||
: [option.name];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (parent.id) {
|
||||
for (const [key, value] of Object.entries(group)) {
|
||||
productOptions.push({
|
||||
id: parent.id,
|
||||
name: key,
|
||||
values: value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return productOptions;
|
||||
}
|
||||
|
||||
function transformVariants(parent: ApiSchemas['Product']): ProductVariant[] {
|
||||
let productVariants: ProductVariant[] = [];
|
||||
|
||||
return productVariants;
|
||||
}
|
96
lib/shopware/types.ts
Normal file
96
lib/shopware/types.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { components } from './api-types/apiTypes-6.5.2.0';
|
||||
|
||||
/** Shopware Types */
|
||||
|
||||
/** Schemas */
|
||||
export type ApiSchemas = components['schemas'];
|
||||
export type StoreNavigationTypeSW = 'main-navigation' | 'footer-navigation' | 'service-navigation';
|
||||
export type ProductListingCriteria = ApiSchemas['ProductListingCriteria'] & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
/** Return Types */
|
||||
export type CategoryListingResultSW = {
|
||||
elements?: ApiSchemas['Category'][];
|
||||
} & ApiSchemas['EntitySearchResult'];
|
||||
export type SeoURLResultSW = {
|
||||
elements?: ApiSchemas['SeoUrl'][];
|
||||
} & ApiSchemas['EntitySearchResult'];
|
||||
|
||||
/** Vercel Commerce Types */
|
||||
export type Menu = { id: string; title: string; path: string };
|
||||
|
||||
export type Page = {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
body: string;
|
||||
bodySummary: string;
|
||||
seo?: SEO;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
routeName?: string;
|
||||
foreignKey?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
updatedAt: string;
|
||||
};
|
@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
21
package.json
21
package.json
@ -23,31 +23,32 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@vercel/og": "^0.5.6",
|
||||
"@shopware/api-client": "^0.0.3",
|
||||
"@vercel/og": "^0.5.8",
|
||||
"clsx": "^1.2.1",
|
||||
"is-empty-iterable": "^3.0.0",
|
||||
"next": "13.4.6",
|
||||
"next": "13.4.8",
|
||||
"react": "18.2.0",
|
||||
"react-cookie": "^4.1.1",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.34.3",
|
||||
"@playwright/test": "^1.35.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/node": "20.2.5",
|
||||
"@types/react": "18.2.8",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@types/node": "20.4.0",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"@vercel/git-hooks": "^1.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-next": "^13.4.4",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-next": "^13.4.8",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-unicorn": "^47.0.0",
|
||||
"lint-staged": "^13.2.2",
|
||||
"lint-staged": "^13.2.3",
|
||||
"postcss": "^8.4.24",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "5.1.3"
|
||||
"typescript": "5.1.6"
|
||||
}
|
||||
}
|
||||
|
619
pnpm-lock.yaml
generated
619
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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