From 9635f90a6a0489567b93ede06e9721f4763b73a6 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Wed, 27 Dec 2023 21:36:55 +0700 Subject: [PATCH] Feature/align products (#3) * Update Name * update getProducts * Align with SDK * Update Product details --- apps/web/components/Gallery/Gallery.tsx | 4 + .../ProductAccordion/ProductAccordion.tsx | 90 +++++++++--------- .../ProductProperties/ProductProperties.tsx | 91 +++++++++---------- .../ProductSlider/ProductSlider.tsx | 5 +- .../components/ui/ProductCard/ProductCard.tsx | 20 ++-- apps/web/hooks/useProduct/useProduct.ts | 7 +- .../useProductAttribute.ts | 86 +++++++++--------- apps/web/package.json | 2 +- apps/web/pages/product/[slug].tsx | 12 +-- apps/web/sdk/shopify/fattenArray.ts | 9 ++ apps/web/sdk/shopify/fragments.ts | 5 + apps/web/sdk/shopify/types.ts | 58 ++++++++++++ 12 files changed, 235 insertions(+), 154 deletions(-) create mode 100644 apps/web/sdk/shopify/fattenArray.ts create mode 100644 apps/web/sdk/shopify/types.ts diff --git a/apps/web/components/Gallery/Gallery.tsx b/apps/web/components/Gallery/Gallery.tsx index b5b5597..1f7e3b6 100644 --- a/apps/web/components/Gallery/Gallery.tsx +++ b/apps/web/components/Gallery/Gallery.tsx @@ -41,6 +41,10 @@ export function Gallery({ images, className, ...attributes }: GalleryProps) { return null; }; + if (!images.map) { + return null; + } + const onChangeIndex = (index: number) => { cancel(); setActiveIndex(() => clamp(index, 0, images.length - 1)); diff --git a/apps/web/components/ProductAccordion/ProductAccordion.tsx b/apps/web/components/ProductAccordion/ProductAccordion.tsx index 660bd88..e0e82dd 100644 --- a/apps/web/components/ProductAccordion/ProductAccordion.tsx +++ b/apps/web/components/ProductAccordion/ProductAccordion.tsx @@ -20,49 +20,55 @@ export function ProductAccordion({ product, ...attributes }: ProductAccordionPro return (
- -

{t('productDetails')}

- - - } - summaryClassName="md:rounded-md w-full hover:bg-neutral-100 py-2 pl-4 pr-3 flex justify-between items-center" - open={isOpen('description')} - onToggle={handleToggle('description')} - > -
-

{description}

-
-
- - -

{t('customerReviews')}

