Mostrando "custom attributes" en pƔgina de producto

Introducción

En la pĆ”gina de producto es bastante frecuente que necesitemos mostrar el valor de algĆŗn ā€œcustom attributeā€ en algĆŗn lugar especĆ­fico.

Si vemos el getStaticProps de la pÔgina de producto, veremos que de prepararProductPageData obtenemos productData y productAdditionalInformation, esta última constante contiene todos los atributos visibles del producto y que se mostrarÔn como información adicional.

node_modules/@mercury/theme/src/pages/product/server.ts
...
export const getStaticProps = serviceMiddleware(async ({ params }) => {
  const { urlKey } = params
  try {
    const storeConfig = await getStoreConfig()
    const [, productData, productAdditionalInformation] = await prepareProductPageData({ storeConfig, urlKey })

    return {
      props: {
        productData,
        productAdditionalInformation
      }
    }
  } catch (e) {
    console.log(e)
    return {
      notFound: true
    }
  }
})
...

DespuƩs de obtener productAdditionalInformation podemos obtener el atributo que queramos mostrar en alguna otra parte de la pƔgina de producto y monerlo en el producData para poderlo usar posteriormente en la pƔgina.

Mostrar el atributo ā€œmanufacturerā€ encima del nombre del producto.

1. Sobreescribir la pƔgina de producto de nuestro proyecto

Lo primero que habrÔ que hacer, si aún no estÔ hecho, es sobreescribir la pÔgina de producto de nuestro proyecto. Como deberemos sobreescribir la parte servidor y cliente de la pÔgina de producto, deberemos copiar el código de los siguientes ficheros a nuestra pÔgina:

  • node_modules/@mercury/theme/src/pages/product/server.ts
  • node_modules/@mercury/theme/src/pages/product/client.tsx

El resultado:

mercury/src/pages/product/[urlKey].tsx
import { setApolloStoreCode, getProductUrlKeys, getStoreConfig } from '@mercury/service-adobe-commerce'
import { prepareProductPageData, serviceMiddleware } from '@mercury/service-adobe-commerce/ssr'
import { useContext, useEffect, useRef, useState } from 'react'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Accordion, AmountSelector, Breadcrumb, HomeIcon } from '@mercury/ui'
import { Price, Gallery, AddToCart, ProductCarousel, ProductVariants, ProductBundleOptions, ProductTitle, AddToWishlist, AdditionalInformation } from '@mercury/theme/components'
import { ProductPricesContext, useProduct, useProductPrice } from '@mercury/service-adobe-commerce'
import { useI18n } from '@mercury/i18n'
import type { ProductType } from '@mercury/models'

export const getStaticProps = serviceMiddleware(async ({ params }) => {
  const { urlKey } = params
  try {
    const storeConfig = await getStoreConfig()
    const [, productData, productAdditionalInformation] = await prepareProductPageData({ storeConfig, urlKey })

    return {
      props: {
        productData,
        productAdditionalInformation
      }
    }
  } catch (e) {
   console.log(e)
   return {
     notFound: true
   }
 }
})

async function getPathsForLocale (locale) {
 setApolloStoreCode(locale)
  const allProducts = await getProductUrlKeys({ search: '' })

  return allProducts.map(urlKey => ({
    params: {
      urlKey,
      locale
    }
  }))
}

export async function getStaticPaths ({ locales }) {
  const paths = []

  for (const locale of locales) {
    const localePaths = await getPathsForLocale(locale)
    paths.push(...localePaths)
  }

  return {
    paths,
    fallback: 'blocking'
  }
}

