Rename Customers to Users with FA/EN titles, compact headers and filter bars across apps, and refine manager role copy plus a customer-header link to the business dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
6.3 KiB
TypeScript
200 lines
6.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import { CalendarDays } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { ImageLightbox } from '../components/ImageLightbox'
|
|
import { PortfolioCommentsSection } from '../components/PortfolioCommentsSection'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError } from '../lib/api'
|
|
import { formatPortfolioDate, getPortfolioDetail } from '../services/portfolioService'
|
|
import type { PortfolioDetail } from '../types/portfolio'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import styles from './PortfolioDetailsPage.module.css'
|
|
|
|
export function PortfolioDetailsPage() {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const { id } = useParams()
|
|
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [lightboxOpen, setLightboxOpen] = useState(false)
|
|
const [lightboxIndex, setLightboxIndex] = useState(0)
|
|
|
|
useEffect(() => {
|
|
if (!id) return
|
|
|
|
const controller = new AbortController()
|
|
|
|
async function loadPortfolio() {
|
|
setIsLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const data = await getPortfolioDetail(id!, controller.signal)
|
|
setPortfolio(data)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('portfolio.details.errorLoad'))
|
|
}
|
|
setPortfolio(null)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
void loadPortfolio()
|
|
return () => controller.abort()
|
|
}, [id])
|
|
|
|
const galleryImages = useMemo(
|
|
() => portfolio?.gallery.map((item) => item.url).filter(Boolean) ?? [],
|
|
[portfolio],
|
|
)
|
|
|
|
function openLightbox(index: number) {
|
|
setLightboxIndex(index)
|
|
setLightboxOpen(true)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<p className={styles.status}>{t('portfolio.details.loading')}</p>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (error || !portfolio || !id) {
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<p className={styles.error}>{error || t('portfolio.details.notFound')}</p>
|
|
<Link to="/portfolios/list" className={styles.backLink}>
|
|
{t('portfolio.details.back')}
|
|
</Link>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
const displayDate = formatPortfolioDate(
|
|
portfolio.publishedAt ?? portfolio.createdAt,
|
|
locale === 'fa' ? 'fa' : 'en',
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<div className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Portfolios', href: '/portfolios' },
|
|
{ label: 'My Portfolios', href: '/portfolios/list' },
|
|
{ label: portfolio.title },
|
|
]}
|
|
/>
|
|
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<h2 className={pageStyles.pageTitle}>{portfolio.title}</h2>
|
|
{portfolio.abstract && (
|
|
<p className={pageStyles.pageSubtitleContent}>{portfolio.abstract}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<article className={styles.portfolioDetail}>
|
|
<div
|
|
className={`${styles.heroImageWrap} ${
|
|
portfolio.titleImageUrl ? '' : styles.heroImageWrapEmpty
|
|
}`}
|
|
>
|
|
{portfolio.titleImageUrl ? (
|
|
<img
|
|
src={portfolio.titleImageUrl}
|
|
alt={portfolio.title}
|
|
className={styles.heroImage}
|
|
/>
|
|
) : (
|
|
<div className={styles.heroPlaceholder} aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.metaRow}>
|
|
<span className={styles.metaItem}>
|
|
<CalendarDays size={15} />
|
|
{displayDate}
|
|
</span>
|
|
{(portfolio.categoryName || portfolio.tags.length > 0) && (
|
|
<>
|
|
<span className={styles.metaDot}>·</span>
|
|
<div className={styles.metaChips}>
|
|
{portfolio.categoryName && (
|
|
<span className={styles.categoryChip}>{portfolio.categoryName}</span>
|
|
)}
|
|
{portfolio.tags.map((tag) => (
|
|
<span key={tag} className={styles.tagChip}>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.contentPanel}>
|
|
{portfolio.mainTextHtml ? (
|
|
<div
|
|
className={styles.content}
|
|
dangerouslySetInnerHTML={{ __html: portfolio.mainTextHtml }}
|
|
/>
|
|
) : (
|
|
<p className={styles.content}>{t('portfolio.details.noContent')}</p>
|
|
)}
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
{galleryImages.length > 0 && (
|
|
<section className={styles.galleryBand} aria-label={t('portfolio.details.gallery')}>
|
|
<div className={styles.galleryInner}>
|
|
<h3 className={styles.galleryTitle}>{t('portfolio.details.gallery')}</h3>
|
|
<div className={styles.galleryGrid}>
|
|
{galleryImages.map((src, index) => (
|
|
<button
|
|
key={`${src}-${index}`}
|
|
type="button"
|
|
className={styles.galleryItem}
|
|
onClick={() => openLightbox(index)}
|
|
aria-label={t('portfolio.details.galleryImage', { index: index + 1 })}
|
|
>
|
|
<img src={src} alt={`${portfolio.title} gallery ${index + 1}`} loading="lazy" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<div className={pageStyles.content}>
|
|
<div className={styles.commentsWrap}>
|
|
<PortfolioCommentsSection portfolioId={id} />
|
|
</div>
|
|
</div>
|
|
|
|
{galleryImages.length > 0 && (
|
|
<ImageLightbox
|
|
open={lightboxOpen}
|
|
images={galleryImages}
|
|
initialIndex={lightboxIndex}
|
|
alt={portfolio.title}
|
|
onClose={() => setLightboxOpen(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|