- - - } - summaryClassName="md:rounded-md w-full hover:bg-neutral-100 py-2 pl-4 pr-3 flex justify-between items-center" - open={isOpen('reviews')} - onToggle={handleToggle('reviews')} - > -
-
- {reviews.map((review) => ( - - ))} + {description ? ( + <> + +

{t('productDetails')}

+ + + } + summaryClassName="md:rounded-md w-full hover:bg-neutral-100 py-2 pl-4 pr-3 flex justify-between items-center" + open={isOpen('description')} + onToggle={handleToggle('description')} + > +
+

{description}

+
+
+ + + ) : null} + {reviews.length > 0 ? ( + +

{t('customerReviews')}

+ + + } + summaryClassName="md:rounded-md w-full hover:bg-neutral-100 py-2 pl-4 pr-3 flex justify-between items-center" + open={isOpen('reviews')} + onToggle={handleToggle('reviews')} + > +
+
+ {reviews.map((review) => ( + + ))} +
-
- + + ) : null}
); } diff --git a/apps/web/components/ProductProperties/ProductProperties.tsx b/apps/web/components/ProductProperties/ProductProperties.tsx index 60f7458..8d67f93 100644 --- a/apps/web/components/ProductProperties/ProductProperties.tsx +++ b/apps/web/components/ProductProperties/ProductProperties.tsx @@ -1,58 +1,57 @@ import { SfChip, SfThumbnail } from '@storefront-ui/react'; import { useTranslation } from 'next-i18next'; -import type { ProductPropertiesProps } from '~/components'; +import { Divider, type ProductPropertiesProps } from '~/components'; import { useProductAttribute } from '~/hooks'; export function ProductProperties({ product, showColors = true, ...attributes }: ProductPropertiesProps): JSX.Element { const { t } = useTranslation(); - const { getAttributeList, getAttribute, setAttribute } = useProductAttribute(product, ['Color', 'Size']); + const { getOptions, getAttributeList, getAttribute, setAttribute } = useProductAttribute(product, ['Color', 'Size']); + const options = getOptions(); - const sizes = getAttributeList('Size'); - const colors = getAttributeList('Color'); - const selectedSize = getAttribute('Size'); - const selectedColor = getAttribute('Color'); + if (options.length === 0) { + return null; + } return ( -
- {sizes.length > 1 && ( - <> - {t('size')} - {sizes.map(({ label, value }) => ( -
- setAttribute('Size', value), - }} - > - {label} - -
- ))} - - )} - {colors.length > 1 && ( - <> - {t('color')} - {colors.map(({ value, label }) => ( -
- : null} - size="sm" - inputProps={{ - checked: value === selectedColor, - onChange: () => setAttribute('Color', value), - }} - > - {label} - -
- ))} - - )} -
+ <> + +
+ {options.map((option) => { + const optionValues = getAttributeList(option); + const selectedValue = getAttribute(option); + + return ( + optionValues.length > 1 && ( +
+ + {t(option.toLowerCase())} + + {optionValues.map(({ label, value }) => ( +
+ + ) : null + } + inputProps={{ + checked: value === selectedValue, + onChange: () => setAttribute(option, value), + }} + > + {label} + +
+ ))} +
+ ) + ); + })} +
+ + ); } diff --git a/apps/web/components/ProductSlider/ProductSlider.tsx b/apps/web/components/ProductSlider/ProductSlider.tsx index f6b215c..afb8805 100644 --- a/apps/web/components/ProductSlider/ProductSlider.tsx +++ b/apps/web/components/ProductSlider/ProductSlider.tsx @@ -22,8 +22,9 @@ const useProducts = () => { const [products, setProducts] = React.useState([]); const getData = async () => { - const resp = await sdk.shopify.getProducts(); - setProducts(processProducts(resp)); + const resp = await sdk.shopify.getProducts({}); + console.log('🚀 ~ file: ProductSlider.tsx:26 ~ getData ~ resp:', resp); + setProducts(resp.products); }; React.useEffect(() => { diff --git a/apps/web/components/ui/ProductCard/ProductCard.tsx b/apps/web/components/ui/ProductCard/ProductCard.tsx index 59647da..8d4c147 100644 --- a/apps/web/components/ui/ProductCard/ProductCard.tsx +++ b/apps/web/components/ui/ProductCard/ProductCard.tsx @@ -33,15 +33,17 @@ export function ProductCard({ >
- {imageAlt + {imageUrl && ( + {imageAlt + )}
=> { - const data = await sdk.shopify.getProduct({ handle: slug }); - return data; +const fetchProduct = async (slug: string): Promise => { + return await sdk.shopify.getProduct({ slug }); }; export async function prefetchProduct(slug: string): Promise { diff --git a/apps/web/hooks/useProductAttribute/useProductAttribute.ts b/apps/web/hooks/useProductAttribute/useProductAttribute.ts index 43691bc..5cb27ed 100644 --- a/apps/web/hooks/useProductAttribute/useProductAttribute.ts +++ b/apps/web/hooks/useProductAttribute/useProductAttribute.ts @@ -1,45 +1,45 @@ import { useState } from 'react'; -import { SfAttribute, SfProduct } from '@vue-storefront/unified-data-model'; -import { get, map, defaults as withDefaults, zipObject, groupBy, uniqBy, pick, mapValues } from 'lodash-es'; - -/** - * Hook for getting product attributes data - * @param {SfProduct} product Product object - */ -export function useProductAttribute(product: SfProduct, attributesNames: TAttribute[] = []) { - // const attributes = groupBy( - // uniqBy( - // (product?.variants || []).flatMap((variant) => variant?.attributes), - // 'value', - // ), - // 'name', - // ); - // const mapAttribute = (attributes: SfAttribute[] = []) => { - // const mappedAttributes = mapValues( - // pick(groupBy(attributes, 'name'), attributesNames), - // (attribute) => attribute[0].value, - // ); - - // const defaults = zipObject( - // attributesNames, - // map(attributesNames, () => null), - // ); - // return withDefaults(mappedAttributes, defaults); - // }; - const defaultAttributes = Object.entries(product.attributes) - .map(([name, values]) => ({ [name]: values[0].value })) - .reduce((obj, attr) => ({ ...obj, ...attr }), {}); - - const [selectedAttrs, setSelectedAttrs] = useState(defaultAttributes); - - return { - getAttributeList: (attributeName: TAttribute) => get(product.attributes, attributeName, [] as SfAttribute[]), - getAttribute: (attributeName: TAttribute) => get(selectedAttrs, attributeName, null), - setAttribute: (attributeName: TAttribute, attributeValue: string) => { - setSelectedAttrs((previous) => ({ - ...previous, - [attributeName]: attributeValue, - })); - }, - }; +import { Product } from '~/sdk/shopify/types'; + +interface AttributeValue { + label: string; + value: string; +} + +interface UseProductAttribute { + getAttributeList: (attributeName: string) => AttributeValue[]; + getAttribute: (attributeName: string) => string; + setAttribute: (attributeName: string, value: string) => void; + getOptions: () => string[]; } + +export const useProductAttribute = (product: Product, attributes: string[]): UseProductAttribute => { + const initialAttributes = attributes.reduce>((accumulator, attribute) => { + const option = product.options.find((opt) => opt.name === attribute); + if (option && option.values.length > 0) { + accumulator[attribute] = option.values[0]; + } + return accumulator; + }, {}); + + const [selectedAttributes, setSelectedAttributes] = useState>(initialAttributes); + + const getAttributeList = (attributeName: string): AttributeValue[] => { + const attribute = product.options.find((option) => option.name === attributeName); + return attribute ? attribute.values.map((value) => ({ label: value, value })) : []; + }; + + const getAttribute = (attributeName: string): string => { + return selectedAttributes[attributeName] || ''; + }; + + const setAttribute = (attributeName: string, value: string): void => { + setSelectedAttributes((previous) => ({ ...previous, [attributeName]: value })); + }; + + const getOptions = (): string[] => { + return product.options.map((option) => option.name); + }; + + return { getAttributeList, getAttribute, setAttribute, getOptions }; +}; diff --git a/apps/web/package.json b/apps/web/package.json index 7e13471..40b61fa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,5 +1,5 @@ { - "name": "web", + "name": "shopify-storefront", "version": "0.1.0", "private": true, "main": "", diff --git a/apps/web/pages/product/[slug].tsx b/apps/web/pages/product/[slug].tsx index e9dd434..6b10ea5 100644 --- a/apps/web/pages/product/[slug].tsx +++ b/apps/web/pages/product/[slug].tsx @@ -15,6 +15,7 @@ import { } from '~/components'; import { useProduct, useProductRecommended, prefetchProduct, useProductBreadcrumbs } from '~/hooks'; import { DefaultLayout } from '~/layouts'; +import { flattenArray } from '~/sdk/shopify/fattenArray'; interface ProductPageQuery extends ParsedUrlQuery { slug: string; @@ -60,7 +61,7 @@ export function ProductPage() { return null; } - const { gallery } = product; + const images = flattenArray(product.gallery); return ( @@ -70,22 +71,19 @@ export function ProductPage() {
- +
- + {/* */} -
-
- -
+
{/* */}
); diff --git a/apps/web/sdk/shopify/fattenArray.ts b/apps/web/sdk/shopify/fattenArray.ts new file mode 100644 index 0000000..7cd7aa4 --- /dev/null +++ b/apps/web/sdk/shopify/fattenArray.ts @@ -0,0 +1,9 @@ +type GraphQLObject = OBJ; + +export const flattenArray = (data: { edges: { node: GraphQLObject }[] }): GraphQLObject[] => { + return data.edges.map((edge: { node: GraphQLObject }) => { + return { + ...edge.node, + }; + }); +}; diff --git a/apps/web/sdk/shopify/fragments.ts b/apps/web/sdk/shopify/fragments.ts index 79ed5cb..4633d1f 100644 --- a/apps/web/sdk/shopify/fragments.ts +++ b/apps/web/sdk/shopify/fragments.ts @@ -38,6 +38,11 @@ const product = `#graphql } } } + options { + id + name + values + } priceRange { minVariantPrice { amount diff --git a/apps/web/sdk/shopify/types.ts b/apps/web/sdk/shopify/types.ts new file mode 100644 index 0000000..228ae41 --- /dev/null +++ b/apps/web/sdk/shopify/types.ts @@ -0,0 +1,58 @@ +export type EdgeNode = { + edges: { + node: TP; + }[]; +}; + +export type Variant = { + id: string; + sku: string; + title: string; + currentlyNotInStock: boolean; + selectedOptions: { + name: string; + value: string; + }[]; + quantityAvailable: number; + price: { + amount: number; + currencyCode: string; + }; +}; + +export type Product = { + id: string; + title: string; + description: string; + slug: string; + primaryImage: { + id: string; + url: string; + width: number; + height: number; + altText: string; + }; + gallery: EdgeNode<{ + id: string; + alt: string; + url: string; + }>; + priceRange: { + minVariantPrice: { + amount: number; + currencyCode: string; + }; + maxVariantPrice: { + amount: number; + currencyCode: string; + }; + }; + availableForSale: boolean; + totalInventory: number; + options: { + id: string; + name: string; + values: string[]; + }[]; + variants: EdgeNode; +};