export function Page ({ storeConfig, productData, productAdditionalInformation }) {
  const { t } = useI18n()
  const router = useRouter()
  const { query } = router
  const { urlKey, added = false } = query

  const { product: data, loading, error } = useProduct({ urlKey })
  const [product, setProduct] = useState<ProductType>(null)
  const { loadPrices } = useContext(ProductPricesContext)
  const { price, loading: loadingPrice } = useProductPrice({ sku: product?.sku })
  const [selectedOptionsUids, setSelectedOptionsUids] = useState<Array<string>>(null)

  const addToCartRef = useRef<HTMLButtonElement>(null)
  const { defaultTitle, defaultKeywords, defaultRobots } = storeConfig
  const { name: productName, metaTitle, metaDescription, metaKeywords } = productData as ProductType

  useEffect(() => {
    if (data) {
      setProduct(data)
    }
  }, [data])

  useEffect(() => {
    if (!product?.sku) return

    loadPrices([product.sku])
  }, [product, loadPrices])

  const handleVariantChange = (selectedOptions, selectedVariantProduct) => {
    if (selectedVariantProduct) {
      const updateAttributes = {
        sku: selectedOptions.length === productData.variants.length ? selectedVariantProduct.sku : productData.sku,
        name: selectedVariantProduct.name,
        mediaGallery: selectedVariantProduct.mediaGallery.length > 0 ? selectedVariantProduct.mediaGallery : productData.mediaGallery,
        price: selectedVariantProduct.price
      }
      setProduct({ ...product, ...updateAttributes })
    }
  }

  const handleBundleOptionChange = (selectedOptions) => {
    if (selectedOptions && selectedOptions.length > 0) {
      const priceIncrement = selectedOptions.reduce((prev, curr) => (prev?.item ? prev.item.priceIncrement : prev) + curr?.item.priceIncrement, 0)
      const updateAttributes = {
        price: {
          regularPrice: productData.price.regularPrice,
          finalPrice: productData.price.finalPrice + priceIncrement
        }
      }
      const selectedOptionsUids = selectedOptions.map(option => option.item.uid)
      setSelectedOptionsUids(selectedOptionsUids)
      setProduct({ ...product, ...updateAttributes })
    }
  }

  const [quantity, setQuantity] = useState<number | string>(1)

  useEffect(() => {
    if (!added || !product?.sku) return

    // remove added query
    delete router.query.added
    router.replace({
      pathname: router.pathname,
      query: router.query
    }, undefined, { shallow: true })

    addToCartRef.current.click()
  }, [added, product, router])

  if (loading || error || !product) return null

  return (<>
    <Head>
      <title>{metaTitle || productName || defaultTitle}</title>
      { metaDescription && <meta name="description" content={metaDescription} /> }
      <meta name="keywords" content={metaKeywords || defaultKeywords} />
      <meta name="robots" content={defaultRobots} />
      <link rel="icon" href="/favicon.ico" />
    </Head>

    <section className='pt-8 pb-10'>

      <Breadcrumb>
        <Link href="/"> <HomeIcon className="w-4 h-4" variant="light" /> </Link>
        {product.categories.map(({ uid, urlPath, name }) =>
          <Link key={uid} href={`/${urlPath}`}> {name} </Link>
        )}
      </Breadcrumb>

      <div className='flex flex-col gap-8 lg:flex-row'>

        <div className='block md:hidden'>
          <ProductTitle name={product.name} price={price ?? product.price} loading={loadingPrice} />
        </div>

        <Gallery enableMaximized={true} images={product.mediaGallery} />

        <div className='min-w-[40%]'>
          <div className='p-8 bg-white'>
            <div className='hidden md:block'>
              <ProductTitle name={product.name} price={price ?? product.price} loading={loadingPrice} />
            </div>

            {/* Bundle Options */}
            {product.bundleOptions?.length > 0 &&
              <div className='md:w-full md:max-w-[420px] mt-6 py-2 px-0 border border-white'>
                <ProductBundleOptions options={product.bundleOptions} onChange={handleBundleOptionChange}/>
              </div>
            }

            <div className='mb-6'>
              <Price price={price?.finalPrice} regularPrice={price?.regularPrice} loading={loadingPrice} />
            </div>

            {/* Variants */}
            {product.variants?.length > 0 &&
              <div className='md:w-full md:max-w-[330px] mt-6 py-2 px-0 border border-white'>
                <ProductVariants options={product.variants} onChange={handleVariantChange}/>
              </div>
            }

            {/* Product Actions */}
            <div className='flex flex-col gap-2 my-6 md:flex-row md:items-center'>
              <AmountSelector className='max-w-[120px] md:w-full md:max-w-[165px] h-[40px]' amount={quantity} setAmount={setQuantity}/>
              <AddToCart buttonSize='md' borderRadius='lg' className='h-[40px]' ref={addToCartRef} productToAdd={product} parentSku={productData.sku} selectedOptions={selectedOptionsUids} quantity={Number(quantity)}>
                {t('cart.add')}
              </AddToCart>
            </div>

            <div className='pt-4 border-t border-neutral-200'>
              <AddToWishlist product={product}/>
            </div>

          </div>
        </div>
      </div>

      <div className='flex flex-col px-4 mt-6 bg-white'>
        {/* Product description */}
        <Accordion title={t('productPage.productInfo')} defaultOpen={true} className='px-2 py-2 text-xl font-bold leading-5 text-left border-b h-14 hover:text-neutral-700 focus:outline-none'>
          <div className='px-3 py-6 text-md text-neutral-800'>
            <p className='text-md text-neutral-800 product-description' dangerouslySetInnerHTML={{ __html: product.description }}></p>
          </div>
        </Accordion>

        {/* Additional information */}
        <Accordion title={t('productPage.productExtraInfo')} className='px-2 py-2 text-xl font-bold leading-5 text-left border-b h-14 hover:text-neutral-700 focus:outline-none'>
          <div className='px-3 py-6 text-md text-neutral-800'>
            <AdditionalInformation additionalInformation={ productAdditionalInformation} />
          </div>
        </Accordion>
      </div>

      {/* Related products */}
      {
        product.relatedProducts.length > 0 &&
        <div className='mt-16'>
          <p className='px-2 pt-2 pb-4 mb-6 text-xl font-bold leading-5 text-left uppercase border-b'>
            {t('productPage.relatedProducts')}
          </p>
          <ProductCarousel sku={product.relatedProducts} />
        </div>
      }

      {/* Upsell products */}
      {
        product.upsellProducts.length > 0 &&
        <div className='mt-16'>
          <p className='px-2 pt-2 pb-4 mb-6 text-xl font-bold leading-5 text-left uppercase border-b'>
            {t('productPage.upsellProducts')}
          </p>
          <ProductCarousel sku={product.upsellProducts} />
        </div>
      }
    </section>
  </>)
}

