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:
Alireza Hassani
2026-08-22 12:22:27 +03:30
co-authored by Cursor
parent 89269dff04
commit d5aca8bea7
76 changed files with 4719 additions and 974 deletions
+10 -10
View File
@@ -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>
)
}
+3 -3
View File
@@ -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"
/>
+10 -2
View File
@@ -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() {
+9 -4
View File
@@ -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"
/>
+2 -2
View File
@@ -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 {
+11 -7
View File
@@ -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"
/>
+60 -8
View File
@@ -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}
+3 -3
View File
@@ -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>
)
}
+75
View File
@@ -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>
)
}