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
@@ -13,6 +13,7 @@ export interface CreateStoreSpecialPayload {
key: string
title: string
storeItemIds?: string[]
productIds?: string[]
sortOrder?: number
isActive?: boolean
}
@@ -21,6 +22,7 @@ export interface UpdateStoreSpecialPayload {
key?: string
title?: string
storeItemIds?: string[]
productIds?: string[]
sortOrder?: number
isActive?: boolean
}
@@ -0,0 +1,76 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'workshop' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listWorkshopCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createWorkshopCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updateWorkshopCategory(categoryId: string, payload: UpdateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteWorkshopCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export function toWorkshopCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapWorkshopCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
@@ -0,0 +1,413 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { extractFirstImageUrl } from '../utils/htmlImages'
import type {
Workshop,
WorkshopAuthor,
WorkshopDetail,
WorkshopGalleryItem,
WorkshopStatus,
} from '../types/workshop'
export const WORKSHOPS_PER_PAGE = 12
export interface WorkshopApi {
id: string
businessId: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
descriptionHtml: string
status: WorkshopStatus
categoryId: string | null
categoryName: string
tags: string[]
coverImageUrl: string | null
featuredMediaId: string | null
gallery: WorkshopGalleryItem[]
galleryMediaIds: string[]
registrationCount: number
authorId?: string | null
author?: WorkshopAuthor | null
publishedAt: string | null
createdAt: string
updatedAt: string
eventDate: string | null
eventTime: string | null
duration: string | null
}
export interface WorkshopsListResponse {
items: WorkshopApi[]
total: number
page: number
pageSize: number
}
export interface ListWorkshopsParams {
page?: number
pageSize?: number
status?: WorkshopStatus
categoryId?: string
title?: string
}
export interface WorkshopRegistrationUser {
id: string
firstName: string | null
lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
email: string | null
cellNumber: string | null
}
export type WorkshopRegistrationStatus = 'pending' | 'approved' | 'rejected'
export interface WorkshopRegistration {
id: string
workshopId: string
userId: string
status: WorkshopRegistrationStatus
createdAt: string
user: WorkshopRegistrationUser
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/workshops${suffix}`
}
export interface CreateWorkshopPayload {
titleFa: string
titleEn?: string
abstract?: string
descriptionHtml?: string
categoryId?: string
status?: WorkshopStatus
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
eventDate?: string | null
eventTime?: string | null
duration?: string | null
}
export interface UpdateWorkshopPayload {
titleFa?: string
titleEn?: string | null
abstract?: string | null
descriptionHtml?: string | null
categoryId?: string | null
status?: WorkshopStatus
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
eventDate?: string | null
eventTime?: string | null
duration?: string | null
}
export function resolveWorkshopTitle(
workshop: Pick<Workshop, 'titleFa' | 'titleEn' | 'title'>,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
return workshop.titleFa || workshop.title
}
return workshop.titleEn?.trim() || workshop.titleFa || workshop.title
}
export function resolveWorkshopCoverImageUrl(workshop: WorkshopApi): string | null {
const featured = workshop.coverImageUrl?.trim()
if (featured) return featured
const galleryUrl = workshop.gallery?.map((item) => item.url?.trim()).find(Boolean)
if (galleryUrl) return galleryUrl
return extractFirstImageUrl(workshop.descriptionHtml) || null
}
export function mapWorkshopApiToUi(workshop: WorkshopApi): Workshop {
return {
id: workshop.id,
title: workshop.titleFa || workshop.title,
titleFa: workshop.titleFa || workshop.title,
titleEn: workshop.titleEn ?? null,
slug: workshop.slug,
abstract: workshop.abstract,
status: workshop.status,
categoryId: workshop.categoryId,
categoryName: workshop.categoryName,
tags: workshop.tags,
coverImageUrl: resolveWorkshopCoverImageUrl(workshop),
registrationCount: workshop.registrationCount ?? 0,
author: workshop.author ?? null,
publishedAt: workshop.publishedAt,
createdAt: workshop.createdAt,
updatedAt: workshop.updatedAt,
eventDate: workshop.eventDate ?? null,
eventTime: workshop.eventTime ?? null,
duration: workshop.duration ?? null,
}
}
export function mapWorkshopDetailFromApi(workshop: WorkshopApi): WorkshopDetail {
return {
...mapWorkshopApiToUi(workshop),
descriptionHtml: workshop.descriptionHtml,
gallery: workshop.gallery ?? [],
}
}
export function formatWorkshopAuthor(author: WorkshopAuthor | null): string {
if (!author) return 'Unknown author'
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
return name || author.email || 'Unknown author'
}
export function formatWorkshopRegistrationName(
user: WorkshopRegistrationUser,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
const faName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
if (faName) return faName
}
const enName = [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ').trim()
if (enName) return enName
const fallback = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return fallback || user.email || '—'
}
export function formatWorkshopDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatWorkshopCardDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function formatWorkshopCardMetaDate(
workshop: Pick<Workshop, 'eventDate' | 'publishedAt' | 'createdAt'>,
locale: 'en' | 'fa' = 'en',
): string {
if (workshop.eventDate) {
return formatWorkshopCardDate(workshop.eventDate, locale)
}
return formatWorkshopCardDate(workshop.publishedAt ?? workshop.createdAt, locale)
}
export function isWorkshopPublished(workshop: Pick<Workshop, 'status'>): boolean {
return workshop.status === 'published'
}
export async function setWorkshopPublished(workshopId: string, published: boolean) {
return updateWorkshop(workshopId, { status: published ? 'published' : 'draft' })
}
export function formatWorkshopDuration(
duration: string | null | undefined,
): string {
const trimmed = duration?.trim()
return trimmed || '—'
}
function normalizeGregorianDate(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''
}
function normalizeTimeInput(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{2}:\d{2})/)?.[1] ?? ''
}
function normalizeWorkshopDuration(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return ''
}
function normalizeRichTextHtml(html: string | null | undefined): string {
const trimmed = html?.trim() ?? ''
if (!trimmed) return ''
if (/<[a-z][\s\S]*>/i.test(trimmed)) return trimmed
return `<p>${trimmed
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</p>`
}
export function mapWorkshopApiToFormState(workshop: WorkshopApi) {
const legacyDuration = (workshop as WorkshopApi & { durationMinutes?: number | null })
.durationMinutes
const gallery = workshop.gallery ?? []
const images: string[] = []
const galleryMediaIds: string[] = []
for (const item of gallery) {
const url = item.url?.trim()
if (!url) continue
images.push(url)
if (item.mediaId) galleryMediaIds.push(item.mediaId)
}
if (galleryMediaIds.length === 0 && workshop.galleryMediaIds?.length) {
galleryMediaIds.push(
...workshop.galleryMediaIds.filter((id) => typeof id === 'string' && id.trim()),
)
}
return {
titleFa: workshop.titleFa || workshop.title,
titleEn: workshop.titleEn ?? '',
abstract: workshop.abstract ?? '',
descriptionHtml: normalizeRichTextHtml(workshop.descriptionHtml),
categoryId: workshop.categoryId ?? '',
tags: Array.isArray(workshop.tags) ? workshop.tags : [],
eventDate: normalizeGregorianDate(workshop.eventDate),
eventTime: normalizeTimeInput(workshop.eventTime),
duration:
normalizeWorkshopDuration(workshop.duration) ||
normalizeWorkshopDuration(legacyDuration),
coverImage: resolveWorkshopCoverImageUrl(workshop),
featuredMediaId: workshop.featuredMediaId,
images,
galleryMediaIds,
}
}
export async function listWorkshops(
page = 1,
pageSize = WORKSHOPS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListWorkshopsParams, 'page' | 'pageSize'>,
) {
const q = new URLSearchParams()
q.set('page', String(page))
q.set('pageSize', String(pageSize))
if (filters?.status) q.set('status', filters.status)
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<WorkshopsListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapWorkshopApiToUi),
}
}
export async function getWorkshop(workshopId: string, signal?: AbortSignal) {
const data = await apiRequest<{ workshop: WorkshopApi }>(businessPath(`/${workshopId}`), {
auth: true,
signal,
})
return data.workshop
}
export async function getWorkshopDetail(workshopId: string, signal?: AbortSignal) {
const workshop = await getWorkshop(workshopId, signal)
return mapWorkshopDetailFromApi(workshop)
}
export async function listWorkshopRegistrations(workshopId: string, signal?: AbortSignal) {
return apiRequest<{ items: WorkshopRegistration[] }>(
businessPath(`/${workshopId}/registrations`),
{ auth: true, signal },
)
}
export async function addWorkshopRegistration(workshopId: string, userId: string) {
return apiRequest<{ message: string; registration: WorkshopRegistration }>(
businessPath(`/${workshopId}/registrations`),
{
method: 'POST',
auth: true,
body: { userId },
},
)
}
export async function updateWorkshopRegistrationStatus(
workshopId: string,
registrationId: string,
status: WorkshopRegistrationStatus,
) {
return apiRequest<{ message: string; registration: WorkshopRegistration }>(
businessPath(`/${workshopId}/registrations/${registrationId}`),
{
method: 'PATCH',
auth: true,
body: { status },
},
)
}
export async function removeWorkshopRegistration(
workshopId: string,
registrationId: string,
) {
return apiRequest<{ message: string }>(
businessPath(`/${workshopId}/registrations/${registrationId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export async function createWorkshop(payload: CreateWorkshopPayload) {
return apiRequest<{ message: string; workshop: WorkshopApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateWorkshop(workshopId: string, payload: UpdateWorkshopPayload) {
return apiRequest<{ message: string; workshop: WorkshopApi }>(
businessPath(`/${workshopId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteWorkshop(workshopId: string) {
return apiRequest<{ message: string }>(businessPath(`/${workshopId}`), {
method: 'DELETE',
auth: true,
})
}