Merge branch 'poc/react-nextjs' into feat/cart

This commit is contained in:
Björn Meyer 2023-07-21 08:41:57 +02:00
commit 59b9a9d1b7
32 changed files with 1017 additions and 618 deletions

View File

@ -4,3 +4,4 @@ SITE_NAME="Next.js Commerce with Shopware Composable Frontends"
SHOPWARE_STORE_DOMAIN="" SHOPWARE_STORE_DOMAIN=""
SHOPWARE_API_TYPE="store-api" SHOPWARE_API_TYPE="store-api"
SHOPWARE_ACCESS_TOKEN="" SHOPWARE_ACCESS_TOKEN=""
SHOPWARE_USE_SEO_URLS="false"

0
.husky/pre-commit Normal file → Executable file
View File

View File

@ -8,11 +8,7 @@ export const runtime = 'edge';
export const revalidate = 43200; // 12 hours in seconds export const revalidate = 43200; // 12 hours in seconds
export async function generateMetadata({ export async function generateMetadata({ params }: { params: { cms: string } }): Promise<Metadata> {
params
}: {
params: { cms: string };
}): Promise<Metadata> {
const page = await getPage(params.cms); const page = await getPage(params.cms);
if (!page) return notFound(); if (!page) return notFound();

View File

@ -1,10 +1,13 @@
import { getCollection, getCollectionProducts } from 'lib/shopware';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import Grid from 'components/grid'; import Grid from 'components/grid';
import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter';
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from 'components/layout/product-grid-items';
import Pagination from 'components/collection/pagination'; import Pagination from 'components/collection/pagination';
import { getCollection, getCollectionProducts } from 'lib/shopware';
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
export const runtime = 'edge'; export const runtime = 'edge';
@ -47,14 +50,26 @@ export default async function CategoryPage({
{products.length === 0 ? ( {products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p> <p className="py-3 text-lg">{`No products found in this collection`}</p>
) : ( ) : (
<div> <div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
<div className="order-first flex-none md:w-1/6">
<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"> <Grid className="grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} /> <ProductGridItems products={products} />
</Grid> </Grid>
<nav aria-label="Collection pagination" className='block sm:flex items-center'> <nav aria-label="Collection pagination" className="block items-center sm:flex">
<Pagination itemsPerPage={limit} itemsTotal={total} currentPage={page ? parseInt(page) - 1 : 0} /> <Pagination
itemsPerPage={limit}
itemsTotal={total}
currentPage={page ? parseInt(page) - 1 : 0}
/>
</nav> </nav>
</div> </div>
<div className="order-none md:order-last md:w-1/6 md:flex-none">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
)} )}
</section> </section>
); );

View File

@ -1,21 +1,11 @@
import Footer from 'components/layout/footer'; 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'; 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 }) { export default function SearchLayout({ children }: { children: React.ReactNode }) {
return ( return (
<Suspense> <Suspense>
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row"> {children}
<div className="order-first flex-none md:w-1/6">
<Collections />
</div>
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
<div className="order-none md:order-last md:w-1/6 md:flex-none">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
<Footer /> <Footer />
</Suspense> </Suspense>
); );

View File

@ -1,13 +1,25 @@
import Grid from 'components/grid'; import Grid from 'components/grid';
import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants';
export default function Loading() { export default function Loading() {
return ( return (
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
<div className="order-first flex-none md:w-1/6"></div>
<div className="order-last min-h-screen w-full md:order-none">
<Grid className="grid-cols-2 lg:grid-cols-3"> <Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12) {Array(12)
.fill(0) .fill(0)
.map((_, index) => { .map((_, index) => {
return <Grid.Item key={index} className="animate-pulse bg-gray-100 dark:bg-gray-900" />; return (
<Grid.Item key={index} className="animate-pulse bg-gray-100 dark:bg-gray-900" />
);
})} })}
</Grid> </Grid>
</div>
<div className="order-none md:order-last md:w-1/6 md:flex-none">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
); );
} }

View File

@ -1,5 +1,6 @@
import Grid from 'components/grid'; import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from 'components/layout/product-grid-items';
import FilterList from 'components/layout/search/filter';
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
import { getSearchCollectionProducts } from 'lib/shopware'; import { getSearchCollectionProducts } from 'lib/shopware';
@ -23,18 +24,32 @@ export default async function SearchPage({
return ( return (
<> <>
{searchValue && products.length === 0 ? (
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
<p>
{'There are no products that match '}
<span className="font-bold">&quot;{searchValue}&quot;</span>
</p>
</div>
) : null}
{products.length > 0 ? (
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
<div className="order-first flex-none md:w-1/6">
{searchValue ? ( {searchValue ? (
<p> <p>
{products.length === 0 {`Showing ${products.length} ${resultsText} for `}
? 'There are no products that match '
: `Showing ${products.length} ${resultsText} for `}
<span className="font-bold">&quot;{searchValue}&quot;</span> <span className="font-bold">&quot;{searchValue}&quot;</span>
</p> </p>
) : null} ) : null}
{products.length > 0 ? ( <p className="pt-4">Good place to add other suggest search terms ;)</p>
</div>
<Grid className="grid-cols-2 lg:grid-cols-3"> <Grid className="grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} /> <ProductGridItems products={products} />
</Grid> </Grid>
<div className="order-none md:order-last md:w-1/6 md:flex-none">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
) : null} ) : null}
</> </>
); );

View File

@ -19,9 +19,7 @@ export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.S
})) }))
); );
const fetchedRoutes = ( const fetchedRoutes = (await Promise.all([productsPromise])).flat();
await Promise.all([productsPromise])
).flat();
return [...routesMap, ...fetchedRoutes]; return [...routesMap, ...fetchedRoutes];
} }

View File

