Add business workshop management UI with Jalali scheduling and shared dashboard polish.
Includes workshop list/edit/details, registrations modal, category tree, shared EmptyState/RichTextEditor/toasts, and fixes for the Jalali date picker and thumbnail field layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
89269dff04
commit
d5aca8bea7
@@ -0,0 +1,211 @@
|
||||
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 { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { formatWorkshopDate, formatWorkshopDuration, getWorkshopDetail } from '../services/workshopService'
|
||||
import type { WorkshopDetail } from '../types/workshop'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './PortfolioDetailsPage.module.css'
|
||||
|
||||
export function WorkshopDetailsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { id } = useParams()
|
||||
const [workshop, setWorkshop] = useState<WorkshopDetail | 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 loadWorkshop() {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getWorkshopDetail(id!, controller.signal)
|
||||
setWorkshop(data)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.details.errorLoad'))
|
||||
}
|
||||
setWorkshop(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkshop()
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
const galleryImages = useMemo(
|
||||
() => workshop?.gallery.map((item) => item.url).filter(Boolean) ?? [],
|
||||
[workshop],
|
||||
)
|
||||
|
||||
function openLightbox(index: number) {
|
||||
setLightboxIndex(index)
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>{t('workshop.details.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !workshop || !id) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.error}>{error || t('workshop.details.notFound')}</p>
|
||||
<Link to="/workshops/list" className={styles.backLink}>
|
||||
{t('workshop.details.back')}
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const displayDate = formatWorkshopDate(
|
||||
workshop.publishedAt ?? workshop.createdAt,
|
||||
locale === 'fa' ? 'fa' : 'en',
|
||||
)
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const hasSchedule = Boolean(workshop.eventDate || workshop.eventTime || workshop.duration)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'My Workshops', href: '/workshops/list' },
|
||||
{ label: workshop.title },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>{workshop.title}</h2>
|
||||
{workshop.abstract && (
|
||||
<p className={pageStyles.pageSubtitleContent}>{workshop.abstract}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className={styles.portfolioDetail}>
|
||||
<div
|
||||
className={`${styles.heroImageWrap} ${
|
||||
workshop.coverImageUrl ? '' : styles.heroImageWrapEmpty
|
||||
}`}
|
||||
>
|
||||
{workshop.coverImageUrl ? (
|
||||
<img
|
||||
src={workshop.coverImageUrl}
|
||||
alt={workshop.title}
|
||||
className={styles.heroImage}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.heroPlaceholder} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
{hasSchedule && (
|
||||
<>
|
||||
<span className={styles.metaItem}>
|
||||
<CalendarDays size={15} />
|
||||
{[
|
||||
workshop.eventDate
|
||||
? formatWorkshopDate(workshop.eventDate, dateLocale)
|
||||
: null,
|
||||
workshop.eventTime || null,
|
||||
workshop.duration ? formatWorkshopDuration(workshop.duration) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
<span className={styles.metaDot}>·</span>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.metaItem}>
|
||||
<CalendarDays size={15} />
|
||||
{displayDate}
|
||||
</span>
|
||||
{(workshop.categoryName || workshop.tags.length > 0) && (
|
||||
<>
|
||||
<span className={styles.metaDot}>·</span>
|
||||
<div className={styles.metaChips}>
|
||||
{workshop.categoryName && (
|
||||
<span className={styles.categoryChip}>{workshop.categoryName}</span>
|
||||
)}
|
||||
{workshop.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tagChip}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.contentPanel}>
|
||||
{workshop.descriptionHtml ? (
|
||||
<div
|
||||
className={styles.content}
|
||||
dangerouslySetInnerHTML={{ __html: workshop.descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content}>{t('workshop.details.noContent')}</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{galleryImages.length > 0 && (
|
||||
<section className={styles.galleryBand} aria-label={t('workshop.details.gallery')}>
|
||||
<div className={styles.galleryInner}>
|
||||
<h3 className={styles.galleryTitle}>{t('workshop.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('workshop.details.galleryImage', { index: index + 1 })}
|
||||
>
|
||||
<img src={src} alt={`${workshop.title} gallery ${index + 1}`} loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{galleryImages.length > 0 && (
|
||||
<ImageLightbox
|
||||
open={lightboxOpen}
|
||||
images={galleryImages}
|
||||
initialIndex={lightboxIndex}
|
||||
alt={workshop.title}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user