2. AƱadir ā€œmanufacturerā€ al productData

Una vez tengamos la pĆ”gina de producto sobreescrita en nuestro proyecto deberemos modificar el mĆ©todo getStaticProps para obtener ā€œmanufacturerā€ de productAdditionalInformation y aƱadirlo al productData

mercury/src/pages/product/[urlKey].tsx
...
export const getStaticProps = serviceMiddleware(async ({ params }) => {
  const { urlKey } = params
  try {
    const storeConfig = await getStoreConfig()
    const [, productData, productAdditionalInformation] = await prepareProductPageData({ storeConfig, urlKey })

+    // AƱadimos un mƩtodo para obtener el label del valor del atributo
+    const getCustomAttribute = (productAdditionalInformation, code) => {
+      const attr = productAdditionalInformation.find(attr => attr.code === code)
+      if (!attr) return null
+
+      const option = attr.attribute.options.find(opt => parseInt(opt.value) === parseInt(attr.value))
+      if (!option) return null
+
+      return option.label
+    }
+
+    // AƱadimos al productData el atributo que necesitamos
+    productData.manufacturer = getCustomAttribute(productAdditionalInformation, 'manufacturer')

    return {
      props: {
        productData,
        productAdditionalInformation
      }
    }
  } catch (e) {
    console.log(e)
    return {
      notFound: true
    }
  }
})
...

3. AƱadir atributo a la pƔgina

Ahora que ya tenemos el atributo en el productData lo podmos mostrar en el lugar deseado de la pƔgina de producto. En nuestro caso lo aƱadimos debajo del nombre.

