mirror of
https://github.com/vercel/commerce.git
synced 2025-05-17 15:06:59 +00:00
Update product image & price based on variant, and fixes
This commit is contained in:
parent
c75b0fc001
commit
180f815071
@ -8,6 +8,17 @@ import type { definitions } from '../api/definitions/store-content'
|
||||
|
||||
import getSlug from './get-slug'
|
||||
|
||||
const normalizePrice = (prices: ProductNode['prices']) => ({
|
||||
value: prices?.price.value || 0,
|
||||
currencyCode: prices?.price.currencyCode || 'USD',
|
||||
...(prices?.salePrice?.value && {
|
||||
salePrice: prices.salePrice.value,
|
||||
}),
|
||||
...(prices?.retailPrice?.value && {
|
||||
retailPrice: prices.retailPrice.value,
|
||||
}),
|
||||
})
|
||||
|
||||
function normalizeProductOption(productOption: any) {
|
||||
const {
|
||||
node: { entityId, values: { edges = [] } = {}, ...rest },
|
||||
@ -20,43 +31,64 @@ function normalizeProductOption(productOption: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProduct(productNode: ProductNode): Product {
|
||||
const {
|
||||
entityId: id,
|
||||
productOptions,
|
||||
prices,
|
||||
path,
|
||||
images,
|
||||
variants,
|
||||
} = productNode
|
||||
|
||||
return {
|
||||
id: String(id),
|
||||
name: productNode.name,
|
||||
description: productNode.description,
|
||||
images:
|
||||
images.edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
|
||||
const normalizeImages = (productNode: ProductNode) => {
|
||||
const output =
|
||||
productNode.images.edges?.map(
|
||||
({ node: { urlOriginal, altText, ...rest } }: any) => ({
|
||||
url: urlOriginal,
|
||||
alt: altText,
|
||||
...rest,
|
||||
})) || [],
|
||||
path: `/${getSlug(path)}`,
|
||||
variants:
|
||||
})
|
||||
) || []
|
||||
|
||||
/**
|
||||
* Add the variants images to the product images, because the variants images are not included in the product images
|
||||
*/
|
||||
productNode.variants.edges?.forEach(({ node: variant }: any) => {
|
||||
if (variant.defaultImage?.urlOriginal) {
|
||||
output.push({
|
||||
url: variant.defaultImage.urlOriginal,
|
||||
alt: variant.defaultImage.altText,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const normalizeVariants = (variants: any) =>
|
||||
variants.edges?.map(
|
||||
({ node: { entityId, productOptions, ...rest } }: any) => ({
|
||||
({
|
||||
node: { entityId, productOptions, prices, defaultImage, ...rest },
|
||||
}: any) => ({
|
||||
id: String(entityId),
|
||||
...(defaultImage && {
|
||||
image: {
|
||||
url: defaultImage.urlOriginal,
|
||||
alt: defaultImage.altText,
|
||||
},
|
||||
}),
|
||||
...(prices && { price: normalizePrice(prices) }),
|
||||
options: productOptions?.edges
|
||||
? productOptions.edges.map(normalizeProductOption)
|
||||
: [],
|
||||
...rest,
|
||||
})
|
||||
) || [],
|
||||
) || []
|
||||
|
||||
export function normalizeProduct(productNode: ProductNode): Product {
|
||||
const { entityId: id, productOptions, prices, path, variants } = productNode
|
||||
|
||||
return {
|
||||
id: String(id),
|
||||
name: productNode.name,
|
||||
description: productNode.description,
|
||||
images: normalizeImages(productNode),
|
||||
path: `/${getSlug(path)}`,
|
||||
variants: normalizeVariants(variants),
|
||||
options: productOptions?.edges?.map(normalizeProductOption) || [],
|
||||
slug: path?.replace(/^\/+|\/+$/g, ''),
|
||||
price: {
|
||||
value: prices?.price.value,
|
||||
currencyCode: prices?.price.currencyCode,
|
||||
},
|
||||
price: normalizePrice(prices),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,7 +44,9 @@ export const transformRequest = (req: NextApiRequest, path: string) => {
|
||||
body = JSON.stringify(req.body)
|
||||
}
|
||||
|
||||
return new NextRequest(`https://${req.headers.host}/api/commerce/${path}`, {
|
||||
const url = new URL(req.url || '/', `https://${req.headers.host}`)
|
||||
|
||||
return new NextRequest(url, {
|
||||
headers,
|
||||
method: req.method,
|
||||
body,
|
||||
|
@ -19,7 +19,9 @@ import type {
|
||||
|
||||
import { colorMap } from './colors'
|
||||
|
||||
const money = ({ amount, currencyCode }: MoneyV2) => {
|
||||
type MoneyProps = MoneyV2 & { retailPrice?: string | number }
|
||||
|
||||
const money = ({ amount, currencyCode }: MoneyProps) => {
|
||||
return {
|
||||
value: +amount,
|
||||
currencyCode,
|
||||
@ -67,6 +69,7 @@ const normalizeProductVariants = ({ edges }: ProductVariantConnection) => {
|
||||
selectedOptions,
|
||||
sku,
|
||||
title,
|
||||
image,
|
||||
priceV2,
|
||||
compareAtPriceV2,
|
||||
requiresShipping,
|
||||
@ -77,7 +80,8 @@ const normalizeProductVariants = ({ edges }: ProductVariantConnection) => {
|
||||
id,
|
||||
name: title,
|
||||
sku,
|
||||
price: +priceV2.amount,
|
||||
image,
|
||||
price: money({ ...priceV2, retailPrice: compareAtPriceV2?.amount }),
|
||||
listPrice: +compareAtPriceV2?.amount,
|
||||
requiresShipping,
|
||||
availableForSale,
|
||||
|
@ -34,6 +34,13 @@ const getProductQuery = /* GraphQL */ `
|
||||
id
|
||||
title
|
||||
sku
|
||||
image {
|
||||
id
|
||||
altText
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
availableForSale
|
||||
requiresShipping
|
||||
selectedOptions {
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
.root:hover {
|
||||
& .productImage {
|
||||
transform: scale(1.2625);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
& .header .name span,
|
||||
@ -47,23 +47,25 @@
|
||||
}
|
||||
|
||||
.header .name {
|
||||
@apply pt-0 max-w-full w-full leading-extra-loose
|
||||
@apply pt-1 max-w-full w-full leading-extra-loose
|
||||
transition-colors ease-in-out duration-500;
|
||||
font-size: 2rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.header .name span {
|
||||
@apply py-4 px-6 bg-primary text-primary font-bold
|
||||
@apply py-2 px-3 bg-primary text-primary font-bold
|
||||
transition-colors ease-in-out duration-500;
|
||||
font-size: inherit;
|
||||
letter-spacing: inherit;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.header .price {
|
||||
@apply pt-2 px-6 pb-4 text-sm bg-primary text-accent-9
|
||||
@apply pt-1.5 pb-2 px-3 text-sm bg-primary text-accent-9
|
||||
font-semibold inline-block tracking-wide
|
||||
transition-colors ease-in-out duration-500;
|
||||
}
|
||||
@ -77,8 +79,7 @@
|
||||
}
|
||||
|
||||
.imageContainer .productImage {
|
||||
@apply transform transition-transform duration-500
|
||||
object-cover scale-120;
|
||||
@apply transform transition-transform duration-500 object-cover;
|
||||
}
|
||||
|
||||
.root .wishlistButton {
|
||||
@ -87,7 +88,7 @@
|
||||
|
||||
/* Variant Simple */
|
||||
.simple .header .name {
|
||||
@apply pt-2 text-lg leading-10 -mt-1;
|
||||
@apply pt-1 text-base leading-6;
|
||||
}
|
||||
|
||||
.simple .header .price {
|
||||
@ -101,11 +102,11 @@
|
||||
}
|
||||
|
||||
.slim .header {
|
||||
@apply absolute inset-0 flex items-center justify-end mr-8 z-20;
|
||||
@apply absolute inset-0 flex text-center items-center justify-end mr-8 z-20;
|
||||
}
|
||||
|
||||
.slim span {
|
||||
@apply bg-accent-9 text-accent-0 inline-block p-3
|
||||
@apply bg-accent-9 text-accent-0 inline-block px-3 py-2.5
|
||||
font-bold text-xl break-words;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,56 @@
|
||||
.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 flex-1 overflow-x-hidden bg-violet items-center justify-center;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
@apply text-center h-full relative flex-shrink-0;
|
||||
}
|
||||
|
||||
.imageContainer > img {
|
||||
@apply mx-auto;
|
||||
}
|
||||
|
||||
.sliderContainer .img {
|
||||
@apply w-full h-full max-h-full object-cover transition duration-500 ease-in-out;
|
||||
}
|
||||
|
||||
.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;
|
||||
min-height: 782px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply col-span-4 py-6;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
max-height: 600px;
|
||||
}
|
||||
}
|
47
site/components/product/ProductDetails/ProductDetails.tsx
Normal file
47
site/components/product/ProductDetails/ProductDetails.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import cn from 'clsx'
|
||||
import { useProduct } from '../context'
|
||||
import { WishlistButton } from '@components/wishlist'
|
||||
import Image from 'next/image'
|
||||
import ProductTag from '../ProductTag'
|
||||
import ProductSlider from '../ProductSlider'
|
||||
import ProductSidebar from '../ProductSidebar'
|
||||
|
||||
import s from './ProductDetails.module.css'
|
||||
|
||||
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
|
||||
src={image.url!}
|
||||
alt={image.alt || 'Product Image'}
|
||||
width={600}
|
||||
height={600}
|
||||
quality="85"
|
||||
priority={i === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ProductSlider>
|
||||
</div>
|
||||
{process.env.COMMERCE_WISHLIST_ENABLED && variant && (
|
||||
<WishlistButton
|
||||
className={s.wishlistButton}
|
||||
productId={product.id}
|
||||
variant={variant}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ProductSidebar key={product.id} className={s.sidebar} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductDetails
|
1
site/components/product/ProductDetails/index.ts
Normal file
1
site/components/product/ProductDetails/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ProductDetails'
|
@ -1,27 +1,18 @@
|
||||
import { memo } from 'react'
|
||||
import { Swatch } from '@components/product'
|
||||
import type { ProductOption } from '@commerce/types/product'
|
||||
import { SelectedOptions } from '../helpers'
|
||||
import { useProduct } from '../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}
|
||||
</h2>
|
||||
<div role="listbox" className="flex flex-row py-4">
|
||||
<div role="listbox" className="flex flex-row flex-wrap gap-3 py-4">
|
||||
{opt.values.map((v, i: number) => {
|
||||
const active = selectedOptions[opt.displayName.toLowerCase()]
|
||||
return (
|
||||
|
@ -1,63 +1,49 @@
|
||||
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 ErrorMessage from '@components/ui/ErrorMessage'
|
||||
import { useProduct } from '../context'
|
||||
|
||||
interface ProductSidebarProps {
|
||||
product: Product
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
|
||||
const ProductSidebar: FC<ProductSidebarProps> = ({ className }) => {
|
||||
const addItem = useAddItem()
|
||||
|
||||
const { product, variant, price } = useProduct()
|
||||
const { openSidebar, setSidebarView } = useUI()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<null | Error>(null)
|
||||
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
selectDefaultOptionFromProduct(product, setSelectedOptions)
|
||||
}, [product])
|
||||
|
||||
const variant = getProductVariant(product, selectedOptions)
|
||||
const addToCart = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (variant) {
|
||||
await addItem({
|
||||
productId: String(product.id),
|
||||
variantId: String(variant ? variant.id : product.variants[0]?.id),
|
||||
variantId: String(variant.id),
|
||||
})
|
||||
setSidebarView('CART_VIEW')
|
||||
openSidebar()
|
||||
} else {
|
||||
throw new Error('The variant selected is not available')
|
||||
}
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setLoading(false)
|
||||
if (err instanceof Error) {
|
||||
console.error(err)
|
||||
setError({
|
||||
...err,
|
||||
message: 'Could not add item to cart. Please try again.',
|
||||
})
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
@ -67,7 +53,6 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
|
||||
<div className="text-accent-6 pr-1 font-medium text-sm">36 reviews</div>
|
||||
</div>
|
||||
<div>
|
||||
{error && <ErrorMessage error={error} className="my-5" />}
|
||||
{process.env.COMMERCE_CART_ENABLED && (
|
||||
<Button
|
||||
aria-label="Add to Cart"
|
||||
@ -75,13 +60,19 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
|
||||
className={s.button}
|
||||
onClick={addToCart}
|
||||
loading={loading}
|
||||
disabled={variant?.availableForSale === false}
|
||||
disabled={loading || !variant || variant.availableForSale === false}
|
||||
>
|
||||
{variant?.availableForSale === false
|
||||
? 'Not Available'
|
||||
: 'Add To Cart'}
|
||||
{!variant || variant.availableForSale === false ? (
|
||||
'Not Available'
|
||||
) : (
|
||||
<>Add To Cart - {price}</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red py-3 text-sm font-bold">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Collapse title="Care">
|
||||
|
@ -1,29 +1,24 @@
|
||||
.root {
|
||||
@apply relative w-full h-full select-none;
|
||||
@apply flex flex-col relative select-none w-full;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slider {
|
||||
@apply relative h-full transition-opacity duration-150;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slider.show {
|
||||
opacity: 1;
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
@apply overflow-hidden inline-block cursor-pointer h-full;
|
||||
@apply overflow-hidden inline-block cursor-pointer h-full transition duration-300 md:hover:scale-105;
|
||||
width: 125px;
|
||||
width: calc(100% / 3);
|
||||
}
|
||||
|
||||
.thumb.selected {
|
||||
@apply bg-white;
|
||||
@apply bg-white/30;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
height: 85% !important;
|
||||
@apply w-full h-full max-h-full object-cover;
|
||||
}
|
||||
|
||||
.album {
|
||||
@ -44,10 +39,6 @@
|
||||
}
|
||||
|
||||
@screen md {
|
||||
.thumb:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.album {
|
||||
height: 182px;
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ import cn from 'clsx'
|
||||
import { a } from '@react-spring/web'
|
||||
import s from './ProductSlider.module.css'
|
||||
import ProductSliderControl from '../ProductSliderControl'
|
||||
import { useProduct } from '../context'
|
||||
import { Image as ProductImage } from '@commerce/types/common'
|
||||
|
||||
interface ProductSliderProps {
|
||||
children?: React.ReactNode[]
|
||||
@ -20,19 +22,18 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
const { product, variant } = useProduct()
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
const sliderContainerRef = useRef<HTMLDivElement>(null)
|
||||
const thumbsContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [ref, slider] = useKeenSlider<HTMLDivElement>({
|
||||
loop: true,
|
||||
slides: { perView: 1 },
|
||||
created: () => setIsMounted(true),
|
||||
slideChanged(s) {
|
||||
const slideNumber = s.track.details.rel
|
||||
setCurrentSlide(slideNumber)
|
||||
|
||||
if (thumbsContainerRef.current) {
|
||||
const $el = document.getElementById(`thumb-${slideNumber}`)
|
||||
if (slideNumber >= 3) {
|
||||
@ -44,6 +45,18 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const index = product.images.findIndex((image: ProductImage) => {
|
||||
return image.url === variant?.image?.url
|
||||
})
|
||||
|
||||
if (index !== -1) {
|
||||
slider.current?.moveToIdx(index, false, {
|
||||
duration: 0,
|
||||
})
|
||||
}
|
||||
}, [variant, product, slider])
|
||||
|
||||
// Stop the history navigation gesture on touch devices
|
||||
useEffect(() => {
|
||||
const preventNavigation = (event: TouchEvent) => {
|
||||
@ -74,15 +87,17 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPrev = React.useCallback(() => slider.current?.prev(), [slider])
|
||||
const onNext = React.useCallback(() => slider.current?.next(), [slider])
|
||||
const onPrev = React.useCallback(() => {
|
||||
slider.current?.prev()
|
||||
}, [slider])
|
||||
|
||||
const onNext = React.useCallback(() => {
|
||||
slider.current?.next()
|
||||
}, [slider])
|
||||
|
||||
return (
|
||||
<div className={cn(s.root, className)} ref={sliderContainerRef}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(s.slider, { [s.show]: isMounted }, 'keen-slider')}
|
||||
>
|
||||
<div ref={ref} className={cn(s.slider, 'keen-slider')}>
|
||||
{slider && <ProductSliderControl onPrev={onPrev} onNext={onNext} />}
|
||||
{Children.map(children, (child) => {
|
||||
// Add the keen-slider__slide className to children
|
||||
@ -109,6 +124,8 @@ const ProductSlider: React.FC<ProductSliderProps> = ({
|
||||
...child,
|
||||
props: {
|
||||
...child.props,
|
||||
width: 132,
|
||||
height: 82,
|
||||
className: cn(child.props.className, s.thumb, {
|
||||
[s.selected]: currentSlide === idx,
|
||||
}),
|
||||
|
@ -4,10 +4,10 @@
|
||||
}
|
||||
|
||||
.root .name {
|
||||
@apply pt-0 max-w-full w-full leading-extra-loose;
|
||||
@apply pt-0 max-w-full w-full;
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.4px;
|
||||
line-height: 2.1em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.root .name span {
|
||||
|
@ -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;
|
||||
}
|
||||
@apply grid grid-cols-2 py-2 gap-2 md:grid-cols-4 md:gap-7 m-0;
|
||||
}
|
||||
|
@ -1,88 +1,44 @@
|
||||
import cn from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import s from './ProductView.module.css'
|
||||
import { FC } from 'react'
|
||||
|
||||
import type { 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 { Container, Text } from '@components/ui'
|
||||
|
||||
import { SEO } from '@components/common'
|
||||
import ProductSidebar from '../ProductSidebar'
|
||||
import ProductTag from '../ProductTag'
|
||||
import { ProductProvider } from '../context'
|
||||
import { Container, Text } from '@components/ui'
|
||||
import { ProductCard } from '@components/product'
|
||||
|
||||
import ProductDetails from '../ProductDetails/ProductDetails'
|
||||
|
||||
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>
|
||||
<ProductProvider product={product}>
|
||||
<ProductDetails />
|
||||
</ProductProvider>
|
||||
|
||||
<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">
|
||||
<Text variant="sectionHeading">Related Products</Text>
|
||||
<div className={s.relatedProductsGrid}>
|
||||
{relatedProducts.map((p) => (
|
||||
<div
|
||||
key={p.path}
|
||||
className="animated fadeIn bg-accent-0 border border-accent-2"
|
||||
className="bg-accent-0 border border-accent-2 flex flex-1 h-full"
|
||||
>
|
||||
<ProductCard
|
||||
className="animated fadeIn"
|
||||
noNameTag
|
||||
product={p}
|
||||
key={p.path}
|
||||
variant="simple"
|
||||
className="animated fadeIn"
|
||||
imgProps={{
|
||||
width: 300,
|
||||
height: 300,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -99,7 +55,7 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
|
||||
images: [
|
||||
{
|
||||
url: product.images[0]?.url!,
|
||||
width: '800',
|
||||
width: '600',
|
||||
height: '600',
|
||||
alt: product.name,
|
||||
},
|
||||
|
@ -1,9 +1,9 @@
|
||||
.swatch {
|
||||
box-sizing: border-box;
|
||||
composes: root from '@components/ui/Button/Button.module.css';
|
||||
@apply h-10 w-10 bg-primary text-primary rounded-full mr-3 inline-flex
|
||||
@apply h-10 w-10 bg-primary text-primary rounded-full inline-flex
|
||||
items-center justify-center cursor-pointer transition duration-150 ease-in-out
|
||||
p-0 shadow-none border-accent-3 border box-border select-none;
|
||||
p-0 shadow-none border-accent-3 border box-border select-none !mr-0;
|
||||
margin-right: calc(0.75rem - 1px);
|
||||
overflow: hidden;
|
||||
width: 48px;
|
||||
|
@ -18,7 +18,7 @@ const Swatch: React.FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
|
||||
className,
|
||||
color = '',
|
||||
label = null,
|
||||
variant = 'size',
|
||||
variant,
|
||||
...props
|
||||
}) => {
|
||||
variant = variant?.toLowerCase()
|
||||
|
71
site/components/product/context.tsx
Normal file
71
site/components/product/context.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useMemo, useState, useEffect, useContext, createContext } from 'react'
|
||||
|
||||
import type { FC, ReactNode, Dispatch, SetStateAction } from 'react'
|
||||
import type { SelectedOptions } from './helpers'
|
||||
import type { Product, ProductVariant } from '@commerce/types/product'
|
||||
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import { getProductVariant, selectDefaultOptionFromProduct } from './helpers'
|
||||
|
||||
export interface ProductContextValue {
|
||||
product: Product
|
||||
price: string
|
||||
variant?: ProductVariant
|
||||
selectedOptions: SelectedOptions
|
||||
setSelectedOptions: Dispatch<SetStateAction<SelectedOptions>>
|
||||
}
|
||||
|
||||
export const ProductContext = 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>({})
|
||||
|
||||
useEffect(
|
||||
() => selectDefaultOptionFromProduct(product, setSelectedOptions),
|
||||
[product]
|
||||
)
|
||||
|
||||
const variant = useMemo(
|
||||
() => getProductVariant(product, selectedOptions),
|
||||
[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!,
|
||||
})
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
price,
|
||||
product,
|
||||
variant,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
}),
|
||||
[price, product, selectedOptions, variant]
|
||||
)
|
||||
|
||||
return (
|
||||
<ProductContext.Provider value={value}>{children}</ProductContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useProduct = () => {
|
||||
const context = useContext(ProductContext) as ProductContextValue
|
||||
if (context === undefined) {
|
||||
throw new Error(`useProduct must be used within a ProductProvider`)
|
||||
}
|
||||
return context
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import type { Product } from '@commerce/types/product'
|
||||
import type { Product } from '@vercel/commerce/types/product'
|
||||
export type SelectedOptions = Record<string, string | null>
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
@ -22,11 +22,19 @@ export function selectDefaultOptionFromProduct(
|
||||
product: Product,
|
||||
updater: Dispatch<SetStateAction<SelectedOptions>>
|
||||
) {
|
||||
// Selects the default option
|
||||
product.variants[0]?.options?.forEach((v) => {
|
||||
updater((choices) => ({
|
||||
...choices,
|
||||
[v.displayName.toLowerCase()]: v.values[0].label.toLowerCase(),
|
||||
}))
|
||||
})
|
||||
// Get the first available option or the first option
|
||||
const variant =
|
||||
product.variants.find((variant) => variant.availableForSale) ||
|
||||
product.variants[0]
|
||||
|
||||
// Reset the selectedOptions and set the default option from the available variant
|
||||
const newValue: SelectedOptions = {}
|
||||
|
||||
if (variant) {
|
||||
for (const c of variant.options) {
|
||||
newValue[c.displayName.toLowerCase()] = c.values[0].label.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
updater(newValue)
|
||||
}
|
||||
|
@ -273,7 +273,7 @@ export default function Search({ categories, brands }: SearchPropsType) {
|
||||
{/* Products */}
|
||||
<div className="col-span-8 order-3 lg:order-none">
|
||||
{(q || activeCategory || activeBrand) && (
|
||||
<div className="mb-12 transition ease-in duration-75">
|
||||
<div className="mt-4">
|
||||
{data ? (
|
||||
<>
|
||||
<span
|
||||
@ -316,7 +316,7 @@ export default function Search({ categories, brands }: SearchPropsType) {
|
||||
</div>
|
||||
)}
|
||||
{data ? (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
{data.products.map((product: Product) => (
|
||||
<ProductCard
|
||||
variant="simple"
|
||||
@ -331,7 +331,7 @@ export default function Search({ categories, brands }: SearchPropsType) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
{rangeMap(12, (i) => (
|
||||
<Skeleton key={i}>
|
||||
<div className="w-60 h-60" />
|
||||
|
@ -14,6 +14,7 @@ export async function getStaticProps({
|
||||
locales,
|
||||
preview,
|
||||
}: GetStaticPropsContext<{ slug: string }>) {
|
||||
try {
|
||||
const config = { locale, locales }
|
||||
const pagesPromise = commerce.getAllPages({ config, preview })
|
||||
const siteInfoPromise = commerce.getSiteInfo({ config, preview })
|
||||
@ -46,6 +47,12 @@ export async function getStaticProps({
|
||||
},
|
||||
revalidate: 200,
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticPaths({ locales }: GetStaticPathsContext) {
|
||||
|
@ -23,8 +23,8 @@
|
||||
"@components/*": ["components/*"],
|
||||
"@commerce": ["../packages/commerce/src"],
|
||||
"@commerce/*": ["../packages/commerce/src/*"],
|
||||
"@framework": ["../packages/local/src"],
|
||||
"@framework/*": ["../packages/local/src/*"]
|
||||
"@framework": ["../packages/shopify/src"],
|
||||
"@framework/*": ["../packages/shopify/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user