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
@@ -319,17 +319,17 @@ export function AddNewPortfolioPage() {
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>{t('portfolio.form.mainText')}</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder={t('portfolio.form.mainTextPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('portfolio.form.mainText')}</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder={t('portfolio.form.mainTextPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
|
||||
@@ -41,8 +41,21 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scheduleRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumbnailField {
|
||||
align-self: start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumbnailField label {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.topFields {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||
import { SearchableSelect } from '../components/SearchableSelect'
|
||||
import { ImageCropper } from '../components/ImageCropper'
|
||||
import { ImageUploader } from '../components/ImageUploader'
|
||||
import { TagInput } from '../components/TagInput'
|
||||
import { RichTextEditor } from '../components/RichTextEditor'
|
||||
import { JalaliDatePicker } from '../components/JalaliDatePicker'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listWorkshopCategories,
|
||||
mapWorkshopCategoryToUi,
|
||||
} from '../services/workshopCategoryService'
|
||||
import {
|
||||
resolveDataUrlToMediaId,
|
||||
resolveDataUrlsToMediaIds,
|
||||
resolveHtmlEmbeddedDataUrls,
|
||||
} from '../services/mediaService'
|
||||
import {
|
||||
createWorkshop,
|
||||
getWorkshop,
|
||||
mapWorkshopApiToFormState,
|
||||
updateWorkshop,
|
||||
} from '../services/workshopService'
|
||||
import type { Category } from '../types/category'
|
||||
import { flattenCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
|
||||
export function AddNewWorkshopPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleFa, setTitleFa] = useState('')
|
||||
const [titleEn, setTitleEn] = useState('')
|
||||
const [abstract, setAbstract] = useState('')
|
||||
const [descriptionHtml, setDescriptionHtml] = useState('')
|
||||
const [coverImage, setCoverImage] = useState<string | null>(null)
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [eventDate, setEventDate] = useState('')
|
||||
const [eventTime, setEventTime] = useState('')
|
||||
const [duration, setDuration] = useState('')
|
||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
||||
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(!isEdit)
|
||||
const [isLoadingWorkshop, setIsLoadingWorkshop] = useState(isEdit)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const categoryOptions = flattenCategories(categories)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const items = await listWorkshopCategories(controller.signal)
|
||||
setCategories(items.map(mapWorkshopCategoryToUi))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorLoad'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCategories()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit || !id) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadWorkshop() {
|
||||
setIsLoadingWorkshop(true)
|
||||
setError('')
|
||||
try {
|
||||
const workshop = await getWorkshop(id!, controller.signal)
|
||||
const form = mapWorkshopApiToFormState(workshop)
|
||||
setCategoryId(form.categoryId)
|
||||
setTitleFa(form.titleFa)
|
||||
setTitleEn(form.titleEn)
|
||||
setAbstract(form.abstract)
|
||||
setDescriptionHtml(form.descriptionHtml)
|
||||
setCoverImage(form.coverImage)
|
||||
setImages(form.images)
|
||||
setTags(form.tags)
|
||||
setEventDate(form.eventDate)
|
||||
setEventTime(form.eventTime)
|
||||
setDuration(form.duration)
|
||||
setFeaturedMediaId(form.featuredMediaId)
|
||||
setGalleryMediaIds(form.galleryMediaIds)
|
||||
setLoaded(true)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.details.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingWorkshop(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkshop()
|
||||
return () => controller.abort()
|
||||
}, [isEdit, id, navigate])
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isLoadingWorkshop ? t('workshop.list.loading') : t('workshop.details.errorLoad')}
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
|
||||
coverImage,
|
||||
'workshop-thumbnail.jpg',
|
||||
featuredMediaId,
|
||||
)
|
||||
const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml)
|
||||
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(images, galleryMediaIds)
|
||||
|
||||
if (coverImage?.startsWith('data:') && !nextFeaturedMediaId) {
|
||||
setError(t('workshop.form.errorThumbnailUpload'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
titleFa: titleFa.trim(),
|
||||
titleEn: titleEn.trim() || undefined,
|
||||
abstract: abstract.trim(),
|
||||
descriptionHtml: resolvedDescriptionHtml,
|
||||
categoryId: categoryId || undefined,
|
||||
featuredMediaId: nextFeaturedMediaId ?? undefined,
|
||||
galleryMediaIds: nextGalleryMediaIds,
|
||||
tags,
|
||||
eventDate: eventDate.trim() || null,
|
||||
eventTime: eventTime.trim() || null,
|
||||
duration: duration.trim() || null,
|
||||
}
|
||||
|
||||
if (isEdit && id) {
|
||||
await updateWorkshop(id, {
|
||||
...payload,
|
||||
categoryId: categoryId || null,
|
||||
featuredMediaId: nextFeaturedMediaId,
|
||||
})
|
||||
showToast(t('workshop.form.toast.updated'), 'success')
|
||||
} else {
|
||||
await createWorkshop({ ...payload, status: 'draft' })
|
||||
showToast(t('workshop.form.toast.created'), 'success')
|
||||
}
|
||||
|
||||
navigate('/workshops/list')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.form.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={
|
||||
isEdit
|
||||
? [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'My Workshops', href: '/workshops/list' },
|
||||
{ label: 'Edit Workshop' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'Add New Workshop' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? t('title.editWorkshop') : t('title.addWorkshop')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit ? t('workshop.form.edit.subtitle') : t('workshop.form.add.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
|
||||
<label>{t('workshop.form.thumbnail')}</label>
|
||||
<ImageCropper
|
||||
value={coverImage}
|
||||
onChange={setCoverImage}
|
||||
aspect={1}
|
||||
uploadLabel={t('workshop.form.uploadThumbnail')}
|
||||
hint={t('workshop.form.cropHint')}
|
||||
changeLabel={t('workshop.form.changeThumbnail')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formColumn}>
|
||||
<div className={styles.field}>
|
||||
<label>{t('workshop.form.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t('workshop.form.categoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-title-fa">{t('workshop.form.titleFa')}</label>
|
||||
<input
|
||||
id="workshop-title-fa"
|
||||
name="titleFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
className="faText"
|
||||
placeholder={t('workshop.form.titleFaPlaceholder')}
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-title-en">{t('workshop.form.titleEn')}</label>
|
||||
<input
|
||||
id="workshop-title-en"
|
||||
name="titleEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder={t('workshop.form.titleEnPlaceholder')}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-abstract">{t('workshop.form.abstract')}</label>
|
||||
<textarea
|
||||
id="workshop-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder={t('workshop.form.abstractPlaceholder')}
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scheduleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-event-date">{t('workshop.form.eventDate')}</label>
|
||||
<JalaliDatePicker
|
||||
id="workshop-event-date"
|
||||
value={eventDate}
|
||||
onChange={setEventDate}
|
||||
disabled={isSubmitting}
|
||||
placeholder={t('workshop.form.eventDatePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-event-time">{t('workshop.form.eventTime')}</label>
|
||||
<input
|
||||
id="workshop-event-time"
|
||||
name="eventTime"
|
||||
type="time"
|
||||
value={eventTime}
|
||||
onChange={(e) => setEventTime(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-duration">{t('workshop.form.duration')}</label>
|
||||
<input
|
||||
id="workshop-duration"
|
||||
name="duration"
|
||||
type="text"
|
||||
placeholder={t('workshop.form.durationPlaceholder')}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('workshop.form.description')}</label>
|
||||
<RichTextEditor
|
||||
value={descriptionHtml}
|
||||
onChange={setDescriptionHtml}
|
||||
placeholder={t('workshop.form.descriptionPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('workshop.form.gallery')}</label>
|
||||
<ImageUploader images={images} onChange={setImages} />
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('workshop.form.tags')}</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/workshops/list" className={styles.cancelBtn}>
|
||||
{t('workshop.form.cancel')}
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t('workshop.form.saving')
|
||||
: isEdit
|
||||
? t('workshop.form.update')
|
||||
: t('workshop.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FileText, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { BlogCard } from '../components/BlogCard'
|
||||
import { BlogCommentsModal } from '../components/BlogCommentsModal'
|
||||
@@ -182,9 +182,9 @@ export function BlogListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('blog.list.loading')}</p>
|
||||
) : blogs.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="blog"
|
||||
icon={FileText}
|
||||
message={t('blog.list.empty')}
|
||||
actionLabel={t('blog.list.emptyCta')}
|
||||
actionTo="/blog/new"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, GraduationCap } from 'lucide-react'
|
||||
import { FileText, GraduationCap, Presentation } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
@@ -14,7 +14,7 @@ const contentSections: {
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
moduleId: 'blog' | 'videos' | 'instructions'
|
||||
moduleId: 'blog' | 'videos' | 'instructions' | 'workshops'
|
||||
}[] = [
|
||||
{
|
||||
icon: FileText,
|
||||
@@ -32,6 +32,14 @@ const contentSections: {
|
||||
href: '/instructions',
|
||||
moduleId: 'instructions',
|
||||
},
|
||||
{
|
||||
icon: Presentation,
|
||||
titleKey: 'nav.workshops',
|
||||
descKey: 'content.card.workshops.desc',
|
||||
linkKey: 'content.card.workshops.link',
|
||||
href: '/workshops',
|
||||
moduleId: 'workshops',
|
||||
},
|
||||
]
|
||||
|
||||
export function ContentPage() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { listBlogs } from '../services/blogService'
|
||||
import { listPortfolios } from '../services/portfolioService'
|
||||
import { listVideos } from '../services/videoService'
|
||||
import { listInstructions } from '../services/instructionService'
|
||||
import { listWorkshops } from '../services/workshopService'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import {
|
||||
hasBusinessModule,
|
||||
@@ -93,7 +94,7 @@ const sections: {
|
||||
countLabelKey: 'home.card.content.count',
|
||||
href: '/content',
|
||||
countKey: 'content',
|
||||
anyModuleIds: ['blog', 'videos', 'instructions'],
|
||||
anyModuleIds: ['blog', 'videos', 'instructions', 'workshops'],
|
||||
},
|
||||
{
|
||||
icon: Briefcase,
|
||||
@@ -110,7 +111,8 @@ const sections: {
|
||||
type SectionCounts = Partial<Record<CountKey, number>>
|
||||
|
||||
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
const [products, store, customers, blog, portfolios, videos, instructions] = await Promise.all([
|
||||
const [products, store, customers, blog, portfolios, videos, instructions, workshops] =
|
||||
await Promise.all([
|
||||
listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listStoreItems(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
|
||||
@@ -118,6 +120,7 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listVideos(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listInstructions(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listWorkshops(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
])
|
||||
|
||||
const counts: SectionCounts = {}
|
||||
@@ -125,8 +128,10 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
if (store !== null) counts.store = store
|
||||
if (customers !== null) counts.customers = customers
|
||||
if (portfolios !== null) counts.portfolios = portfolios
|
||||
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0)
|
||||
if (blog !== null || videos !== null || instructions !== null) counts.content = contentTotal
|
||||
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0) + (workshops ?? 0)
|
||||
if (blog !== null || videos !== null || instructions !== null || workshops !== null) {
|
||||
counts.content = contentTotal
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { InstructionCard } from '../components/InstructionCard'
|
||||
import { InstructionCommentsModal } from '../components/InstructionCommentsModal'
|
||||
@@ -159,9 +159,9 @@ export function InstructionListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('instruction.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="instruction"
|
||||
icon={Play}
|
||||
message={t('instruction.list.empty')}
|
||||
actionLabel={t('instruction.list.emptyCta')}
|
||||
actionTo="/instructions/new"
|
||||
/>
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
}
|
||||
|
||||
.businessName {
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.appName {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { EmptyState, ListEmptyState } from '../components/EmptyState'
|
||||
import { ProductCard } from '../components/ProductCard'
|
||||
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
@@ -258,12 +258,16 @@ export function MyProductsPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('products.list.loading')}</p>
|
||||
) : products.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
|
||||
actionLabel={appliedName ? undefined : t('products.list.emptyCta')}
|
||||
actionTo={appliedName ? undefined : '/products/new'}
|
||||
/>
|
||||
appliedName ? (
|
||||
<EmptyState message={t('products.list.emptyFiltered')} />
|
||||
) : (
|
||||
<ListEmptyState
|
||||
entity="products"
|
||||
icon={Package}
|
||||
actionLabel={t('products.list.emptyCta')}
|
||||
actionTo="/products/new"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Briefcase, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { PortfolioCard } from '../components/PortfolioCard'
|
||||
import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
|
||||
@@ -190,9 +190,9 @@ export function PortfolioListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('portfolio.list.loading')}</p>
|
||||
) : portfolios.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="portfolio"
|
||||
icon={Briefcase}
|
||||
message={t('portfolio.list.empty')}
|
||||
actionLabel={t('portfolio.list.emptyCta')}
|
||||
actionTo="/portfolios/new"
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
listStoreSpecials,
|
||||
updateStoreSpecial,
|
||||
} from '../services/storeSpecialService'
|
||||
import {
|
||||
getSettings,
|
||||
type SpecialProductsSource,
|
||||
} from '../services/settingsService'
|
||||
import { hasBusinessModule, type BusinessModuleId } from '../utils/businessModules'
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import type { StoreSpecial } from '../types/storeSpecial'
|
||||
import type { StoreProductListing } from '../utils/storeProductGroups'
|
||||
@@ -29,11 +34,23 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import fabStyles from './StoreItemsPage.module.css'
|
||||
import styles from './StoreSpecialsPage.module.css'
|
||||
|
||||
function defaultSpecialProductsSource(
|
||||
enabledModules: string[] | undefined,
|
||||
): SpecialProductsSource {
|
||||
return hasBusinessModule(
|
||||
enabledModules as BusinessModuleId[] | undefined,
|
||||
'store',
|
||||
)
|
||||
? 'store_item'
|
||||
: 'product'
|
||||
}
|
||||
|
||||
export function StoreSpecialsPage() {
|
||||
const t = useT()
|
||||
const { itemCount, hasItems, addVariant } = useDraftCart()
|
||||
const { showToast } = useToast()
|
||||
const [specials, setSpecials] = useState<StoreSpecial[]>([])
|
||||
const [specialSource, setSpecialSource] = useState<SpecialProductsSource>('store_item')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -61,10 +78,36 @@ export function StoreSpecialsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSpecials(controller.signal)
|
||||
void loadPage(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadPage(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [specialsData, settingsData] = await Promise.all([
|
||||
listStoreSpecials(1, 50, signal),
|
||||
getSettings(signal),
|
||||
])
|
||||
setSpecials(specialsData.items)
|
||||
setSpecialSource(
|
||||
settingsData.settings.website?.specialProductsSource ??
|
||||
defaultSpecialProductsSource(settingsData.settings.modules?.enabled),
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('website.specialItems.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSpecials(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
@@ -212,19 +255,23 @@ export function StoreSpecialsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddItems(storeItemIds: string[]) {
|
||||
async function handleAddItems(ids: string[]) {
|
||||
if (!pickItemsTarget) return
|
||||
|
||||
const existingIds = pickItemsTarget.items.map((item) => item.id)
|
||||
const nextIds = [...existingIds, ...storeItemIds]
|
||||
const fromProducts = specialSource === 'product'
|
||||
const existingIds = pickItemsTarget.items.map((item) =>
|
||||
fromProducts ? item.productId : item.id,
|
||||
)
|
||||
const nextIds = [...existingIds, ...ids]
|
||||
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await updateStoreSpecial(pickItemsTarget.id, {
|
||||
storeItemIds: nextIds,
|
||||
})
|
||||
const result = await updateStoreSpecial(
|
||||
pickItemsTarget.id,
|
||||
fromProducts ? { productIds: nextIds } : { storeItemIds: nextIds },
|
||||
)
|
||||
replaceSpecial(result.special)
|
||||
setPickItemsTarget(null)
|
||||
showToast(t('website.specialItems.toastAdded'), 'success')
|
||||
@@ -402,7 +449,12 @@ export function StoreSpecialsPage() {
|
||||
<PickStoreSpecialItemsModal
|
||||
open={!!pickItemsTarget}
|
||||
specialTitle={pickItemsTarget?.title ?? ''}
|
||||
existingStoreItemIds={pickItemsTarget?.items.map((item) => item.id) ?? []}
|
||||
source={specialSource}
|
||||
existingIds={
|
||||
pickItemsTarget?.items.map((item) =>
|
||||
specialSource === 'product' ? item.productId : item.id,
|
||||
) ?? []
|
||||
}
|
||||
onClose={() => !isSaving && setPickItemsTarget(null)}
|
||||
onConfirm={handleAddItems}
|
||||
isSubmitting={isSaving}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { VideoCard } from '../components/VideoCard'
|
||||
import { VideoCommentsModal } from '../components/VideoCommentsModal'
|
||||
@@ -159,9 +159,9 @@ export function VideoListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('video.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="video"
|
||||
icon={Play}
|
||||
message={t('video.list.empty')}
|
||||
actionLabel={t('video.list.emptyCta')}
|
||||
actionTo="/videos/new"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import sharedStyles from './ProductSettingsPage.module.css'
|
||||
import styles from './WebsiteSettingsPage.module.css'
|
||||
|
||||
const SOURCE_OPTIONS: SpecialProductsSource[] = ['product', 'store_item']
|
||||
const SOURCE_OPTIONS: SpecialProductsSource[] = ['store_item', 'product']
|
||||
|
||||
function defaultSpecialProductsSource(
|
||||
enabledModules: string[] | undefined,
|
||||
@@ -113,8 +113,6 @@ export function WebsiteSettingsPage() {
|
||||
)}
|
||||
|
||||
<section className={sharedStyles.panel}>
|
||||
<h3 className={sharedStyles.sectionTitle}>{t('website.settings.section')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>{t('website.settings.loading')}</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FolderTree, Plus } from 'lucide-react'
|
||||
import { CategoryModal } from '../components/CategoryModal'
|
||||
import { WorkshopCategoryTree } from '../components/WorkshopCategoryTree'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import {
|
||||
createWorkshopCategory,
|
||||
deleteWorkshopCategory,
|
||||
listWorkshopCategories,
|
||||
mapWorkshopCategoryToUi,
|
||||
toWorkshopCategoryFormPayload,
|
||||
updateWorkshopCategory,
|
||||
} from '../services/workshopCategoryService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
export function WorkshopCategoriesPage() {
|
||||
const t = useT()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [defaultParentId, setDefaultParentId] = useState('')
|
||||
const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadCategories(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadCategories(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const items = await listWorkshopCategories(signal)
|
||||
setCategories(items.map(mapWorkshopCategoryToUi))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal(parentId = '', title?: string) {
|
||||
setEditingCategory(null)
|
||||
setDefaultParentId(parentId)
|
||||
setModalTitle(title ?? t('categories.add'))
|
||||
setModalOpen(true)
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId))
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle(t('categories.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(data: CategoryFormData) {
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = toWorkshopCategoryFormPayload(data)
|
||||
|
||||
if (editingCategory) {
|
||||
const result = await updateWorkshopCategory(editingCategory.id, {
|
||||
...payload,
|
||||
parentId: data.parentId || null,
|
||||
})
|
||||
setCategories((prev) =>
|
||||
prev.map((category) =>
|
||||
category.id === editingCategory.id
|
||||
? mapWorkshopCategoryToUi(result.category)
|
||||
: category,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const result = await createWorkshopCategory(payload)
|
||||
setCategories((prev) => [...prev, mapWorkshopCategoryToUi(result.category)])
|
||||
if (data.parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(data.parentId))
|
||||
}
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await deleteWorkshopCategory(deleteTarget.id)
|
||||
const removed = new Set(result.deletedIds)
|
||||
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
removed.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'Categories' },
|
||||
]}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="WORKSHOP CATEGORIES">{t('workshop.categories.title')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('workshop.categories.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTree}
|
||||
message={t('workshop.categories.empty')}
|
||||
actionLabel={t('categories.add')}
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<WorkshopCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
onClose={() => {
|
||||
if (!isSubmitting) {
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
}
|
||||
}}
|
||||
title={modalTitle}
|
||||
defaultParentId={defaultParentId}
|
||||
categories={categories}
|
||||
editingCategory={editingCategory}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('categories.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className={styles.fabDock}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label={t('categories.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Presentation, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { WorkshopCard } from '../components/WorkshopCard'
|
||||
import { WorkshopRegistrationsModal } from '../components/WorkshopRegistrationsModal'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
WORKSHOPS_PER_PAGE,
|
||||
deleteWorkshop,
|
||||
isWorkshopPublished,
|
||||
listWorkshops,
|
||||
mapWorkshopApiToUi,
|
||||
resolveWorkshopTitle,
|
||||
setWorkshopPublished,
|
||||
} from '../services/workshopService'
|
||||
import type { Workshop } from '../types/workshop'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogPage.module.css'
|
||||
|
||||
export function WorkshopListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [workshops, setWorkshops] = useState<Workshop[]>([])
|
||||
const [totalWorkshops, setTotalWorkshops] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [registrationCounts, setRegistrationCounts] = useState<Record<string, number>>({})
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
const [registrationsTarget, setRegistrationsTarget] = useState<{
|
||||
id: string
|
||||
title: string
|
||||
} | null>(null)
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalWorkshops / WORKSHOPS_PER_PAGE))
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadWorkshops(currentPage, controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [currentPage])
|
||||
|
||||
async function loadWorkshops(page: number, signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listWorkshops(page, WORKSHOPS_PER_PAGE, signal)
|
||||
setWorkshops(data.items)
|
||||
setTotalWorkshops(data.total)
|
||||
setRegistrationCounts(
|
||||
Object.fromEntries(
|
||||
data.items.map((workshop) => [workshop.id, workshop.registrationCount ?? 0]),
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.list.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(id: string) {
|
||||
navigate(`/workshops/edit/${id}`)
|
||||
}
|
||||
|
||||
function handleRegistrations(id: string) {
|
||||
const workshop = workshops.find((item) => item.id === id)
|
||||
if (workshop) {
|
||||
setRegistrationsTarget({
|
||||
id,
|
||||
title: resolveWorkshopTitle(workshop, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveRequest(id: string) {
|
||||
const workshop = workshops.find((item) => item.id === id)
|
||||
if (workshop) {
|
||||
setDeleteTarget({
|
||||
id,
|
||||
title: resolveWorkshopTitle(workshop, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsDeleting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await deleteWorkshop(deleteTarget.id)
|
||||
showToast(t('workshop.list.toast.removed'), 'success')
|
||||
const nextTotal = totalWorkshops - 1
|
||||
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / WORKSHOPS_PER_PAGE))
|
||||
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
||||
setDeleteTarget(null)
|
||||
setCurrentPage(nextPage)
|
||||
await loadWorkshops(nextPage)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.list.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setCurrentPage(page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function handleTogglePublished(id: string) {
|
||||
const workshop = workshops.find((item) => item.id === id)
|
||||
if (!workshop) return
|
||||
|
||||
const nextPublished = !isWorkshopPublished(workshop)
|
||||
setTogglingId(id)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await setWorkshopPublished(id, nextPublished)
|
||||
setWorkshops((prev) =>
|
||||
prev.map((item) => (item.id === id ? mapWorkshopApiToUi(result.workshop) : item)),
|
||||
)
|
||||
showToast(
|
||||
nextPublished ? t('workshop.list.toast.published') : t('workshop.list.toast.unpublished'),
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.list.errorStatus'))
|
||||
}
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'My Workshops' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="MY WORKSHOPS">{t('title.myWorkshops')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{t('workshop.list.subtitle', { count: totalWorkshops })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('workshop.list.loading')}</p>
|
||||
) : workshops.length === 0 ? (
|
||||
<ListEmptyState
|
||||
entity="workshop"
|
||||
icon={Presentation}
|
||||
actionLabel={t('workshop.list.emptyCta')}
|
||||
actionTo="/workshops/new"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.gridCols5}>
|
||||
{workshops.map((workshop) => (
|
||||
<WorkshopCard
|
||||
key={workshop.id}
|
||||
workshop={workshop}
|
||||
registrationCount={registrationCounts[workshop.id] ?? workshop.registrationCount}
|
||||
onEdit={handleEdit}
|
||||
onRegistrations={handleRegistrations}
|
||||
onTogglePublished={handleTogglePublished}
|
||||
onRemove={handleRemoveRequest}
|
||||
isTogglingStatus={togglingId === workshop.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && workshops.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => navigate('/workshops/new')}
|
||||
aria-label={t('workshop.list.addNew')}
|
||||
>
|
||||
<Plus size={26} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('workshop.list.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('workshop.list.deleteMessage', { name: deleteTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isDeleting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{registrationsTarget && (
|
||||
<WorkshopRegistrationsModal
|
||||
open={!!registrationsTarget}
|
||||
workshopId={registrationsTarget.id}
|
||||
workshopTitle={registrationsTarget.title}
|
||||
onClose={() => setRegistrationsTarget(null)}
|
||||
onRegistrationAdded={() => {
|
||||
setRegistrationCounts((current) => ({
|
||||
...current,
|
||||
[registrationsTarget.id]:
|
||||
(current[registrationsTarget.id] ?? 0) + 1,
|
||||
}))
|
||||
}}
|
||||
onRegistrationRemoved={() => {
|
||||
setRegistrationCounts((current) => ({
|
||||
...current,
|
||||
[registrationsTarget.id]: Math.max(
|
||||
0,
|
||||
(current[registrationsTarget.id] ?? 0) - 1,
|
||||
),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Presentation, PlusCircle, FolderTree } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const workshopSections: {
|
||||
icon: typeof Presentation
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
wide?: boolean
|
||||
}[] = [
|
||||
{
|
||||
icon: Presentation,
|
||||
titleKey: 'nav.workshops.list',
|
||||
descKey: 'workshop.card.list.desc',
|
||||
linkKey: 'workshop.card.list.link',
|
||||
href: '/workshops/list',
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
icon: PlusCircle,
|
||||
titleKey: 'nav.workshops.new',
|
||||
descKey: 'workshop.card.new.desc',
|
||||
linkKey: 'workshop.card.new.link',
|
||||
href: '/workshops/new',
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
titleKey: 'workshop.card.categories.title',
|
||||
descKey: 'workshop.card.categories.desc',
|
||||
linkKey: 'workshop.card.categories.link',
|
||||
href: '/workshops/categories',
|
||||
},
|
||||
]
|
||||
|
||||
export function WorkshopPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Content', href: '/content' },
|
||||
{ label: 'Workshops' },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="WORKSHOPS">{t('title.workshops')}</PageTitle>
|
||||
<p className={styles.pageSubtitle}>{t('workshop.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{workshopSections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
className={section.wide ? styles.span4 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user