@ -1,41 +1,44 @@
import { getCollectionProducts } from 'lib/shopware'; import { getCollectionProducts } from 'lib/shopware';
import { isSeoUrls } from 'lib/shopware/helpers';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
export async function Carousel() { export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page. const collectionName = isSeoUrls()
// const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' }); ? 'Summer-BBQ/Hidden-Carousel-Category'
: 'ff7bf3c59f1342a685844fbf8fdf9dc8';
const { products } = await getCollectionProducts({
collection: collectionName
});
// if (!products?.length) return null; if (!products?.length) return null;
return null; return (
<div className="relative w-full overflow-hidden bg-white dark:bg-black">
// return ( <div className="flex animate-carousel">
// <div className="relative w-full overflow-hidden bg-black dark:bg-white"> {[...products, ...products].map((product, i) => (
// <div className="flex animate-carousel"> <Link
// {[...products, ...products].map((product, i) => ( key={`${product.path}${i}`}
// <Link href={`/product/${product.path}`}
// key={`${product.handle}${i}`} className="relative h-[30vh] w-1/2 flex-none md:w-1/3"
// href={`/product/${product.handle}`} >
// className="relative h-[30vh] w-1/2 flex-none md:w-1/3" {product.featuredImage ? (
// > <Image
// {product.featuredImage ? ( alt={product.title}
// <Image className="h-full object-contain"
// alt={product.title} fill
// className="h-full object-contain" sizes="33vw"
// fill src={product.featuredImage.url}
// sizes="33vw" />
// src={product.featuredImage.url} ) : null}
// /> <div className="absolute inset-y-0 right-0 flex items-center justify-center">
// ) : null} <div className="inline-flex bg-white p-4 text-xl font-semibold text-black dark:bg-black dark:text-white">
// <div className="absolute inset-y-0 right-0 flex items-center justify-center"> {product.title}
// <div className="inline-flex bg-white p-4 text-xl font-semibold text-black dark:bg-black dark:text-white"> </div>
// {product.title} </div>
// </div> </Link>
// </div> ))}
// </Link> </div>
// ))} </div>
// </div> );
// </div>
// );
} }

View File

@ -59,4 +59,3 @@ export const updateItemQuantity = async ({
return new Error('Error updating item quantity', { cause: e }); return new Error('Error updating item quantity', { cause: e });
} }
}; };

View File

