Files
dashboards/apps/business/src/pages/ProductDetailsPage.tsx
T

239 lines
7.4 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox'
import { ProductDetailsTabs } from '../components/ProductDetailsTabs'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
formatProductCategoryChip,
getProduct,
mapProductApiToUi,
} from '../services/productService'
import type { Product } from '../types/product'
import { textLocaleAttrs } from '../utils/textLocale'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductDetailsPage.module.css'
export function ProductDetailsPage() {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const { id } = useParams()
const [product, setProduct] = useState<Product | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function loadProduct() {
setIsLoading(true)
setError('')
try {
const data = await getProduct(id!, controller.signal)
setProduct(mapProductApiToUi(data))
setActiveIndex(0)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('products.details.errorLoad'))
}
setProduct(null)
} finally {
setIsLoading(false)
}
}
void loadProduct()
return () => controller.abort()
}, [id, t])
const galleryImages = useMemo(() => product?.images ?? [], [product])
const currentImage = galleryImages[activeIndex] ?? galleryImages[0] ?? ''
function openLightbox(index: number) {
setLightboxIndex(index)
setLightboxOpen(true)
}
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('products.details.loading')}</p>
</main>
)
}
if (error || !product) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || t('products.details.notFound')}</p>
<Link to="/products/list" className={styles.backLink}>
{t('products.details.back')}
</Link>
</main>
)
}
const primaryName = isFa
? product.nameFa?.trim() || product.nameEn
: product.nameEn?.trim() || product.nameFa
const secondaryName = isFa
? product.nameEn?.trim() && product.nameEn !== primaryName
? product.nameEn
: ''
: product.nameFa?.trim() && product.nameFa !== primaryName
? product.nameFa
: ''
const primaryLocale = textLocaleAttrs(primaryName)
const secondaryLocale = textLocaleAttrs(secondaryName)
const summaryLocale = textLocaleAttrs(product.summary)
const descriptionLocale = textLocaleAttrs(
product.description?.replace(/<[^>]+>/g, ' ') ?? '',
)
const categoryLabel = formatProductCategoryChip(product, isFa)
const categoryLocale = textLocaleAttrs(categoryLabel)
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'My Products', href: '/products/list' },
{ label: primaryName },
]}
/>
<div className={styles.layout}>
<div className={styles.gallery}>
<button
type="button"
className={styles.mainImage}
onClick={() => currentImage && openLightbox(activeIndex)}
aria-label={t('products.details.openGallery')}
disabled={!currentImage}
>
{currentImage ? (
<img src={currentImage} alt={primaryName} />
) : (
<div className={styles.imagePlaceholder}>{t('products.details.noImage')}</div>
)}
{product.status === 'draft' && (
<span className={styles.draftBadge}>{t('products.card.draft')}</span>
)}
</button>
{galleryImages.length > 1 && (
<div className={styles.thumbnails}>
{galleryImages.map((src, index) => (
<button
key={`${src}-${index}`}
type="button"
className={`${styles.thumb} ${index === activeIndex ? styles.thumbActive : ''}`}
onClick={() => {
setActiveIndex(index)
openLightbox(index)
}}
aria-label={t('products.details.viewImage', { index: index + 1 })}
>
<img src={src} alt="" />
</button>
))}
</div>
)}
</div>
<div className={styles.details}>
{categoryLabel ? (
<span
className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')}
lang={categoryLocale.lang}
dir={isFa ? 'rtl' : 'ltr'}
>
{categoryLabel}
</span>
) : null}
<h1
className={[styles.nameEn, primaryLocale.className].filter(Boolean).join(' ')}
lang={primaryLocale.lang}
dir={primaryLocale.dir}
>
{primaryName}
</h1>
{secondaryName ? (
<p
className={[styles.nameFa, secondaryLocale.className].filter(Boolean).join(' ')}
lang={secondaryLocale.lang}
dir={secondaryLocale.dir}
>
{secondaryName}
</p>
) : null}
{product.summary && (
<p
className={[styles.summary, summaryLocale.className].filter(Boolean).join(' ')}
lang={summaryLocale.lang}
dir={summaryLocale.dir}
>
{product.summary}
</p>
)}
{product.description && (
<div
className={[styles.description, descriptionLocale.className]
.filter(Boolean)
.join(' ')}
lang={descriptionLocale.lang}
dir={descriptionLocale.dir}
dangerouslySetInnerHTML={{ __html: product.description }}
/>
)}
{product.tags.length > 0 && (
<div className={styles.tags}>
{product.tags.map((tag) => {
const tagLocale = textLocaleAttrs(tag)
return (
<span
key={tag}
className={[styles.tag, tagLocale.className].filter(Boolean).join(' ')}
lang={tagLocale.lang}
dir={tagLocale.dir}
>
{tag}
</span>
)
})}
</div>
)}
</div>
</div>
<ProductDetailsTabs productId={product.id} commentCount={product.commentCount} />
<ImageLightbox
open={lightboxOpen}
images={galleryImages}
initialIndex={lightboxIndex}
alt={primaryName}
onClose={() => setLightboxOpen(false)}
/>
</main>
)
}