Change image & price on variant change

This commit is contained in:
cond0r 2022-07-14 09:51:36 +03:00
parent f8a1fb3d7f
commit 0b41b4e618
11 changed files with 258 additions and 153 deletions

View File

@ -14,3 +14,8 @@ export type Image = {
width?: number width?: number
height?: number height?: number
} }
export type SEO = {
title?: string
description?: string
}

View File

@ -1,3 +1,5 @@
import { SEO } from './common'
export type ProductImage = { export type ProductImage = {
url: string url: string
alt?: string alt?: string
@ -29,6 +31,10 @@ export type ProductVariant = {
id: string | number id: string | number
options: ProductOption[] options: ProductOption[]
availableForSale?: boolean availableForSale?: boolean
// Product variant price
price?: ProductPrice
// Product variant image
image?: ProductImage
} }
export type Product = { export type Product = {
@ -44,6 +50,7 @@ export type Product = {
price: ProductPrice price: ProductPrice
options: ProductOption[] options: ProductOption[]
vendor?: string vendor?: string
seo?: SEO
} }
export type SearchProductsBody = { export type SearchProductsBody = {

View File

@ -108,10 +108,7 @@ const ProductCard: FC<Props> = ({
variant={product.variants[0] as any} variant={product.variants[0] as any}
/> />
)} )}
<ProductTag <ProductTag name={product.name} price={price} />
name={product.name}
price={`${price} ${product.price?.currencyCode}`}
/>
<div className={s.imageContainer}> <div className={s.imageContainer}>
{product?.images && ( {product?.images && (
<div> <div>

View File

@ -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;
}
}

View File

@ -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 (
<div className={cn(s.root, 'fit')}>
<div className={cn(s.main, 'fit')}>
<ProductTag name={product.name} price={price} fontSize={32} />
<div className={s.sliderContainer}>
<ProductSlider key={product.id}>
{product.images.map((image, i) => (
<div key={image.url} className={s.imageContainer}>
<Image
className={s.img}
src={image.url!}
alt={image.alt || 'Product Image'}
width={600}
height={600}
priority={i === 0}
quality="85"
/>
</div>
))}
</ProductSlider>
</div>
{process.env.COMMERCE_WISHLIST_ENABLED && (
<WishlistButton
className={s.wishlistButton}
productId={product.id}
variant={variant}
/>
)}
</div>
<ProductSidebar key={product.id} className={s.sidebar} />
</div>
)
}
export default ProductDetails

View File

@ -1,22 +1,13 @@
import { memo } from 'react' import { memo } from 'react'
import { Swatch } from '@components/product' import { Swatch } from '@components/product'
import type { ProductOption } from '@commerce/types/product' import { useProduct } from '../product-context'
import { SelectedOptions } from '../helpers'
interface ProductOptionsProps { const ProductOptions: React.FC = () => {
options: ProductOption[] const { product, selectedOptions, setSelectedOptions } = useProduct()
selectedOptions: SelectedOptions
setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedOptions>>
}
const ProductOptions: React.FC<ProductOptionsProps> = ({
options,
selectedOptions,
setSelectedOptions,
}) => {
return ( return (
<div> <div>
{options.map((opt) => ( {product.options.map((opt) => (
<div className="pb-4" key={opt.displayName}> <div className="pb-4" key={opt.displayName}>
<h2 className="uppercase font-medium text-sm tracking-wide"> <h2 className="uppercase font-medium text-sm tracking-wide">
{opt.displayName} {opt.displayName}

View File

@ -1,31 +1,22 @@
import s from './ProductSidebar.module.css' import s from './ProductSidebar.module.css'
import { useAddItem } from '@framework/cart' import { useAddItem } from '@framework/cart'
import { FC, useEffect, useState } from 'react' import { FC, useState } from 'react'
import { ProductOptions } from '@components/product' import { ProductOptions } from '@components/product'
import type { Product } from '@commerce/types/product'
import { Button, Text, Rating, Collapse, useUI } from '@components/ui' import { Button, Text, Rating, Collapse, useUI } from '@components/ui'
import {
getProductVariant, import { useProduct } from '../product-context'
selectDefaultOptionFromProduct,
SelectedOptions,
} from '../helpers'
interface ProductSidebarProps { interface ProductSidebarProps {
product: Product
className?: string className?: string
} }
const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => { const ProductSidebar: FC<ProductSidebarProps> = ({ className }) => {
const addItem = useAddItem() const addItem = useAddItem()
const { product, variant } = useProduct()
const { openSidebar, setSidebarView } = useUI() const { openSidebar, setSidebarView } = useUI()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>({})
useEffect(() => {
selectDefaultOptionFromProduct(product, setSelectedOptions)
}, [product])
const variant = getProductVariant(product, selectedOptions)
const addToCart = async () => { const addToCart = async () => {
setLoading(true) setLoading(true)
try { try {
@ -43,11 +34,7 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
return ( return (
<div className={className}> <div className={className}>
<ProductOptions <ProductOptions />
options={product.options}
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
/>
<Text <Text
className="pb-4 break-words w-full max-w-xl" className="pb-4 break-words w-full max-w-xl"
html={product.descriptionHtml || product.description} html={product.descriptionHtml || product.description}

View File

@ -10,6 +10,7 @@ import cn from 'clsx'
import { a } from '@react-spring/web' import { a } from '@react-spring/web'
import s from './ProductSlider.module.css' import s from './ProductSlider.module.css'
import ProductSliderControl from '../ProductSliderControl' import ProductSliderControl from '../ProductSliderControl'
import { useProduct } from '../product-context'
interface ProductSliderProps { interface ProductSliderProps {
children: React.ReactNode[] children: React.ReactNode[]
@ -20,7 +21,8 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
children, children,
className = '', className = '',
}) => { }) => {
const [currentSlide, setCurrentSlide] = useState(0) const { imageIndex, resetImageIndex } = useProduct()
const [currentSlide, setCurrentSlide] = useState(imageIndex ?? 0)
const [isMounted, setIsMounted] = useState(false) const [isMounted, setIsMounted] = useState(false)
const sliderContainerRef = useRef<HTMLDivElement>(null) const sliderContainerRef = useRef<HTMLDivElement>(null)
const thumbsContainerRef = useRef<HTMLDivElement>(null) const thumbsContainerRef = useRef<HTMLDivElement>(null)
@ -29,10 +31,12 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
loop: true, loop: true,
slides: { perView: 1 }, slides: { perView: 1 },
created: () => setIsMounted(true), created: () => setIsMounted(true),
dragStarted: () => {
resetImageIndex()
},
slideChanged(s) { slideChanged(s) {
const slideNumber = s.track.details.rel const slideNumber = s.track.details.rel
setCurrentSlide(slideNumber) setCurrentSlide(slideNumber)
if (thumbsContainerRef.current) { if (thumbsContainerRef.current) {
const $el = document.getElementById(`thumb-${slideNumber}`) const $el = document.getElementById(`thumb-${slideNumber}`)
if (slideNumber >= 3) { if (slideNumber >= 3) {
@ -74,8 +78,23 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
} }
}, []) }, [])
const onPrev = React.useCallback(() => slider.current?.prev(), [slider]) useEffect(() => {
const onNext = React.useCallback(() => slider.current?.next(), [slider]) 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 ( return (
<div className={cn(s.root, className)} ref={sliderContainerRef}> <div className={cn(s.root, className)} ref={sliderContainerRef}>
@ -114,6 +133,7 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
}), }),
id: `thumb-${idx}`, id: `thumb-${idx}`,
onClick: () => { onClick: () => {
resetImageIndex()
slider.current?.moveToIdx(idx) slider.current?.moveToIdx(idx)
}, },
}, },

View File

@ -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 { .relatedProductsGrid {
@apply grid grid-cols-2 py-2 gap-2 md:grid-cols-4 md:gap-7; @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;
}
}

View File

@ -1,71 +1,27 @@
import cn from 'clsx'
import Image from 'next/image'
import s from './ProductView.module.css' import s from './ProductView.module.css'
import { FC } from 'react' import { FC } from 'react'
import type { Product } from '@commerce/types/product' import type { Product } from '@commerce/types/product'
import usePrice from '@framework/product/use-price'
import { WishlistButton } from '@components/wishlist' import { ProductCard } from '@components/product'
import { ProductSlider, ProductCard } from '@components/product'
import { Container, Text } from '@components/ui' import { Container, Text } from '@components/ui'
import { ProductProvider } from '../product-context'
import ProductDetails from '../ProductDetails/ProductDetails'
import { SEO } from '@components/common' import { SEO } from '@components/common'
import ProductSidebar from '../ProductSidebar'
import ProductTag from '../ProductTag'
interface ProductViewProps { interface ProductViewProps {
product: Product product: Product
relatedProducts: Product[] relatedProducts: Product[]
} }
const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => { const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
const { price } = usePrice({
amount: product.price.value,
baseAmount: product.price.retailPrice,
currencyCode: product.price.currencyCode!,
})
return ( return (
<> <>
<Container className="max-w-none w-full" clean> <Container className="max-w-none w-full" clean>
<div className={cn(s.root, 'fit')}>
<div className={cn(s.main, 'fit')}>
<ProductTag
name={product.name}
price={`${price} ${product.price?.currencyCode}`}
fontSize={32}
/>
<div className={s.sliderContainer}>
<ProductSlider key={product.id}>
{product.images.map((image, i) => (
<div key={image.url} className={s.imageContainer}>
<Image
className={s.img}
src={image.url!}
alt={image.alt || 'Product Image'}
width={600}
height={600}
priority={i === 0}
quality="85"
/>
</div>
))}
</ProductSlider>
</div>
{process.env.COMMERCE_WISHLIST_ENABLED && (
<WishlistButton
className={s.wishlistButton}
productId={product.id}
variant={product.variants[0]}
/>
)}
</div>
<ProductSidebar
key={product.id}
product={product}
className={s.sidebar}
/>
</div>
<hr className="mt-7 border-accent-2" /> <hr className="mt-7 border-accent-2" />
<section className="py-12 px-6 mb-10"> <section className="py-12 px-6 mb-10">
<ProductProvider product={product}>
<ProductDetails />
</ProductProvider>
<Text variant="sectionHeading">Related Products</Text> <Text variant="sectionHeading">Related Products</Text>
<div className={s.relatedProductsGrid}> <div className={s.relatedProductsGrid}>
{relatedProducts.map((p) => ( {relatedProducts.map((p) => (
@ -90,12 +46,12 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
</section> </section>
</Container> </Container>
<SEO <SEO
title={product.name} title={product.seo?.title || product.name}
description={product.description} description={product.seo?.description || product.description}
openGraph={{ openGraph={{
type: 'website', type: 'website',
title: product.name, title: product.seo?.title || product.name,
description: product.description, description: product.seo?.description || product.description,
images: [ images: [
{ {
url: product.images[0]?.url!, url: product.images[0]?.url!,

View File

@ -0,0 +1,95 @@
import { Product, ProductImage, ProductVariant } from '@commerce/types/product'
import {
getProductVariant,
selectDefaultOptionFromProduct,
SelectedOptions,
} from './helpers'
import React, {
FC,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import usePrice from '@framework/product/use-price'
export interface ProductContextValue {
product: Product
imageIndex: number | null
price: string
variant: ProductVariant
selectedOptions: SelectedOptions
setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedOptions>>
resetImageIndex: () => void
}
export const ProductContext = React.createContext<ProductContextValue | null>(
null
)
ProductContext.displayName = 'ProductContext'
type ProductProviderProps = {
product: Product
children?: ReactNode
}
export const ProductProvider: FC<ProductProviderProps> = ({
product,
children,
}) => {
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>({})
const [imageIndex, setImageIndex] = useState<number | null>(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 (
<ProductContext.Provider
value={{
price,
product,
variant,
imageIndex,
resetImageIndex,
selectedOptions,
setSelectedOptions,
}}
>
{children}
</ProductContext.Provider>
)
}
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
}