@ -6,17 +6,17 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react'; import { useEffect, useState, useTransition } from 'react';
import LoadingDots from 'components/loading-dots'; import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
import { Product } from 'lib/shopware/types'; import { Product } from 'lib/shopware/types';
import { ProductVariant } from 'lib/shopware/types';
export function AddToCart({ export function AddToCart({
product, product,
variants, variants,
availableForSale, availableForSale
}: { }: {
variants: ProductVariant[]; variants: ProductVariant[];
availableForSale: boolean; availableForSale: boolean;
product: Product product: Product;
}) { }) {
const [selectedVariantId, setSelectedVariantId] = useState(variants[0]?.id); const [selectedVariantId, setSelectedVariantId] = useState(variants[0]?.id);
const router = useRouter(); const router = useRouter();

View File

@ -3,7 +3,7 @@ import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import clsx from 'clsx'; import clsx from 'clsx';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopware/types';
import { useTransition } from 'react'; import { useTransition } from 'react';
import { removeItem } from 'components/cart/actions'; import { removeItem } from 'components/cart/actions';

View File

@ -6,7 +6,7 @@ import { removeItem, updateItemQuantity } from 'components/cart/actions';
import MinusIcon from 'components/icons/minus'; import MinusIcon from 'components/icons/minus';
import PlusIcon from 'components/icons/plus'; import PlusIcon from 'components/icons/plus';
import LoadingDots from 'components/loading-dots'; import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopware/types';
export default function EditItemQuantityButton({ export default function EditItemQuantityButton({
item, item,

View File

@ -9,6 +9,10 @@ export default async function Cart() {
let cartIdUpdated = false; let cartIdUpdated = false;
const cart = await getCart(cartId); const cart = await getCart(cartId);
if (!cart) {
return null;
}
if (cartId !== cart.id) { if (cartId !== cart.id) {
cartIdUpdated = true; cartIdUpdated = true;
} }

View File

@ -9,7 +9,7 @@ import CloseIcon from 'components/icons/close';
import ShoppingBagIcon from 'components/icons/shopping-bag'; import ShoppingBagIcon from 'components/icons/shopping-bag';
import Price from 'components/price'; import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants'; import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/shopify/types'; import type { Cart } from 'lib/shopware/types';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import { Fragment, useEffect, useRef, useState } from 'react'; import { Fragment, useEffect, useRef, useState } from 'react';
import { useCookies } from 'react-cookie'; import { useCookies } from 'react-cookie';
@ -110,7 +110,7 @@ export default function CartModal({ cart, cartIdUpdated }: { cart: Cart; cartIdU
}); });
const merchandiseUrl = createUrl( const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`, `/product/${item.merchandise.product.path}`,
new URLSearchParams(merchandiseSearchParams) new URLSearchParams(merchandiseSearchParams)
); );

View File

@ -1,10 +1,18 @@
'use client' 'use client';
import ReactPaginate from 'react-paginate'; import ReactPaginate from 'react-paginate';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import { usePathname, useSearchParams, useRouter } from 'next/navigation'; import { usePathname, useSearchParams, useRouter } from 'next/navigation';
export default function Pagination({ itemsPerPage, itemsTotal, currentPage }: { itemsPerPage: number, itemsTotal: number, currentPage: number }) { export default function Pagination({
itemsPerPage,
itemsTotal,
currentPage
}: {
itemsPerPage: number;
itemsTotal: number;
currentPage: number;
}) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const currentParams = useSearchParams(); const currentParams = useSearchParams();
@ -12,20 +20,26 @@ export default function Pagination({ itemsPerPage, itemsTotal, currentPage }: {
const sort = currentParams.get('sort'); const sort = currentParams.get('sort');
const pageCount = Math.ceil(itemsTotal / itemsPerPage); const pageCount = Math.ceil(itemsTotal / itemsPerPage);
// Invoke when user click to request another page. // Invoke when user click to request another page. test
const handlePageClick = (event: clickEvent) => { const handlePageClick = (event: clickEvent) => {
const page = event.selected; const page = event.selected;
const newPage = page + 1; const newPage = page + 1;
let newUrl = createUrl(pathname, new URLSearchParams({ let newUrl = createUrl(
pathname,
new URLSearchParams({
...(q && { q }), ...(q && { q }),
...(sort && { sort }), ...(sort && { sort })
})); })
);
if (page !== 0) { if (page !== 0) {
newUrl = createUrl(pathname, new URLSearchParams({ newUrl = createUrl(
pathname,
new URLSearchParams({
...(q && { q }), ...(q && { q }),
...(sort && { sort }), ...(sort && { sort }),
page: newPage.toString(), page: newPage.toString()
})); })
);
} }
router.replace(newUrl); router.replace(newUrl);
}; };
@ -65,4 +79,4 @@ type clickEvent = {
isNext: boolean; isNext: boolean;
isBreak: boolean; isBreak: boolean;
isActive: boolean; isActive: boolean;
} };

View File

@ -1,5 +1,6 @@
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopware'; import { getCollectionProducts } from 'lib/shopware';
import { isSeoUrls } from 'lib/shopware/helpers';
import type { Product } from 'lib/shopware/types'; import type { Product } from 'lib/shopware/types';
import Link from 'next/link'; import Link from 'next/link';
@ -37,8 +38,11 @@ function ThreeItemGridItem({
export async function ThreeItemGrid() { export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page. // Collections that start with `hidden-*` are hidden from the search page.
const collectionName = isSeoUrls()
? 'Summer-BBQ/Hidden-Category'
: '4ab73c06d90d4a5cb312209a64480d87';
const { products: homepageItems } = await getCollectionProducts({ const { products: homepageItems } = await getCollectionProducts({
collection: 'Summer-BBQ/Hidden-Category' collection: collectionName
}); });
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null; if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;

View File

@ -41,7 +41,8 @@ export function GridTileImage({
{props.src ? ( {props.src ? (
<Image <Image
className={clsx('relative h-full w-full object-contain', { className={clsx('relative h-full w-full object-contain', {
'transition duration-300 ease-in-out hover:scale-105': isInteractive 'transition duration-300 ease-in-out hover:scale-105': isInteractive,
'm-4 max-h-[8rem] min-h-[8rem]': props.width === 200 && props.height === 200 // this styling is for the thumbnails below gallery on product detail page
})} })}
{...props} {...props}
alt={props.title || ''} alt={props.title || ''}

View File

@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import GitHubIcon from 'components/icons/github'; import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo'; import LogoIcon from 'components/icons/logo';
@ -25,12 +26,8 @@ export default async function Footer() {
</a> </a>
</div> </div>
{menu.map((item: Menu) => ( {menu.map((item: Menu) => (
< nav className="col-span-1 lg:col-span-3" key={item.title + item.type} > <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.type === "headline" ? (
<span className='font-bold'>{item.title}</span>
) : null
}
{item.children.length > 0 ? ( {item.children.length > 0 ? (
<ul className="py-3 md:py-0 md:pt-4" key={item.title}> <ul className="py-3 md:py-0 md:pt-4" key={item.title}>
{item.children.map((item: Menu) => ( {item.children.map((item: Menu) => (
@ -44,7 +41,7 @@ export default async function Footer() {
</li> </li>
))} ))}
</ul> </ul>
) : ) : (
// if there are no children, at least display a link // if there are no children, at least display a link
<Link <Link
key={item.title} key={item.title}
@ -52,11 +49,17 @@ export default async function Footer() {
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100" className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
> >
{item.title} {item.title}
</Link>} </Link>
)}
</nav> </nav>
))} ))}
<div className="col-span-1 text-black dark:text-white lg:col-span-2 inline-grid justify-items-end"> <div className="col-span-1 inline-grid justify-items-end text-black dark:text-white lg:col-span-2">
<a aria-label="Github Repository" href="https://github.com/shopware/frontends" target="_blank" rel="noopener noreferrer"> <a
aria-label="Github Repository"
href="https://github.com/shopware/frontends"
target="_blank"
rel="noopener noreferrer"
>
<GitHubIcon className="h-6" /> <GitHubIcon className="h-6" />
</a> </a>
</div> </div>
@ -77,15 +80,17 @@ export default async function Footer() {
className="text-black dark:text-white" className="text-black dark:text-white"
> >
<div className="ml-4 h-auto w-10"> <div className="ml-4 h-auto w-10">
<img <Image
src="https://www.shopware.com/media/pages/solutions/shopware-frontends/shopware-frontends-intro-graphic-base.svg" src="https://www.shopware.com/media/pages/solutions/shopware-frontends/shopware-frontends-intro-graphic-base.svg"
alt="Shopware Composable Frontends Logo" alt="Shopware Composable Frontends Logo"
></img> width={40}
height={40}
></Image>
</div> </div>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</footer > </footer>
); );
} }

View File

@ -7,7 +7,7 @@ import { Fragment, useEffect, useState } from 'react';
import CloseIcon from 'components/icons/close'; import CloseIcon from 'components/icons/close';
import MenuIcon from 'components/icons/menu'; import MenuIcon from 'components/icons/menu';
import { Menu } from 'lib/shopify/types'; import { Menu } from 'lib/shopware/types';
import Search from './search'; import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) { export default function MobileMenu({ menu }: { menu: Menu[] }) {

View File

@ -1,15 +1,15 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { getStaticCollections } from 'lib/shopware'; import { getSubCollections } from 'lib/shopware';
import FilterList from './filter'; import FilterList from './filter';
import { transformStaticCollectionToList } from 'lib/shopware/transform'; import { transformCollectionToList } from 'lib/shopware/transform';
async function CollectionList() { async function CollectionList({ collection }: { collection: string }) {
const collections = await getStaticCollections(); const collections = await getSubCollections(collection);
if (collections) { if (collections) {
const list = transformStaticCollectionToList(collections); const list = transformCollectionToList(collections);
return <FilterList list={list} title="Collections" />; if (list.length > 0) return <FilterList list={list} title="Sub-Collections" />;
} }
} }
@ -17,7 +17,7 @@ const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
const activeAndTitles = 'bg-gray-800 dark:bg-gray-300'; const activeAndTitles = 'bg-gray-800 dark:bg-gray-300';
const items = 'bg-gray-400 dark:bg-gray-700'; const items = 'bg-gray-400 dark:bg-gray-700';
export default function Collections() { export default function Collections({ collection }: { collection: string }) {
return ( return (
<Suspense <Suspense
fallback={ fallback={
@ -35,7 +35,7 @@ export default function Collections() {
</div> </div>
} }
> >
<CollectionList /> <CollectionList collection={collection} />
</Suspense> </Suspense>
); );
} }

View File

@ -84,8 +84,8 @@ export function Gallery({
<GridTileImage <GridTileImage
alt={image?.altText} alt={image?.altText}
src={image.src} src={image.src}
width={600} width={200}
height={600} height={200}
background="purple-dark" background="purple-dark"
active={isActive} active={isActive}
/> />

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { ProductOption, ProductVariant } from 'lib/shopify/types'; import { ProductOption, ProductVariant } from 'lib/shopware/types';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';

View File

@ -57,9 +57,10 @@ export type ExtendedProductListingResult = Omit<schemas['ProductListingResult'],
export type ExtendedCrossSellingElementCollection = Omit< export type ExtendedCrossSellingElementCollection = Omit<
schemas['CrossSellingElementCollection'], schemas['CrossSellingElementCollection'],
'products' 'products'
> & { > &
{
products?: ExtendedProduct[]; products?: ExtendedProduct[];
}[]; }[];
export type ExtendedCategory = Omit<schemas['Category'], 'children' | 'seoUrls' | 'cmsPage'> & { export type ExtendedCategory = Omit<schemas['Category'], 'children' | 'seoUrls' | 'cmsPage'> & {
children?: ExtendedCategory[]; children?: ExtendedCategory[];
@ -104,7 +105,7 @@ type extendedReadProductCrossSellings = {
/** Found cross sellings */ /** Found cross sellings */
200: { 200: {
content: { content: {
'application/json': ExtendedCrossSellingElementCollection 'application/json': ExtendedCrossSellingElementCollection;
}; };
}; };
}; };

View File

@ -1,4 +1,4 @@
import { createAPIClient, RequestReturnType } from '@shopware/api-client'; import { createAPIClient, RequestReturnType, ApiClientError } from '@shopware/api-client';
import { operations } from '@shopware/api-client/api-types'; import { operations } from '@shopware/api-client/api-types';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { import {
@ -16,15 +16,13 @@ import {
SeoURLResultSW, SeoURLResultSW,
StoreNavigationTypeSW StoreNavigationTypeSW
} from './types'; } from './types';
import { getStoreDomainWithApiType, getAccessToken, getApiType } from 'lib/shopware/helpers';
const domainSW = `https://${process.env.SHOPWARE_STORE_DOMAIN!}/${process.env.SHOPWARE_API_TYPE!}`;
const accessTokenSW = `${process.env.SHOPWARE_ACCESS_TOKEN}`;
function getApiClient(cartId?: string) { function getApiClient(cartId?: string) {
const apiInstance = createAPIClient<extendedOperations, extendedPaths>({ const apiInstance = createAPIClient<extendedOperations, extendedPaths>({
baseURL: domainSW, baseURL: getStoreDomainWithApiType(),
accessToken: accessTokenSW, accessToken: getAccessToken(),
apiType: 'store-api', apiType: getApiType(),
contextToken: cartId, contextToken: cartId,
onContextChanged(newContextToken: string) { onContextChanged(newContextToken: string) {
//cookies().set('sw-context-token', newContextToken); //cookies().set('sw-context-token', newContextToken);
@ -43,8 +41,9 @@ export type ApiReturnType<OPERATION_NAME extends keyof operations> = RequestRetu
export async function requestNavigation( export async function requestNavigation(
type: StoreNavigationTypeSW, type: StoreNavigationTypeSW,
depth: number depth: number
): Promise<ExtendedCategory[]> { ): Promise<ExtendedCategory[] | undefined> {
return await getApiClient(cookies().get('sw-context-token')).invoke( try {
return await getApiClient().invoke(
'readNavigation post /navigation/{activeId}/{rootId} sw-include-seo-urls', 'readNavigation post /navigation/{activeId}/{rootId} sw-include-seo-urls',
{ {
activeId: type, activeId: type,
@ -52,50 +51,104 @@ export async function requestNavigation(
depth: depth depth: depth
} }
); );
} catch (error) {
if (error instanceof ApiClientError) {
console.error(error);
console.error('Details:', error.details);
} else {
console.error('==>', error);
}
}
} }
export async function requestCategory( export async function requestCategory(
categoryId: string, categoryId: string,
criteria?: Partial<ProductListingCriteria> criteria?: Partial<ProductListingCriteria>
): Promise<ExtendedCategory> { ): Promise<ExtendedCategory | undefined> {
try {
return await getApiClient().invoke('readCategory post /category/{navigationId}?slots', { return await getApiClient().invoke('readCategory post /category/{navigationId}?slots', {
navigationId: categoryId, navigationId: categoryId,
criteria criteria
}); });
} catch (error) {
if (error instanceof ApiClientError) {
console.error(error);
console.error('Details:', error.details);
} else {
console.error('==>', error);
}
}
} }
export async function requestCategoryList( export async function requestCategoryList(
criteria: Partial<ExtendedCriteria> criteria: Partial<ExtendedCriteria>
): Promise<CategoryListingResultSW> { ): Promise<CategoryListingResultSW | undefined> {
try {
return await getApiClient().invoke('readCategoryList post /category', criteria); 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( export async function requestProductsCollection(
criteria: Partial<ProductListingCriteria> criteria: Partial<ProductListingCriteria>
): Promise<ExtendedProductListingResult> { ): Promise<ExtendedProductListingResult | undefined> {
try {
return await getApiClient().invoke('readProduct post /product', criteria); 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( export async function requestCategoryProductsCollection(
categoryId: string, categoryId: string,
criteria: Partial<ProductListingCriteria> criteria: Partial<ProductListingCriteria>
): Promise<ExtendedProductListingResult> { ): Promise<ExtendedProductListingResult | undefined> {
try {
return await getApiClient().invoke('readProductListing post /product-listing/{categoryId}', { return await getApiClient().invoke('readProductListing post /product-listing/{categoryId}', {
...criteria, ...criteria,
categoryId: categoryId categoryId: categoryId
}); });
} catch (error) {
if (error instanceof ApiClientError) {
console.error(error);
console.error('Details:', error.details);
} else {
console.error('==>', error);
}
}
} }
export async function requestSearchCollectionProducts( export async function requestSearchCollectionProducts(
criteria?: Partial<ProductListingCriteria> criteria?: Partial<ProductListingCriteria>
): Promise<ExtendedProductListingResult> { ): Promise<ExtendedProductListingResult | undefined> {
try {
return await getApiClient().invoke('searchPage post /search', { return await getApiClient().invoke('searchPage post /search', {
search: encodeURIComponent(criteria?.query || ''), search: encodeURIComponent(criteria?.query || ''),
...criteria ...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) { export async function requestSeoUrls(routeName: RouteNames, page: number = 1, limit: number = 100) {
try {
return await getApiClient().invoke('readSeoUrl post /seo-url', { return await getApiClient().invoke('readSeoUrl post /seo-url', {
page: page, page: page,
limit: limit, limit: limit,
@ -107,20 +160,28 @@ export async function requestSeoUrls(routeName: RouteNames, page: number = 1, li
} }
] ]
}); });
} catch (error) {
if (error instanceof ApiClientError) {
console.error(error);
console.error('Details:', error.details);
} else {
console.error('==>', error);
}
}
} }
export async function requestSeoUrl( export async function requestSeoUrl(
handle: string, handle: string,
page: number = 1, page: number = 1,
limit: number = 1 limit: number = 1
): Promise<SeoURLResultSW> { ): Promise<SeoURLResultSW | undefined> {
return await getApiClient().invoke('readSeoUrl post /seo-url', { try {
const criteriaSeoUrls = {
page: page, page: page,
limit: limit, limit: limit,
filter: [ filter: [
{ {
type: 'multi', type: 'multi',
// @ts-ignore
operator: 'or', operator: 'or',
queries: [ queries: [
{ {
@ -136,13 +197,24 @@ export async function requestSeoUrl(
] ]
} }
] ]
}); };
// @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( export async function requestCrossSell(
productId: string, productId: string,
criteria?: Partial<ProductListingCriteria> criteria?: Partial<ProductListingCriteria>
): Promise<ExtendedCrossSellingElementCollection> { ): Promise<ExtendedCrossSellingElementCollection | undefined> {
try {
return await getApiClient().invoke( return await getApiClient().invoke(
'readProductCrossSellings post /product/{productId}/cross-selling', 'readProductCrossSellings post /product/{productId}/cross-selling',
{ {
@ -150,10 +222,27 @@ export async function requestCrossSell(
...criteria ...criteria
} }
); );
} catch (error) {
if (error instanceof ApiClientError) {
console.error(error);
console.error('Details:', error.details);
} else {
console.error('==>', error);
}
}
} }
export async function requestCart(cartId?: string) { export async function requestCart(cartId?: string) {
try {
return getApiClient(cartId).invoke('readCart get /checkout/cart?name', {}); return getApiClient(cartId).invoke('readCart get /checkout/cart?name', {});
} catch (error) {
if (error instanceof ApiClientError) {
console.error(error);
console.error('Details:', error.details);
} else {
console.error('==>', error);
}
}
} }
export async function requestContext(cartId?: string) { export async function requestContext(cartId?: string) {

View File

@ -165,7 +165,7 @@ export function getStaticCollectionCriteria(page: number = 1, limit: number = 20
export function getDefaultSubCategoriesCriteria( export function getDefaultSubCategoriesCriteria(
categoryId: string, categoryId: string,
page: number = 1, page: number = 1,
limit: number = 10 limit: number = 1
) { ) {
return { return {
page: page, page: page,

25
lib/shopware/helpers.ts Normal file
View 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';
}

View File

@ -1,4 +1,3 @@
import { Cart } from 'lib/shopify/types';
import { import {
requestCart, requestCart,
requestCategory, requestCategory,
@ -19,8 +18,8 @@ import {
getDefaultProductCriteria, getDefaultProductCriteria,
getDefaultProductsCriteria, getDefaultProductsCriteria,
getDefaultSearchProductsCriteria, getDefaultSearchProductsCriteria,
getSortingCriteria, getDefaultSubCategoriesCriteria,
getStaticCollectionCriteria getSortingCriteria
} from './criteria'; } from './criteria';
import { import {
transformCollection, transformCollection,
@ -29,16 +28,19 @@ import {
transformPage, transformPage,
transformProduct, transformProduct,
transformProducts, transformProducts,
transformStaticCollection transformSubCollection
} from './transform'; } from './transform';
import { import {
ApiSchemas, ApiSchemas,
Cart,
CategoryListingResultSW,
Menu, Menu,
Page, Page,
Product, Product,
ProductListingCriteria, ProductListingCriteria,
StoreNavigationTypeSW StoreNavigationTypeSW
} from './types'; } from './types';
import { isSeoUrls } from 'lib/shopware/helpers';
export async function getMenu(params?: { export async function getMenu(params?: {
type?: StoreNavigationTypeSW; type?: StoreNavigationTypeSW;
@ -52,39 +54,66 @@ export async function getMenu(params?: {
} }
export async function getPage(handle: string | []): Promise<Page | undefined> { export async function getPage(handle: string | []): Promise<Page | undefined> {
const pageHandle = transformHandle(handle).replace('cms/', ''); let seoUrlElement;
const seoUrlElement = await getFirstSeoUrlElement(pageHandle); let pageIdOrHandle = decodeURIComponent(transformHandle(handle)).replace('cms/', '');
if (seoUrlElement) {
const resCategory = await getCategory(seoUrlElement);
return resCategory ? transformPage(seoUrlElement, resCategory) : undefined; 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( export async function getFirstSeoUrlElement(
handle: string handle: string
): Promise<ApiSchemas['SeoUrl'] | undefined> { ): Promise<ApiSchemas['SeoUrl'] | undefined> {
const resSeoUrl = await requestSeoUrl(handle); const seoURL = await requestSeoUrl(handle);
if (resSeoUrl.elements && resSeoUrl.elements.length > 0 && resSeoUrl.elements[0]) { if (seoURL && seoURL.elements && seoURL.elements.length > 0 && seoURL.elements[0]) {
return resSeoUrl.elements[0]; return seoURL.elements[0];
} }
} }
export async function getFirstProduct(productId: string): Promise<ExtendedProduct | undefined> { export async function getFirstProduct(productId: string): Promise<ExtendedProduct | undefined> {
const productCriteria = getDefaultProductCriteria(productId); const productCriteria = getDefaultProductCriteria(productId);
const res: ExtendedProductListingResult = await requestProductsCollection(productCriteria); const listing: ExtendedProductListingResult | undefined = await requestProductsCollection(
if (res.elements && res.elements.length > 0 && res.elements[0]) { productCriteria
return res.elements[0]; );
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 // ToDo: should be more dynamic (depending on handle), should work with server and not client see generateStaticParams from next.js
export async function getStaticCollections() { export async function getSubCollections(collection: string) {
// @ToDo: This is an example about multi-filter with new store API client const collectionName = decodeURIComponent(transformHandle(collection ?? ''));
// @ts-ignore let criteria = getDefaultSubCategoriesCriteria(collectionName);
const resCategory = await requestCategoryList(getStaticCollectionCriteria()); let res: CategoryListingResultSW | undefined = undefined;
const parentCollectionName =
Array.isArray(collection) && collection[0] ? collection[0] : undefined;
return resCategory ? transformStaticCollection(resCategory) : []; 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?: { export async function getSearchCollectionProducts(params?: {
@ -99,9 +128,34 @@ export async function getSearchCollectionProducts(params?: {
const sorting = getSortingCriteria(params?.sortKey, params?.reverse); const sorting = getSortingCriteria(params?.sortKey, params?.reverse);
const searchCriteria = { ...criteria, ...sorting }; const searchCriteria = { ...criteria, ...sorting };
const res = await requestSearchCollectionProducts(searchCriteria); const search = await requestSearchCollectionProducts(searchCriteria);
if (isSeoUrls() && search) {
search.elements = await changeVariantUrlToParentUrl(search);
}
return res ? transformProducts(res) : []; 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?: { export async function getCollectionProducts(params?: {
@ -112,12 +166,12 @@ export async function getCollectionProducts(params?: {
categoryId?: string; categoryId?: string;
defaultSearchCriteria?: Partial<ProductListingCriteria>; defaultSearchCriteria?: Partial<ProductListingCriteria>;
}): Promise<{ products: Product[]; total: number; limit: number }> { }): Promise<{ products: Product[]; total: number; limit: number }> {
let res; let products;
let category = params?.categoryId; let category = params?.categoryId;
const collectionName = transformHandle(params?.collection ?? ''); const collectionName = decodeURIComponent(transformHandle(params?.collection ?? ''));
const sorting = getSortingCriteria(params?.sortKey, params?.reverse); const sorting = getSortingCriteria(params?.sortKey, params?.reverse);
if (!category && collectionName !== '') { if (isSeoUrls() && !category && collectionName !== '') {
const seoUrlElement = await getFirstSeoUrlElement(collectionName); const seoUrlElement = await getFirstSeoUrlElement(collectionName);
if (seoUrlElement) { if (seoUrlElement) {
category = seoUrlElement.foreignKey; category = seoUrlElement.foreignKey;
@ -130,37 +184,56 @@ export async function getCollectionProducts(params?: {
} }
} }
if (!isSeoUrls()) {
category = collectionName ?? undefined;
}
if (category) { if (category) {
const criteria = !params?.defaultSearchCriteria const criteria = !params?.defaultSearchCriteria
? getDefaultProductsCriteria(params?.page) ? getDefaultProductsCriteria(params?.page)
: params?.defaultSearchCriteria; : params?.defaultSearchCriteria;
const productsCriteria = { ...criteria, ...sorting }; const productsCriteria = { ...criteria, ...sorting };
res = await requestCategoryProductsCollection(category, productsCriteria); products = await requestCategoryProductsCollection(category, productsCriteria);
if (products) {
products.elements = await changeVariantUrlToParentUrl(products);
}
} }
return res return products
? { products: transformProducts(res), total: res.total ?? 0, limit: res.limit ?? 0 } ? {
products: transformProducts(products),
total: products.total ?? 0,
limit: products.limit ?? 0
}
: { products: [], total: 0, limit: 0 }; : { products: [], total: 0, limit: 0 };
} }
export async function getCategory( export async function getCategory(
seoUrl: ApiSchemas['SeoUrl'], categoryId: string,
cms: boolean = false cms: boolean = false
): Promise<ExtendedCategory> { ): Promise<ExtendedCategory | undefined> {
const criteria = cms ? getDefaultCategoryWithCmsCriteria() : getDefaultCategoryCriteria(); const criteria = cms ? getDefaultCategoryWithCmsCriteria() : getDefaultCategoryCriteria();
const resCategory = await requestCategory(seoUrl.foreignKey, criteria); return await requestCategory(categoryId, criteria);
return resCategory;
} }
// This function is only used for generateMetadata at app/search/(collection)/[...collection]/page.tsx // This function is only used for generateMetadata at app/search/(collection)/[...collection]/page.tsx
export async function getCollection(handle: string | []) { export async function getCollection(handle: string | []) {
const collectionName = transformHandle(handle); let path;
const seoUrlElement = await getFirstSeoUrlElement(collectionName); let seoUrlElement;
let categoryIdOrHandle = decodeURIComponent(transformHandle(handle));
if (isSeoUrls()) {
seoUrlElement = await getFirstSeoUrlElement(categoryIdOrHandle);
if (seoUrlElement) { if (seoUrlElement) {
const resCategory = await getCategory(seoUrlElement); categoryIdOrHandle = seoUrlElement.foreignKey;
const path = seoUrlElement.seoPathInfo ?? ''; path = seoUrlElement.seoPathInfo ?? '';
const collection = transformCollection(seoUrlElement, resCategory); }
}
const category = await getCategory(categoryIdOrHandle);
if (category) {
const collection = transformCollection(category, seoUrlElement);
path = path ?? category.id ?? '';
return { return {
...collection, ...collection,
@ -171,10 +244,10 @@ export async function getCollection(handle: string | []) {
export async function getProductSeoUrls() { export async function getProductSeoUrls() {
const productSeoUrls: { path: string; updatedAt: string }[] = []; const productSeoUrls: { path: string; updatedAt: string }[] = [];
const res = await requestSeoUrls('frontend.detail.page'); const seoUrls = await requestSeoUrls('frontend.detail.page');
if (res.elements && res.elements.length > 0) { if (seoUrls && seoUrls.elements && seoUrls.elements.length > 0) {
res.elements.map((item) => seoUrls.elements.map((item) =>
productSeoUrls.push({ path: item.seoPathInfo, updatedAt: item.updatedAt ?? item.createdAt }) productSeoUrls.push({ path: item.seoPathInfo, updatedAt: item.updatedAt ?? item.createdAt })
); );
} }
@ -185,16 +258,20 @@ export async function getProductSeoUrls() {
export async function getProduct(handle: string | []): Promise<Product | undefined> { export async function getProduct(handle: string | []): Promise<Product | undefined> {
let productSW: ExtendedProduct | undefined; let productSW: ExtendedProduct | undefined;
let productId: string | undefined; let productId: string | undefined;
const productHandle = transformHandle(handle); 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); const seoUrlElement = await getFirstSeoUrlElement(productHandle);
if (seoUrlElement) { if (seoUrlElement) {
productId = seoUrlElement.foreignKey; productId = seoUrlElement.foreignKey;
} }
}
if (!productId) { if (!productId) {
console.log('[getProduct][search] Did not found any product with handle:', productHandle); console.log('[getProduct][search] Did not found any product with handle:', handle);
} }
if (productId) { if (productId) {
const firstProduct = await getFirstProduct(productId); const firstProduct = await getFirstProduct(productId);
if (firstProduct) { if (firstProduct) {
@ -217,10 +294,11 @@ export async function getProductRecommendations(productId: string): Promise<Prod
return products ? transformProducts(products) : []; return products ? transformProducts(products) : [];
} }
export async function getCart(cartId?: string): Promise<Cart> { export async function getCart(cartId?: string): Promise<Cart | undefined> {
const cartData = await requestCart(cartId); const cartData = await requestCart(cartId);
if (cartData) {
let cart: Cart = { // @ToDo: should be moved to transformCart function
const cart: Cart = {
checkoutUrl: 'https://frontends-demo.vercel.app', checkoutUrl: 'https://frontends-demo.vercel.app',
cost: { cost: {
subtotalAmount: { subtotalAmount: {
@ -240,42 +318,50 @@ export async function getCart(cartId?: string): Promise<Cart> {
lines: lines:
cartData.lineItems?.map((lineItem) => ({ cartData.lineItems?.map((lineItem) => ({
id: lineItem.referencedId || '', id: lineItem.referencedId || '',
quantity: lineItem.quantity, quantity: lineItem.quantity ?? 0,
cost: { cost: {
totalAmount: { totalAmount: {
amount: (lineItem as any)?.price?.totalPrice || '' amount: (lineItem as any)?.price?.totalPrice || '',
currencyCode: 'EUR'
} }
}, },
merchandise: { merchandise: {
id: lineItem.referencedId, id: lineItem.referencedId ?? '',
title: lineItem.label, title: lineItem.label ?? '',
selectedOptions: [], selectedOptions: [],
product: { product: {
description: lineItem.description, description: lineItem.description ?? '',
descriptionHtml: lineItem.description, descriptionHtml: lineItem.description ?? '',
id: lineItem.referencedId, id: lineItem.referencedId ?? '',
images: [], images: [],
path: '',
seo: { seo: {
description: lineItem.description, description: lineItem.description ?? '',
title: lineItem.label title: lineItem.label ?? ''
}, },
availableForSale: true, availableForSale: true,
featuredImage: { featuredImage: (lineItem as any).cover?.url,
altText: 'Cover image of ' + lineItem.label,
url: (lineItem as any).cover?.url
},
handle: '', handle: '',
options: [], options: [],
variants: [], variants: [],
priceRange: {}, priceRange: {
minVariantPrice: {
amount: '', // @ToDo: should be correct value
currencyCode: 'EUR'
},
maxVariantPrice: {
amount: '', // @ToDo: should be correct value
currencyCode: 'EUR'
}
},
tags: [], tags: [],
title: lineItem.label, title: lineItem.label ?? '',
updatedAt: (lineItem as any)?.payload?.updatedAt updatedAt: (lineItem as any)?.payload?.updatedAt
} }
} }
})) || [], })) || [],
totalQuantity: cartData.lineItems?.length || 0 totalQuantity: cartData.lineItems?.length || 0
}; };
return cart; return cart;
}
} }

View File

@ -15,6 +15,7 @@ import {
ExtendedProductListingResult ExtendedProductListingResult
} from './api-extended'; } from './api-extended';
import { ListItem } from 'components/layout/search/filter'; import { ListItem } from 'components/layout/search/filter';
import { isSeoUrls } from 'lib/shopware/helpers';
export function transformMenu(res: ExtendedCategory[], type: string) { export function transformMenu(res: ExtendedCategory[], type: string) {
const menu: Menu[] = []; const menu: Menu[] = [];
@ -25,24 +26,29 @@ export function transformMenu(res: ExtendedCategory[], type: string) {
} }
function transformMenuItem(item: ExtendedCategory, type: string): 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) // @ToDo: currently only footer-navigation is used for cms pages, this need to be more dynamic (shoud depending on the item)
return { return {
id: item.id ?? '', id: item.id ?? '',
title: item.name, title: item.name,
children: item.children?.map((item) => transformMenuItem(item, type)) ?? [], children: item.children?.map((item) => transformMenuItem(item, type)) ?? [],
path: path: path,
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: item.children && item.children.length > 0 ? 'headline' : 'link' type: item.children && item.children.length > 0 ? 'headline' : 'link'
}; };
} }
export function transformPage( export function transformPage(
seoUrlElement: ApiSchemas['SeoUrl'], category: ExtendedCategory,
category: ExtendedCategory seoUrlElement?: ApiSchemas['SeoUrl']
): Page { ): Page {
let plainHtmlContent; let plainHtmlContent;
if (category.cmsPage) { if (category.cmsPage) {
@ -51,20 +57,20 @@ export function transformPage(
} }
return { return {
id: seoUrlElement.id ?? '', id: seoUrlElement?.id ?? category.id ?? '',
title: category.translated?.metaTitle ?? category.name ?? '', title: category.translated?.metaTitle ?? category.name ?? '',
handle: seoUrlElement.seoPathInfo, handle: seoUrlElement?.seoPathInfo ?? category.id ?? '',
body: plainHtmlContent ?? category.description ?? '', body: plainHtmlContent ?? category.description ?? '',
bodySummary: category.translated?.metaDescription ?? category.description ?? '', bodySummary: category.translated?.metaDescription ?? category.description ?? '',
seo: { seo: {
title: category.translated?.metaTitle ?? category.name ?? '', title: category.translated?.metaTitle ?? category.name ?? '',
description: category.translated?.metaDescription ?? category.description ?? '' description: category.translated?.metaDescription ?? category.description ?? ''
}, },
createdAt: seoUrlElement.createdAt ?? '', createdAt: seoUrlElement?.createdAt ?? category.createdAt ?? '',
updatedAt: seoUrlElement.updatedAt ?? '', updatedAt: seoUrlElement?.updatedAt ?? category.updatedAt ?? '',
routeName: seoUrlElement.routeName, routeName: seoUrlElement?.routeName,
originalCmsPage: category.cmsPage, originalCmsPage: category.cmsPage,
foreignKey: seoUrlElement.foreignKey foreignKey: seoUrlElement?.foreignKey ?? category.id
}; };
} }
@ -89,55 +95,89 @@ export function transformToPlainHtmlContent(cmsPage: ExtendedCmsPage): string {
} }
export function transformCollection( export function transformCollection(
seoUrlElement: ApiSchemas['SeoUrl'], resCategory: ExtendedCategory,
resCategory: ExtendedCategory seoUrlElement?: ApiSchemas['SeoUrl']
) { ) {
return { return {
handle: seoUrlElement.seoPathInfo, handle: seoUrlElement?.seoPathInfo ?? resCategory.id ?? '',
title: resCategory.translated?.metaTitle ?? resCategory.name ?? '', title: resCategory.translated?.metaTitle ?? resCategory.name ?? '',
description: resCategory.description ?? '', description: resCategory.description ?? '',
seo: { seo: {
title: resCategory.translated?.metaTitle ?? resCategory.name ?? '', title: resCategory.translated?.metaTitle ?? resCategory.name ?? '',
description: resCategory.translated?.metaDescription ?? resCategory.description ?? '' description: resCategory.translated?.metaDescription ?? resCategory.description ?? ''
}, },
updatedAt: seoUrlElement.updatedAt ?? seoUrlElement.createdAt ?? '' updatedAt:
seoUrlElement?.updatedAt ??
seoUrlElement?.createdAt ??
resCategory.updatedAt ??
resCategory.createdAt
}; };
} }
export function transformStaticCollection(resCategory: CategoryListingResultSW): Collection[] { export function transformSubCollection(
category: CategoryListingResultSW,
parentCollectionName?: string
): Collection[] {
const collection: Collection[] = []; const collection: Collection[] = [];
if (resCategory.elements && resCategory.elements.length > 0) { if (category.elements && category.elements[0] && category.elements[0].children) {
resCategory.elements.map((item) => // 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({ collection.push({
handle: handle: handle,
item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo
? item.seoUrls[0].seoPathInfo
: '',
title: item.translated?.metaTitle ?? item.name ?? '', title: item.translated?.metaTitle ?? item.name ?? '',
description: item.description ?? '', description: item.description ?? '',
seo: { seo: {
title: item.translated?.metaTitle ?? item.name ?? '', title: item.translated?.metaTitle ?? item.name ?? '',
description: item.translated?.metaDescription ?? item.description ?? '' description: item.translated?.metaDescription ?? item.description ?? ''
}, },
childCount: item.childCount ?? 0,
updatedAt: item.updatedAt ?? item.createdAt ?? '' updatedAt: item.updatedAt ?? item.createdAt ?? ''
}) });
); }
});
} }
return collection; return collection;
} }
export function transformStaticCollectionToList(collection: Collection[]): ListItem[] { // 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[] = []; const listItem: ListItem[] = [];
if (collection && collection.length > 0) { if (collection && collection.length > 0) {
collection.map((item) => 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({ listItem.push({
title: item.title, title: item.title,
path: `/search/${item.handle}` path: `${pagePrefix}/${newHandle}`
}) });
); });
} }
return listItem; return listItem;
@ -157,12 +197,17 @@ export function transformProduct(item: ExtendedProduct): Product {
const productOptions = transformOptions(item); const productOptions = transformOptions(item);
const productVariants = transformVariants(item); const productVariants = transformVariants(item);
return { let path = item.parentId ?? item.id ?? '';
id: item.id ?? '', if (isSeoUrls()) {
path: path =
item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo item.seoUrls && item.seoUrls.length > 0 && item.seoUrls[0] && item.seoUrls[0].seoPathInfo
? item.seoUrls[0].seoPathInfo ? item.seoUrls[0].seoPathInfo
: '', : '';
}
return {
id: item.id ?? '',
path: path,
availableForSale: item.available ?? false, availableForSale: item.available ?? false,
title: item.translated ? item.translated.name ?? '' : item.name, title: item.translated ? item.translated.name ?? '' : item.name,
description: item.translated?.metaDescription description: item.translated?.metaDescription
@ -213,9 +258,11 @@ function transformOptions(parent: ExtendedProduct): ProductOption[] {
const productOptions: ProductOption[] = []; const productOptions: ProductOption[] = [];
if (parent.children && parent.parentId === null && parent.children.length > 0) { if (parent.children && parent.parentId === null && parent.children.length > 0) {
const group: { [key: string]: string[] } = {}; const group: { [key: string]: string[] } = {};
const groupId: { [key: string]: string } = {};
parent.children.map((child) => { parent.children.map((child) => {
child.options?.map((option) => { child.options?.map((option) => {
if (option && option.group) { if (option && option.group) {
groupId[option.group.name] = option.groupId;
group[option.group.name] = group[option.group.name] group[option.group.name] = group[option.group.name]
? [...new Set([...(group[option.group.name] as []), ...[option.name]])] ? [...new Set([...(group[option.group.name] as []), ...[option.name]])]
: [option.name]; : [option.name];
@ -223,16 +270,18 @@ function transformOptions(parent: ExtendedProduct): ProductOption[] {
}); });
}); });
if (parent.id) {
for (const [key, value] of Object.entries(group)) { for (const [key, value] of Object.entries(group)) {
for (const [currentGroupName, currentGroupId] of Object.entries(groupId)) {
if (key === currentGroupName) {
productOptions.push({ productOptions.push({
id: parent.id, id: currentGroupId,
name: key, name: key,
values: value values: value
}); });
} }
} }
} }
}
return productOptions; return productOptions;
} }

View File

@ -10,7 +10,11 @@ export type ProductListingCriteria = {
query: string; query: string;
} & Omit<ApiSchemas['ProductListingCriteria'], 'filter'> & } & Omit<ApiSchemas['ProductListingCriteria'], 'filter'> &
ExtendedCriteria; ExtendedCriteria;
export type RouteNames = 'frontend.navigation.page' | 'frontend.detail.page' | 'frontend.account.customer-group-registration.page' | 'frontend.landing.page' export type RouteNames =
| 'frontend.navigation.page'
| 'frontend.detail.page'
| 'frontend.account.customer-group-registration.page'
| 'frontend.landing.page';
/** Return Types */ /** Return Types */
export type CategoryListingResultSW = { export type CategoryListingResultSW = {
@ -96,5 +100,35 @@ export type Collection = {
title: string; title: string;
description: string; description: string;
seo: SEO; seo: SEO;
childCount: number;
updatedAt: string; 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;
};
};

View File

@ -1,8 +1,8 @@
{ {
"private": true, "private": true,
"engines": { "engines": {
"node": ">=16", "node": ">=18",
"pnpm": ">=7" "pnpm": ">=8"
}, },
"scripts": { "scripts": {
"build": "next build", "build": "next build",
@ -15,42 +15,39 @@
"test": "pnpm lint && pnpm prettier:check", "test": "pnpm lint && pnpm prettier:check",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"git": {
"pre-commit": "lint-staged"
},
"lint-staged": { "lint-staged": {
"*": "prettier --write --ignore-unknown" "*": "prettier --write --ignore-unknown"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
"@shopware/api-client": "0.0.0-canary-20230706101754", "@shopware/api-client": "0.0.0-canary-20230713092547",
"@vercel/og": "^0.5.8", "@vercel/og": "^0.5.8",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"is-empty-iterable": "^3.0.0", "is-empty-iterable": "^3.0.0",
"next": "13.4.9", "next": "13.4.10",
"react": "18.2.0", "react": "18.2.0",
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-paginate": "^8.2.0" "react-paginate": "^8.2.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.35.1", "@playwright/test": "^1.36.1",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@types/node": "^20.4.1", "@types/node": "^20.4.2",
"@types/react": "18.2.14", "@types/react": "18.2.15",
"@types/react-dom": "18.2.6", "@types/react-dom": "18.2.7",
"@vercel/git-hooks": "^1.0.0", "@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"eslint": "^8.44.0", "eslint": "^8.45.0",
"eslint-config-next": "^13.4.9", "eslint-config-next": "^13.4.10",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-unicorn": "^47.0.0", "eslint-plugin-unicorn": "^48.0.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^13.2.3", "lint-staged": "^13.2.3",
"postcss": "^8.4.25", "postcss": "^8.4.26",
"prettier": "^2.8.8", "prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.3.0", "prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.3",
"typescript": "5.1.6" "typescript": "5.1.6"
} }
} }

519
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff