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
height?: number
}
export type SEO = {
title?: string
description?: string
}

View File

@ -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 = {

View File

@ -108,10 +108,7 @@ const ProductCard: FC<Props> = ({
variant={product.variants[0] as any}
/>
)}
<ProductTag
name={product.name}
price={`${price} ${product.price?.currencyCode}`}
/>
<ProductTag name={product.name} price={price} />
<div className={s.imageContainer}>
{product?.images && (
<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 { 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<React.SetStateAction<SelectedOptions>>
}
const ProductOptions: React.FC = () => {
const { product, selectedOptions, setSelectedOptions } = useProduct()
const ProductOptions: React.FC<ProductOptionsProps> = ({
options,
selectedOptions,
setSelectedOptions,
}) => {
return (
<div>
{options.map((opt) => (
{product.options.map((opt) => (
<div className="pb-4" key={opt.displayName}>
<h2 className="uppercase font-medium text-sm tracking-wide">
{opt.displayName}

View File

@ -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<ProductSidebarProps> = ({ product, className }) => {
const ProductSidebar: FC<ProductSidebarProps> = ({ className }) => {
const addItem = useAddItem()
const { product, variant } = useProduct()
const { openSidebar, setSidebarView } = useUI()
const [loading, setLoading] = useState(false)
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>({})
useEffect(() => {
selectDefaultOptionFromProduct(product, setSelectedOptions)
}, [product])
const variant = getProductVariant(product, selectedOptions)
const addToCart = async () => {
setLoading(true)
try {
@ -43,11 +34,7 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
return (
<div className={className}>
<ProductOptions
options={product.options}
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
/>
<ProductOptions />
<Text
className="pb-4 break-words w-full max-w-xl"
html={product.descriptionHtml || product.description}

View File

@ -10,6 +10,7 @@ import cn from 'clsx'
import { a } from '@react-spring/web'
import s from './ProductSlider.module.css'
import ProductSliderControl from '../ProductSliderControl'
import { useProduct } from '../product-context'
interface ProductSliderProps {
children: React.ReactNode[]
@ -20,7 +21,8 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
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<HTMLDivElement>(null)
const thumbsContainerRef = useRef<HTMLDivElement>(null)
@ -29,10 +31,12 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
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<ProductSliderProps> = ({
}
}, [])
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 (
<div className={cn(s.root, className)} ref={sliderContainerRef}>
@ -114,6 +133,7 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
}),
id: `thumb-${idx}`,
onClick: () => {
resetImageIndex()
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 {
@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 { 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<ProductViewProps> = ({ product, relatedProducts }) => {
const { price } = usePrice({
amount: product.price.value,
baseAmount: product.price.retailPrice,
currencyCode: product.price.currencyCode!,
})
return (
<>
<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" />
<section className="py-12 px-6 mb-10">
<ProductProvider product={product}>
<ProductDetails />
</ProductProvider>
<Text variant="sectionHeading">Related Products</Text>
<div className={s.relatedProductsGrid}>
{relatedProducts.map((p) => (
@ -90,12 +46,12 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
</section>
</Container>
<SEO
title={product.name}
description={product.description}
title={product.seo?.title || product.name}
description={product.seo?.description || product.description}
openGraph={{
type: 'website',
title: product.name,
description: product.description,
title: product.seo?.title || product.name,
description: product.seo?.description || product.description,
images: [
{
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
}