feat(poc): carousel and improved sub-collections

This commit is contained in:
Björn Meyer 2023-07-13 12:05:07 +02:00
parent 8dcf6db08f
commit 8550185eae
10 changed files with 239 additions and 127 deletions

View File

@ -3,6 +3,8 @@ 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 { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
@ -47,7 +49,11 @@ 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>
@ -55,6 +61,10 @@ export default async function CategoryPage({
<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,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';
@ -22,19 +23,32 @@ export default async function SearchPage({
const resultsText = products.length > 1 ? 'results' : 'result'; const resultsText = products.length > 1 ? 'results' : 'result';
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

@ -3,39 +3,36 @@ 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 { products } = await getCollectionProducts({ collection: 'Summer-BBQ/Hidden-Carousel-Category' });
// const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
// 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

@ -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. test // Invoke when user click to request another page.
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,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

@ -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,

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,10 +28,12 @@ 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,
@ -55,9 +56,17 @@ export async function getPage(handle: string | []): Promise<Page | undefined> {
const pageHandle = transformHandle(handle).replace('cms/', ''); const pageHandle = transformHandle(handle).replace('cms/', '');
const seoUrlElement = await getFirstSeoUrlElement(pageHandle); const seoUrlElement = await getFirstSeoUrlElement(pageHandle);
if (seoUrlElement) { if (seoUrlElement) {
const resCategory = await getCategory(seoUrlElement); const category = await getCategory(seoUrlElement);
return resCategory ? transformPage(seoUrlElement, resCategory) : undefined; if (!category) {
console.log('[getPage] Did not found any category with page handle:', pageHandle);
}
return category ? transformPage(seoUrlElement, category) : undefined;
}
if (!seoUrlElement) {
console.log('[getPage] Did not found any seoUrl element with page handle:', pageHandle);
} }
} }
@ -79,12 +88,19 @@ export async function getFirstProduct(productId: string): Promise<ExtendedProduc
} }
// 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 let res: CategoryListingResultSW | undefined = undefined;
const parentCollectionName =
Array.isArray(collection) && collection[0] ? collection[0] : undefined;
const collectionName = transformHandle(collection ?? '');
const seoUrlElement = await getFirstSeoUrlElement(collectionName);
if (seoUrlElement) {
const criteria = getDefaultSubCategoriesCriteria(seoUrlElement.foreignKey);
// @ts-ignore // @ts-ignore
const resCategory = await requestCategoryList(getStaticCollectionCriteria()); res = await requestCategoryList(criteria);
}
return resCategory ? transformStaticCollection(resCategory) : []; return res ? transformSubCollection(res, parentCollectionName) : [];
} }
export async function getSearchCollectionProducts(params?: { export async function getSearchCollectionProducts(params?: {
@ -220,6 +236,7 @@ export async function getProductRecommendations(productId: string): Promise<Prod
export async function getCart(): Promise<Cart> { export async function getCart(): Promise<Cart> {
const cartData = await requestCart(); const cartData = await requestCart();
// @ToDo: should be moved to transformCart function
let cart: Cart = { let cart: Cart = {
checkoutUrl: 'https://frontends-demo.vercel.app', checkoutUrl: 'https://frontends-demo.vercel.app',
cost: { cost: {
@ -240,33 +257,44 @@ export async function getCart(): Promise<Cart> {
lines: lines:
cartData.lineItems?.map((lineItem) => ({ cartData.lineItems?.map((lineItem) => ({
id: lineItem.id || '', id: lineItem.id || '',
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: (lineItem as any).cover?.url, featuredImage: (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
} }
} }

View File

@ -104,40 +104,69 @@ export function transformCollection(
}; };
} }
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 = item.seoUrls ? findHandle(item.seoUrls, parentCollectionName) : undefined;
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;

View File

@ -96,5 +96,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;
};
};