mercury/src/pages/product/[urlKey].tsx
...
productData as ProductType
...
  return (<>
    <Head>
      <title>{metaTitle || productName || defaultTitle}</title>
      { metaDescription && <meta name="description" content={metaDescription} /> }
      <meta name="keywords" content={metaKeywords || defaultKeywords} />
      <meta name="robots" content={defaultRobots} />
      <link rel="icon" href="/favicon.ico" />
    </Head>

    <section className='pt-8 pb-10'>

      <Breadcrumb>
        <Link href="/"> <HomeIcon className="w-4 h-4" variant="light" /> </Link>
        {product.categories.map(({ uid, urlPath, name }) =>
          <Link key={uid} href={`/${urlPath}`}> {name} </Link>
        )}
      </Breadcrumb>

      <div className='flex flex-col gap-8 lg:flex-row'>

        <div className='block md:hidden'>
+          {productData?.manufacturer &&
+            <Typography htmlTag='span' variant='small' weight='400' customClass='mb-4 line-clamp-3'>
+                {productData.manufacturer}
+            </Typography>
+          }
          <Typography className=''>{name}</Typography>
          <ProductTitle name={product.name} price={price ?? product.price} loading={loadingPrice} />
        </div>

        <Gallery enableMaximized={true} images={product.mediaGallery} />

        <div className='min-w-[40%]'>
          <div className='p-8 bg-white'>
            <div className='hidden md:block'>
+             {productData?.manufacturer &&
+               <Typography htmlTag='span' variant='small' weight='400' customClass='mb-4 line-clamp-3'>
+                 {productData.manufacturer}
+               </Typography>
+             }
              <ProductTitle name={product.name} price={price ?? product.price} loading={loadingPrice} />
            </div>

            {/* Bundle Options */}
            {product.bundleOptions?.length > 0 &&
              <div className='md:w-full md:max-w-[420px] mt-6 py-2 px-0 border border-white'>
                <ProductBundleOptions options={product.bundleOptions} onChange={handleBundleOptionChange}/>
              </div>
            }

            <div className='mb-6'>
              <Price price={price?.finalPrice} regularPrice={price?.regularPrice} loading={loadingPrice} />
            </div>

            {/* Variants */}
            {product.variants?.length > 0 &&
              <div className='md:w-full md:max-w-[330px] mt-6 py-2 px-0 border border-white'>
                <ProductVariants options={product.variants} onChange={handleVariantChange}/>
              </div>
            }

            {/* Product Actions */}
            <div className='flex flex-col gap-2 my-6 md:flex-row md:items-center'>
              <AmountSelector className='max-w-[120px] md:w-full md:max-w-[165px] h-[40px]' amount={quantity} setAmount={setQuantity}/>
              <AddToCart buttonSize='md' borderRadius='lg' className='h-[40px]' ref={addToCartRef} productToAdd={product} parentSku={productData.sku} selectedOptions={selectedOptionsUids} quantity={Number(quantity)}>
                {t('cart.add')}
              </AddToCart>
            </div>

            <div className='pt-4 border-t border-neutral-200'>
              <AddToWishlist product={product}/>
            </div>

          </div>
        </div>
      </div>

      <div className='flex flex-col px-4 mt-6 bg-white'>
        {/* Product description */}
        <Accordion title={t('productPage.productInfo')} defaultOpen={true} className='px-2 py-2 text-xl font-bold leading-5 text-left border-b h-14 hover:text-neutral-700 focus:outline-none'>
          <div className='px-3 py-6 text-md text-neutral-800'>
            <p className='text-md text-neutral-800 product-description' dangerouslySetInnerHTML={{ __html: product.description }}></p>
          </div>
        </Accordion>

        {/* Additional information */}
        <Accordion title={t('productPage.productExtraInfo')} className='px-2 py-2 text-xl font-bold leading-5 text-left border-b h-14 hover:text-neutral-700 focus:outline-none'>
          <div className='px-3 py-6 text-md text-neutral-800'>
            <AdditionalInformation additionalInformation={ productAdditionalInformation} />
          </div>
        </Accordion>
      </div>

      {/* Related products */}
      {
        product.relatedProducts.length > 0 &&
        <div className='mt-16'>
          <p className='px-2 pt-2 pb-4 mb-6 text-xl font-bold leading-5 text-left uppercase border-b'>
            {t('productPage.relatedProducts')}
          </p>
          <ProductCarousel sku={product.relatedProducts} />
        </div>
      }

      {/* Upsell products */}
      {
        product.upsellProducts.length > 0 &&
        <div className='mt-16'>
          <p className='px-2 pt-2 pb-4 mb-6 text-xl font-bold leading-5 text-left uppercase border-b'>
            {t('productPage.upsellProducts')}
          </p>
          <ProductCarousel sku={product.upsellProducts} />
        </div>
      }
    </section>
  </>)
}