diff --git a/packages/commerce/src/types/common.ts b/packages/commerce/src/types/common.ts index 06908c464..2649ac261 100644 --- a/packages/commerce/src/types/common.ts +++ b/packages/commerce/src/types/common.ts @@ -14,3 +14,8 @@ export type Image = { width?: number height?: number } + +export type SEO = { + title?: string + description?: string +} diff --git a/packages/commerce/src/types/product.ts b/packages/commerce/src/types/product.ts index fb48ba00b..e528a0690 100644 --- a/packages/commerce/src/types/product.ts +++ b/packages/commerce/src/types/product.ts @@ -1,3 +1,5 @@ +import { SEO } from './common' + export type ProductImage = { url: string alt?: string @@ -29,6 +31,10 @@ export type ProductVariant = { id: string | number options: ProductOption[] availableForSale?: boolean + // Product variant price + price?: ProductPrice + // Product variant image + image?: ProductImage } export type Product = { @@ -44,6 +50,7 @@ export type Product = { price: ProductPrice options: ProductOption[] vendor?: string + seo?: SEO } export type SearchProductsBody = { diff --git a/site/components/product/ProductCard/ProductCard.tsx b/site/components/product/ProductCard/ProductCard.tsx index c70461f6c..ccfec4371 100644 --- a/site/components/product/ProductCard/ProductCard.tsx +++ b/site/components/product/ProductCard/ProductCard.tsx @@ -108,10 +108,7 @@ const ProductCard: FC = ({ variant={product.variants[0] as any} /> )} - +
{product?.images && (
diff --git a/site/components/product/ProductDetails/ProductDetails.module.css b/site/components/product/ProductDetails/ProductDetails.module.css new file mode 100644 index 000000000..d11d57844 --- /dev/null +++ b/site/components/product/ProductDetails/ProductDetails.module.css @@ -0,0 +1,54 @@ +.root { + @apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden; + min-height: auto; +} + +.main { + @apply relative px-0 pb-0 box-border flex flex-col col-span-1; + min-height: 500px; +} +.sidebar { + @apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full; +} + +.sliderContainer { + @apply flex items-center justify-center overflow-x-hidden bg-violet; +} + +.imageContainer { + @apply text-center h-full relative; +} + +.imageContainer > span { + height: 100% !important; +} + +.sliderContainer .img { + @apply w-full h-full max-h-full object-cover; +} + +.button { + width: 100%; +} + +.wishlistButton { + @apply absolute z-30 top-0 right-0; +} + +@screen lg { + .root { + @apply grid-cols-12; + } + + .main { + @apply mx-0 col-span-8; + } + + .sidebar { + @apply col-span-4 py-6; + } + + .imageContainer { + max-height: 600px; + } +} diff --git a/site/components/product/ProductDetails/ProductDetails.tsx b/site/components/product/ProductDetails/ProductDetails.tsx new file mode 100644 index 000000000..f13b9b8e0 --- /dev/null +++ b/site/components/product/ProductDetails/ProductDetails.tsx @@ -0,0 +1,49 @@ +import cn from 'clsx' +import Image from 'next/image' +import { WishlistButton } from '@components/wishlist' +import ProductSidebar from '../ProductSidebar' +import ProductTag from '../ProductTag' + +import { useProduct } from '../product-context' + +import s from './ProductDetails.module.css' +import ProductSlider from '../ProductSlider' + +const ProductDetails = () => { + const { product, variant, price } = useProduct() + + return ( +
+
+ +
+ + {product.images.map((image, i) => ( +
+ {image.alt +
+ ))} +
+
+ {process.env.COMMERCE_WISHLIST_ENABLED && ( + + )} +
+ +
+ ) +} + +export default ProductDetails diff --git a/site/components/product/ProductOptions/ProductOptions.tsx b/site/components/product/ProductOptions/ProductOptions.tsx index 15c229edb..04a2aa3dd 100644 --- a/site/components/product/ProductOptions/ProductOptions.tsx +++ b/site/components/product/ProductOptions/ProductOptions.tsx @@ -1,22 +1,13 @@ import { memo } from 'react' import { Swatch } from '@components/product' -import type { ProductOption } from '@commerce/types/product' -import { SelectedOptions } from '../helpers' +import { useProduct } from '../product-context' -interface ProductOptionsProps { - options: ProductOption[] - selectedOptions: SelectedOptions - setSelectedOptions: React.Dispatch> -} +const ProductOptions: React.FC = () => { + const { product, selectedOptions, setSelectedOptions } = useProduct() -const ProductOptions: React.FC = ({ - options, - selectedOptions, - setSelectedOptions, -}) => { return (
- {options.map((opt) => ( + {product.options.map((opt) => (

{opt.displayName} diff --git a/site/components/product/ProductSidebar/ProductSidebar.tsx b/site/components/product/ProductSidebar/ProductSidebar.tsx index e549ed698..8babd0143 100644 --- a/site/components/product/ProductSidebar/ProductSidebar.tsx +++ b/site/components/product/ProductSidebar/ProductSidebar.tsx @@ -1,31 +1,22 @@ import s from './ProductSidebar.module.css' import { useAddItem } from '@framework/cart' -import { FC, useEffect, useState } from 'react' +import { FC, useState } from 'react' import { ProductOptions } from '@components/product' -import type { Product } from '@commerce/types/product' import { Button, Text, Rating, Collapse, useUI } from '@components/ui' -import { - getProductVariant, - selectDefaultOptionFromProduct, - SelectedOptions, -} from '../helpers' + +import { useProduct } from '../product-context' interface ProductSidebarProps { - product: Product className?: string } -const ProductSidebar: FC = ({ product, className }) => { +const ProductSidebar: FC = ({ className }) => { const addItem = useAddItem() + + const { product, variant } = useProduct() const { openSidebar, setSidebarView } = useUI() const [loading, setLoading] = useState(false) - const [selectedOptions, setSelectedOptions] = useState({}) - useEffect(() => { - selectDefaultOptionFromProduct(product, setSelectedOptions) - }, [product]) - - const variant = getProductVariant(product, selectedOptions) const addToCart = async () => { setLoading(true) try { @@ -43,11 +34,7 @@ const ProductSidebar: FC = ({ product, className }) => { return (
- + = ({ children, className = '', }) => { - const [currentSlide, setCurrentSlide] = useState(0) + const { imageIndex, resetImageIndex } = useProduct() + const [currentSlide, setCurrentSlide] = useState(imageIndex ?? 0) const [isMounted, setIsMounted] = useState(false) const sliderContainerRef = useRef(null) const thumbsContainerRef = useRef(null) @@ -29,10 +31,12 @@ const ProductSlider: React.FC = ({ loop: true, slides: { perView: 1 }, created: () => setIsMounted(true), + dragStarted: () => { + resetImageIndex() + }, slideChanged(s) { const slideNumber = s.track.details.rel setCurrentSlide(slideNumber) - if (thumbsContainerRef.current) { const $el = document.getElementById(`thumb-${slideNumber}`) if (slideNumber >= 3) { @@ -74,8 +78,23 @@ const ProductSlider: React.FC = ({ } }, []) - const onPrev = React.useCallback(() => slider.current?.prev(), [slider]) - const onNext = React.useCallback(() => slider.current?.next(), [slider]) + useEffect(() => { + if (imageIndex && imageIndex !== currentSlide) { + slider.current?.moveToIdx(imageIndex, undefined, { + duration: 0, + }) + } + }, [imageIndex, currentSlide, slider]) + + const onPrev = React.useCallback(() => { + resetImageIndex() + slider.current?.prev() + }, [resetImageIndex, slider]) + + const onNext = React.useCallback(() => { + resetImageIndex() + slider.current?.next() + }, [resetImageIndex, slider]) return (
@@ -114,6 +133,7 @@ const ProductSlider: React.FC = ({ }), id: `thumb-${idx}`, onClick: () => { + resetImageIndex() slider.current?.moveToIdx(idx) }, }, diff --git a/site/components/product/ProductView/ProductView.module.css b/site/components/product/ProductView/ProductView.module.css index e68c76f21..251a5d9c4 100644 --- a/site/components/product/ProductView/ProductView.module.css +++ b/site/components/product/ProductView/ProductView.module.css @@ -1,59 +1,3 @@ -.root { - @apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden; - min-height: auto; -} - -.main { - @apply relative px-0 pb-0 box-border flex flex-col col-span-1; - min-height: 500px; -} - -.sidebar { - @apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full; -} - -.sliderContainer { - @apply flex items-center justify-center overflow-x-hidden bg-violet; -} - -.imageContainer { - @apply text-center h-full relative; -} - -.imageContainer > span { - height: 100% !important; -} - -.sliderContainer .img { - @apply w-full h-full max-h-full object-cover; -} - -.button { - width: 100%; -} - -.wishlistButton { - @apply absolute z-30 top-0 right-0; -} - .relatedProductsGrid { @apply grid grid-cols-2 py-2 gap-2 md:grid-cols-4 md:gap-7; } - -@screen lg { - .root { - @apply grid-cols-12; - } - - .main { - @apply mx-0 col-span-8; - } - - .sidebar { - @apply col-span-4 py-6; - } - - .imageContainer { - max-height: 600px; - } -} diff --git a/site/components/product/ProductView/ProductView.tsx b/site/components/product/ProductView/ProductView.tsx index 31cbcd577..288badfe7 100644 --- a/site/components/product/ProductView/ProductView.tsx +++ b/site/components/product/ProductView/ProductView.tsx @@ -1,71 +1,27 @@ -import cn from 'clsx' -import Image from 'next/image' import s from './ProductView.module.css' import { FC } from 'react' import type { Product } from '@commerce/types/product' -import usePrice from '@framework/product/use-price' -import { WishlistButton } from '@components/wishlist' -import { ProductSlider, ProductCard } from '@components/product' + +import { ProductCard } from '@components/product' import { Container, Text } from '@components/ui' +import { ProductProvider } from '../product-context' +import ProductDetails from '../ProductDetails/ProductDetails' import { SEO } from '@components/common' -import ProductSidebar from '../ProductSidebar' -import ProductTag from '../ProductTag' + interface ProductViewProps { product: Product relatedProducts: Product[] } const ProductView: FC = ({ product, relatedProducts }) => { - const { price } = usePrice({ - amount: product.price.value, - baseAmount: product.price.retailPrice, - currencyCode: product.price.currencyCode!, - }) - return ( <> -
-
- -
- - {product.images.map((image, i) => ( -
- {image.alt -
- ))} -
-
- {process.env.COMMERCE_WISHLIST_ENABLED && ( - - )} -
- - -

+ + + Related Products
{relatedProducts.map((p) => ( @@ -90,12 +46,12 @@ const ProductView: FC = ({ product, relatedProducts }) => {
> + resetImageIndex: () => void +} + +export const ProductContext = React.createContext( + null +) + +ProductContext.displayName = 'ProductContext' + +type ProductProviderProps = { + product: Product + children?: ReactNode +} + +export const ProductProvider: FC = ({ + product, + children, +}) => { + const [selectedOptions, setSelectedOptions] = useState({}) + const [imageIndex, setImageIndex] = useState(null) + const resetImageIndex = useCallback(() => setImageIndex(null), []) + + const variant = useMemo(() => { + const v = getProductVariant(product, selectedOptions) + return v || product.variants[0] + }, [product, selectedOptions]) + + const { price } = usePrice({ + amount: variant?.price?.value || product.price.value, + baseAmount: variant?.price?.retailPrice || product.price.retailPrice, + currencyCode: variant?.price?.currencyCode || product.price.currencyCode!, + }) + + useEffect(() => { + selectDefaultOptionFromProduct(product, setSelectedOptions) + }, [product]) + + useEffect(() => { + const idx = product.images.findIndex( + (image: ProductImage) => image.url === variant?.image?.url + ) + if (idx) { + setImageIndex(idx) + } + }, [variant, product]) + + return ( + + {children} + + ) +} + +export const useProduct = () => { + const context = React.useContext(ProductContext) as ProductContextValue + if (context === undefined) { + throw new Error(`useProduct must be used within a ProductProvider`) + } + return context +}