mirror of
https://github.com/vercel/commerce.git
synced 2025-05-17 15:06:59 +00:00
Change image & price on variant change
This commit is contained in:
parent
f8a1fb3d7f
commit
0b41b4e618
@ -14,3 +14,8 @@ export type Image = {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export type SEO = {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
49
site/components/product/ProductDetails/ProductDetails.tsx
Normal file
49
site/components/product/ProductDetails/ProductDetails.tsx
Normal 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
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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!,
|
||||
|
95
site/components/product/product-context.tsx
Normal file
95
site/components/product/product-context.tsx
Normal 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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user