123 lines
3.5 KiB
TypeScript
123 lines
3.5 KiB
TypeScript
import type { Metadata } from 'next';
|
|
import { notFound } from 'next/navigation';
|
|
|
|
import { BackButton } from '@/components/product/BackButton';
|
|
import { ProductDescription } from '@/components/product/ProductDescription';
|
|
import { container } from '@/lib/utils';
|
|
import { ProductProvider } from 'components/product/product-context';
|
|
import { ProductDetailsSection } from 'components/product/ProductDetailsSection';
|
|
import { ProductGallery } from 'components/product/ProductGallery';
|
|
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
|
import { getProduct } from 'lib/shopify';
|
|
import { Suspense } from 'react';
|
|
|
|
export async function generateMetadata(props: {
|
|
params: Promise<{ handle: string }>;
|
|
}): Promise<Metadata> {
|
|
const params = await props.params;
|
|
const product = await getProduct(params.handle);
|
|
|
|
if (!product) return notFound();
|
|
|
|
const { url, width, height, altText: alt } = product.featuredImage || {};
|
|
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
|
|
|
return {
|
|
title: product.seo.title || product.title,
|
|
description: product.seo.description || product.description,
|
|
robots: {
|
|
index: indexable,
|
|
follow: indexable,
|
|
googleBot: {
|
|
index: indexable,
|
|
follow: indexable
|
|
}
|
|
},
|
|
openGraph: url
|
|
? {
|
|
images: [
|
|
{
|
|
url,
|
|
width,
|
|
height,
|
|
alt
|
|
}
|
|
]
|
|
}
|
|
: null
|
|
};
|
|
}
|
|
|
|
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
|
|
const params = await props.params;
|
|
const product = await getProduct(params.handle);
|
|
|
|
if (!product) return notFound();
|
|
|
|
const productJsonLd = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Product',
|
|
name: product.title,
|
|
description: product.description,
|
|
image: product.featuredImage.url,
|
|
offers: {
|
|
'@type': 'AggregateOffer',
|
|
availability: product.availableForSale
|
|
? 'https://schema.org/InStock'
|
|
: 'https://schema.org/OutOfStock',
|
|
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
|
|
highPrice: product.priceRange.maxVariantPrice.amount,
|
|
lowPrice: product.priceRange.minVariantPrice.amount
|
|
}
|
|
};
|
|
|
|
// If product has only one image, duplicate it to create the gallery effect
|
|
const galleryImages = product.images.length === 1
|
|
? Array(5).fill(product.images[0]).map(image => ({
|
|
url: image.url,
|
|
altText: image.altText,
|
|
width: image.width,
|
|
height: image.height
|
|
}))
|
|
: product.images;
|
|
|
|
return (
|
|
<ProductProvider>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{
|
|
__html: JSON.stringify(productJsonLd)
|
|
}}
|
|
/>
|
|
<div className="py-4">
|
|
<div className={container}>
|
|
<BackButton />
|
|
</div>
|
|
</div>
|
|
|
|
<div className={container}>
|
|
<div className="flex flex-col md:flex-row lg:gap-8">
|
|
<div className="w-full lg:w-3/5">
|
|
<Suspense
|
|
fallback={
|
|
<div className="relative aspect-square h-full max-h-[600px] w-full overflow-hidden" />
|
|
}
|
|
>
|
|
<ProductGallery
|
|
images={galleryImages.slice(0, 5)}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
|
|
<div className="w-full lg:w-2/5 mt-8 lg:mt-0">
|
|
<Suspense fallback={null}>
|
|
<ProductDescription product={product} />
|
|
<ProductDetailsSection />
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ProductProvider>
|
|
);
|
|
}
|