mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { ImageLightbox } from '../components/ImageLightbox'
|
|
import { ProductDetailsTabs } from '../components/ProductDetailsTabs'
|
|
import { ApiError } from '../lib/api'
|
|
import {
|
|
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 { 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('Unable to load product.')
|
|
}
|
|
setProduct(null)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
void loadProduct()
|
|
return () => controller.abort()
|
|
}, [id])
|
|
|
|
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}>Loading product...</p>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (error || !product) {
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<p className={styles.error}>{error || 'Product not found.'}</p>
|
|
<Link to="/products/list" className={styles.backLink}>
|
|
Back to My Products
|
|
</Link>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
const nameEnLocale = textLocaleAttrs(product.nameEn)
|
|
const nameFaLocale = textLocaleAttrs(product.nameFa)
|
|
const summaryLocale = textLocaleAttrs(product.summary)
|
|
const descriptionLocale = textLocaleAttrs(
|
|
product.description?.replace(/<[^>]+>/g, ' ') ?? '',
|
|
)
|
|
const categoryLocale = textLocaleAttrs(product.category)
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Products', href: '/products' },
|
|
{ label: 'My Products', href: '/products/list' },
|
|
{ label: product.nameEn },
|
|
]}
|
|
/>
|
|
|
|
<div className={styles.layout}>
|
|
<div className={styles.gallery}>
|
|
<button
|
|
type="button"
|
|
className={styles.mainImage}
|
|
onClick={() => currentImage && openLightbox(activeIndex)}
|
|
aria-label="Open image gallery"
|
|
disabled={!currentImage}
|
|
>
|
|
{currentImage ? (
|
|
<img src={currentImage} alt={product.nameEn} />
|
|
) : (
|
|
<div className={styles.imagePlaceholder}>No image</div>
|
|
)}
|
|
{product.status === 'draft' && <span className={styles.draftBadge}>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={`View image ${index + 1}`}
|
|
>
|
|
<img src={src} alt="" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.details}>
|
|
{product.category && (
|
|
<span
|
|
className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')}
|
|
lang={categoryLocale.lang}
|
|
dir={categoryLocale.dir}
|
|
>
|
|
{product.category}
|
|
</span>
|
|
)}
|
|
|
|
<h1
|
|
className={[styles.nameEn, nameEnLocale.className].filter(Boolean).join(' ')}
|
|
lang={nameEnLocale.lang}
|
|
dir={nameEnLocale.dir}
|
|
>
|
|
{product.nameEn}
|
|
</h1>
|
|
{product.nameFa && (
|
|
<p
|
|
className={[styles.nameFa, nameFaLocale.className].filter(Boolean).join(' ')}
|
|
lang={nameFaLocale.lang}
|
|
dir={nameFaLocale.dir}
|
|
>
|
|
{product.nameFa}
|
|
</p>
|
|
)}
|
|
|
|
{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={product.nameEn}
|
|
onClose={() => setLightboxOpen(false)}
|
|
/>
|
|
</main>
|
|
)
|
|
}
|