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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user