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
@@ -0,0 +1,23 @@
.modal {
max-width: 440px;
}
.body {
padding: 16px 24px 24px;
}
.hint {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.error {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.08);
color: #b91c1c;
font-size: 13px;
}
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { BusinessCustomer } from '../services/customerService'
import { useT } from '../i18n/useT'
import { CustomerSearchSelect } from './CustomerSearchSelect'
import modalStyles from './VariationsModal.module.css'
import styles from './AddWorkshopRegistrationModal.module.css'
interface AddWorkshopRegistrationModalProps {
open: boolean
excludeIds: string[]
isAdding: boolean
error?: string
onClose: () => void
onPick: (customer: BusinessCustomer) => void
}
const ANIMATION_MS = 220
export function AddWorkshopRegistrationModal({
open,
excludeIds,
isAdding,
error = '',
onClose,
onPick,
}: AddWorkshopRegistrationModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted) return null
return createPortal(
<div
className={`${modalStyles.overlay} ${modalStyles.overlayNested} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="add-workshop-registration-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={modalStyles.header}>
<h3 id="add-workshop-registration-title" className={modalStyles.title}>
{t('workshop.registrations.add')}
</h3>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<div className={modalStyles.field}>
<label htmlFor="workshop-registration-customer-search">
{t('workshop.registrations.searchLabel')}
</label>
<CustomerSearchSelect
id="workshop-registration-customer-search"
value={null}
onChange={() => undefined}
onPick={onPick}
excludeIds={excludeIds}
disabled={isAdding}
placeholder={t('workshop.registrations.searchPlaceholder')}
/>
</div>
<p className={styles.hint}>{t('workshop.registrations.searchHint')}</p>
</div>
</div>
</div>,
document.body,
)
}
@@ -13,6 +13,8 @@ interface CustomerSearchSelectProps {
disabled?: boolean
placeholder?: string
id?: string
excludeIds?: string[]
onPick?: (customer: BusinessCustomer) => void
}
function customerLabel(customer: BusinessCustomer, withPhone = false) {
@@ -30,6 +32,8 @@ export function CustomerSearchSelect({
disabled = false,
placeholder,
id,
excludeIds = [],
onPick,
}: CustomerSearchSelectProps) {
const t = useT()
const { locale } = useLocale()
@@ -94,11 +98,20 @@ export function CustomerSearchSelect({
}, [query, open])
function selectOption(customer: BusinessCustomer) {
if (excludeIds.includes(customer.id)) return
if (onPick) {
onPick(customer)
setOpen(false)
setQuery('')
return
}
onChange(customer)
setOpen(false)
setQuery('')
}
const visibleResults = results.filter((customer) => !excludeIds.includes(customer.id))
return (
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
<div
@@ -153,10 +166,10 @@ export function CustomerSearchSelect({
<li className={styles.noResults}>{t('issueInvoice.pickCustomerHint')}</li>
) : loading ? (
<li className={styles.noResults}>{t('issueInvoice.loading')}</li>
) : results.length === 0 ? (
) : visibleResults.length === 0 ? (
<li className={styles.noResults}>{t('issueInvoice.pickCustomerNoResults')}</li>
) : (
results.map((customer) => (
visibleResults.map((customer) => (
<li key={customer.id}>
<button
type="button"
@@ -1,53 +0,0 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
padding: 48px 24px;
text-align: center;
border-block: 1px solid color-mix(in srgb, var(--text-muted) 42%, transparent);
}
.icon {
flex-shrink: 0;
color: var(--text-primary);
}
.message {
margin: 0;
max-width: 36rem;
font-family: var(--font-ui);
font-size: 14px;
font-weight: 500;
line-height: 1.7;
color: var(--text-secondary);
}
.action {
appearance: none;
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
font-family: var(--font-ui);
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.action:hover {
text-decoration: underline;
}
.cell {
padding: 0 !important;
background: transparent !important;
border-bottom: none !important;
vertical-align: middle;
}
+9 -51
View File
@@ -1,52 +1,10 @@
import type { LucideIcon } from 'lucide-react'
import { Link } from 'react-router-dom'
import styles from './EmptyState.module.css'
export {
EmptyState,
EmptyStateEmphasis,
EmptyTableRow,
ListEmptyMessage,
buildListEmptyPlainMessage,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
export interface EmptyStateProps {
icon: LucideIcon
message: string
actionLabel?: string
actionTo?: string
onAction?: () => void
}
export function EmptyState({
icon: Icon,
message,
actionLabel,
actionTo,
onAction,
}: EmptyStateProps) {
const showAction = Boolean(actionLabel && (actionTo || onAction))
return (
<div className={styles.root} role="status">
<Icon className={styles.icon} size={36} strokeWidth={1.5} aria-hidden />
<p className={styles.message}>{message}</p>
{showAction && actionLabel ? (
actionTo ? (
<Link to={actionTo} className={styles.action} onClick={onAction}>
{actionLabel}
</Link>
) : (
<button type="button" className={styles.action} onClick={onAction}>
{actionLabel}
</button>
)
) : null}
</div>
)
}
export function EmptyTableRow({
colSpan,
...props
}: EmptyStateProps & { colSpan: number }) {
return (
<tr>
<td colSpan={colSpan} className={styles.cell}>
<EmptyState {...props} />
</td>
</tr>
)
}
export { ListEmptyState } from './ListEmptyState'
@@ -11,8 +11,8 @@
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 120px;
box-sizing: border-box;
min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.4);
@@ -35,8 +35,7 @@
.preview {
position: relative;
width: 100%;
min-height: 120px;
min-height: 0;
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
@@ -83,6 +82,10 @@
}
.cropPanel {
width: fit-content;
max-width: 100%;
margin-inline: auto;
align-self: center;
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
@@ -91,15 +94,8 @@
.cropArea {
position: relative;
width: 100%;
min-height: 160px;
max-height: 420px;
background: #1e293b;
}
.cropAreaPortrait {
max-height: min(360px, 42vh);
min-height: 0;
background: #1e293b;
}
.cropControls {
@@ -162,7 +158,7 @@
.changeBtn {
display: inline-flex;
align-self: flex-start;
align-self: center;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
+11 -14
View File
@@ -80,19 +80,16 @@ export function ImageCropper({
}
const isPortrait = aspect < 1
const frameStyle: React.CSSProperties = isPortrait
? {
aspectRatio: `${aspect}`,
height: 'min(360px, 42vh)',
width: 'auto',
maxWidth: '100%',
marginInline: 'auto',
}
: {
aspectRatio: `${aspect}`,
width: '100%',
maxHeight: 'min(280px, 36vh)',
}
const maxHeight = isPortrait ? 'min(360px, 42vh)' : 'min(280px, 36vh)'
const frameStyle: React.CSSProperties = {
aspectRatio: String(aspect),
width: `min(100%, calc(${maxHeight} * ${aspect}))`,
height: 'auto',
maxHeight,
minHeight: 0,
marginInline: 'auto',
alignSelf: 'center',
}
return (
<div className={styles.wrapper}>
@@ -121,7 +118,7 @@ export function ImageCropper({
{imageSrc && (
<div className={styles.cropPanel}>
<div className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`} style={frameStyle}>
<div className={styles.cropArea} style={frameStyle}>
<Cropper
key={String(aspect)}
image={imageSrc}
@@ -39,8 +39,8 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
return (
<div className={styles.wrapper}>
<div className={styles.grid}>
{images.map((src, index) => (
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}>
{images.filter(Boolean).map((src, index) => (
<div key={`${String(src).slice(0, 32)}-${index}`} className={styles.item}>
<img src={src} alt={t('products.form.images')} />
<button
type="button"
@@ -0,0 +1,98 @@
.wrap {
width: 100%;
}
.input {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.85);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.calendar {
--rmdp-primary: var(--primary);
--rmdp-secondary: rgba(var(--primary-rgb) / 0.12);
--rmdp-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
--rmdp-today: rgba(var(--primary-rgb) / 0.14);
--rmdp-hover: rgba(var(--primary-rgb) / 0.1);
--rmdp-dashed-color: rgba(var(--primary-rgb) / 0.35);
font-family: var(--font-ui);
}
.calendar :global(.rmdp-wrapper) {
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.calendar :global(.rmdp-day) {
position: relative;
width: 34px;
height: 34px;
}
.calendar :global(.rmdp-day span) {
position: absolute;
inset: 3px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 14px;
line-height: 1;
text-align: center;
}
.calendar :global(.rmdp-week-day) {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
line-height: 1;
color: var(--text-secondary);
font-weight: 600;
}
.calendar :global(.rmdp-day.rmdp-selected span:not(.highlight)) {
background-color: var(--primary) !important;
color: #fff !important;
box-shadow: 0 2px 8px rgba(var(--primary-rgb) / 0.35);
}
.calendar :global(.rmdp-day.rmdp-today:not(.rmdp-selected) span) {
background-color: rgba(var(--primary-rgb) / 0.14);
color: var(--primary-dark, var(--primary));
}
.calendar :global(.rmdp-arrow) {
border-color: var(--primary);
}
.calendar :global(.rmdp-arrow-container:hover) {
background-color: rgba(var(--primary-rgb) / 0.1);
box-shadow: none;
}
.calendar :global(.rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden) span:hover) {
background-color: rgba(var(--primary-rgb) / 0.12);
color: var(--text-primary);
}
@@ -0,0 +1,118 @@
import { useEffect, useState, type ComponentType } from 'react'
import MultiDatePickerModule from 'react-multi-date-picker'
import DateObjectModule from 'react-date-object'
import type DateObjectType from 'react-date-object'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
import gregorian from 'react-date-object/calendars/gregorian'
import gregorian_en from 'react-date-object/locales/gregorian_en'
import { useLocale } from '@meshkee/dashboard-ui'
import styles from './JalaliDatePicker.module.css'
function resolveDefaultExport<T>(mod: T): T {
if (
mod &&
typeof mod === 'object' &&
'default' in mod &&
(mod as { default?: unknown }).default
) {
return (mod as { default: T }).default
}
return mod
}
const DatePicker = resolveDefaultExport(MultiDatePickerModule) as ComponentType<
Record<string, unknown>
>
const DateObject = resolveDefaultExport(DateObjectModule) as typeof DateObjectType
interface JalaliDatePickerProps {
id?: string
value: string
onChange: (isoDate: string) => void
disabled?: boolean
placeholder?: string
}
function normalizeGregorianDate(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''
}
function toPickerValue(isoDate: string): DateObjectType | undefined {
const normalized = normalizeGregorianDate(isoDate)
if (!normalized) return undefined
try {
return new DateObject({ date: normalized, format: 'YYYY-MM-DD' }).convert(
persian,
persian_fa,
)
} catch {
return undefined
}
}
function toIsoDate(date: DateObjectType | DateObjectType[] | null): string {
if (!date || Array.isArray(date)) return ''
try {
return date.convert(gregorian, gregorian_en).format('YYYY-MM-DD')
} catch {
return ''
}
}
function JalaliDatePickerInner({
id,
value,
onChange,
disabled = false,
placeholder,
}: JalaliDatePickerProps) {
const { locale } = useLocale()
const isFa = locale === 'fa'
return (
<DatePicker
id={id}
value={toPickerValue(value)}
onChange={(date: DateObjectType | DateObjectType[] | null) => onChange(toIsoDate(date))}
calendar={persian}
locale={persian_fa}
format="YYYY/MM/DD"
disabled={disabled}
placeholder={placeholder}
containerClassName={styles.wrap}
inputClass={styles.input}
className={styles.calendar}
calendarPosition={isFa ? 'bottom-right' : 'bottom-left'}
arrow={false}
editable={false}
/>
)
}
export function JalaliDatePicker(props: JalaliDatePickerProps) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<input
id={props.id}
type="text"
className={styles.input}
value={props.value}
readOnly
disabled={props.disabled}
placeholder={props.placeholder}
aria-hidden="true"
/>
)
}
return <JalaliDatePickerInner {...props} />
}
@@ -0,0 +1,64 @@
import type { LucideIcon } from 'lucide-react'
import {
EmptyState,
ListEmptyMessage,
buildListEmptyPlainMessage,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
type ListEmptyEntity =
| 'workshop'
| 'instruction'
| 'video'
| 'blog'
| 'portfolio'
| 'products'
type ListEmptyStateProps = {
entity: ListEmptyEntity
actionLabel: string
icon?: LucideIcon
} & Pick<EmptyStateProps, 'actionTo' | 'onAction' | 'layout'>
function entityKeys(entity: ListEmptyEntity): {
none: BusinessMessageKey
action: BusinessMessageKey
} {
return {
none: `${entity}.list.emptyNone` as BusinessMessageKey,
action: `${entity}.list.emptyAction` as BusinessMessageKey,
}
}
export function ListEmptyState({
entity,
actionLabel,
actionTo,
onAction,
icon,
layout,
}: ListEmptyStateProps) {
const t = useT()
const keys = entityKeys(entity)
const parts = {
part1: t('empty.list.part1'),
noneLabel: t(keys.none),
part2: t('empty.list.part2'),
actionLabel: t(keys.action),
part3: t('empty.list.part3'),
}
return (
<EmptyState
icon={icon}
message={buildListEmptyPlainMessage(parts)}
messageNode={<ListEmptyMessage {...parts} />}
actionLabel={actionLabel}
actionTo={actionTo}
onAction={onAction}
layout={layout}
/>
)
}
@@ -101,6 +101,18 @@
gap: 20px;
}
.gridCols5 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
@media (min-width: 1600px) {
.gridCols5 {
grid-template-columns: repeat(5, 1fr);
}
}
.gridFour {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -142,14 +154,16 @@
}
@media (max-width: 1400px) {
.gridCols4 {
.gridCols4,
.gridCols5 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1100px) {
.grid,
.gridCols4 {
.gridCols4,
.gridCols5 {
grid-template-columns: repeat(2, 1fr);
}
@@ -190,7 +204,8 @@
.grid,
.gridHome,
.gridFour,
.gridCols4 {
.gridCols4,
.gridCols5 {
grid-template-columns: 1fr;
gap: 16px;
}
@@ -4,7 +4,9 @@ import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
import { listAllStoreItems } from '../services/storeItemService'
import type { SpecialProductsSource } from '../services/settingsService'
import { groupStoreItemsByProduct } from '../utils/storeProductGroups'
import {
StoreItemMultiSearchSelect,
@@ -16,9 +18,11 @@ import styles from './PickStoreSpecialItemsModal.module.css'
interface PickStoreSpecialItemsModalProps {
open: boolean
specialTitle: string
existingStoreItemIds: string[]
/** Store item ids when source is store_item; product ids when source is product. */
existingIds: string[]
source: SpecialProductsSource
onClose: () => void
onConfirm: (storeItemIds: string[]) => void
onConfirm: (ids: string[]) => void
isSubmitting?: boolean
}
@@ -27,7 +31,8 @@ const ANIMATION_MS = 220
export function PickStoreSpecialItemsModal({
open,
specialTitle,
existingStoreItemIds,
existingIds,
source,
onClose,
onConfirm,
isSubmitting = false,
@@ -35,6 +40,7 @@ export function PickStoreSpecialItemsModal({
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const fromProducts = source === 'product'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
@@ -43,7 +49,7 @@ export function PickStoreSpecialItemsModal({
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [selectOpen, setSelectOpen] = useState(false)
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds])
const existingSet = useMemo(() => new Set(existingIds), [existingIds])
useEffect(() => {
if (open) {
@@ -67,7 +73,7 @@ export function PickStoreSpecialItemsModal({
const controller = new AbortController()
void loadOptions(controller.signal)
return () => controller.abort()
}, [open, existingStoreItemIds])
}, [open, existingIds, source])
useEffect(() => {
if (!mounted || closing) return
@@ -83,51 +89,68 @@ export function PickStoreSpecialItemsModal({
setError('')
try {
const items = await listAllStoreItems(signal)
const listings = groupStoreItemsByProduct(items)
const nextOptions = listings
.map(
(listing): StoreItemSearchOption | null => {
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
const firstPricedVariant = listing.variants.find((v) => v.price !== null) ?? null
const price = firstPricedVariant?.price ?? null
const hasPrice = listing.variants.some((v) => v.price !== null)
const hasInStock = listing.variants.some(
(v) => v.stockQuantity === null || v.stockQuantity > 0,
)
const nextOptions = fromProducts
? await loadProductOptions(signal)
: await loadStoreItemOptions(signal)
if (!hasPrice && !hasInStock) return null
return {
id: storeItemId,
title: listing.productTitle,
nameFa: listing.productNameFa,
image: listing.productImage,
price,
}
},
)
.filter((option): option is StoreItemSearchOption => option !== null)
.filter((option) => !existingSet.has(option.id))
setOptions(nextOptions)
setOptions(nextOptions.filter((option) => !existingSet.has(option.id)))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.specialItems.pickErrorLoad'))
setError(
fromProducts
? t('website.specialItems.pickErrorLoadProducts')
: t('website.specialItems.pickErrorLoad'),
)
}
} finally {
setIsLoading(false)
}
}
function toggleSelection(storeItemId: string) {
async function loadStoreItemOptions(signal?: AbortSignal) {
const items = await listAllStoreItems(signal)
const listings = groupStoreItemsByProduct(items)
return listings
.map((listing): StoreItemSearchOption | null => {
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
const firstPricedVariant = listing.variants.find((v) => v.price !== null) ?? null
const price = firstPricedVariant?.price ?? null
const hasPrice = listing.variants.some((v) => v.price !== null)
const hasInStock = listing.variants.some(
(v) => v.stockQuantity === null || v.stockQuantity > 0,
)
if (!hasPrice && !hasInStock) return null
return {
id: storeItemId,
title: listing.productTitle,
nameFa: listing.productNameFa,
image: listing.productImage,
price,
}
})
.filter((option): option is StoreItemSearchOption => option !== null)
}
async function loadProductOptions(signal?: AbortSignal) {
const products = await listAllProducts(signal)
return products.map(
(product): StoreItemSearchOption => ({
id: product.id,
title: product.title,
nameFa: product.nameFa,
image: product.thumbnailUrl || product.thumbnail || product.image || null,
}),
)
}
function toggleSelection(id: string) {
setSelectedIds((prev) =>
prev.includes(storeItemId)
? prev.filter((id) => id !== storeItemId)
: [...prev, storeItemId],
prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id],
)
}
@@ -167,7 +190,11 @@ export function PickStoreSpecialItemsModal({
<h2 id="pick-special-items-title" className={modalStyles.title}>
{t('website.pick.addTo', { title: specialTitle })}
</h2>
<p className={modalStyles.subtitle}>{t('website.specialItems.pickSubtitle')}</p>
<p className={modalStyles.subtitle}>
{fromProducts
? t('website.specialItems.pickSubtitleProducts')
: t('website.specialItems.pickSubtitle')}
</p>
</div>
<button
type="button"
@@ -183,15 +210,27 @@ export function PickStoreSpecialItemsModal({
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>{t('website.specialItems.pickLoading')}</p>
<p className={styles.empty}>
{fromProducts
? t('website.specialItems.pickLoadingProducts')
: t('website.specialItems.pickLoading')}
</p>
) : options.length === 0 ? (
<p className={styles.empty}>{t('website.specialItems.pickEmpty')}</p>
<p className={styles.empty}>
{fromProducts
? t('website.specialItems.pickEmptyProducts')
: t('website.specialItems.pickEmpty')}
</p>
) : (
<StoreItemMultiSearchSelect
options={options}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder={t('website.specialItems.pickSearch')}
placeholder={
fromProducts
? t('website.specialItems.pickSearchProducts')
: t('website.specialItems.pickSearch')
}
disabled={isSubmitting}
onOpenChange={setSelectOpen}
/>
@@ -212,8 +251,7 @@ export function PickStoreSpecialItemsModal({
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -1,143 +0,0 @@
.wrapper {
width: 100%;
max-width: 100%;
min-width: 0;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.wrapper:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(248, 250, 252, 0.8);
}
.toolbar button {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.toolbar button:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.divider {
width: 1px;
height: 20px;
background: rgba(148, 163, 184, 0.3);
margin: 0 4px;
}
.editorShell {
position: relative;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.editor {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 140px;
padding: var(--field-padding-y) var(--field-padding-x);
padding-bottom: 40px;
font-size: var(--field-font-size);
line-height: 1.5;
color: var(--text-primary);
outline: none;
box-sizing: border-box;
overflow-x: hidden;
overflow-wrap: break-word;
word-break: break-word;
unicode-bidi: isolate;
}
.editor:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
.editor > * {
max-width: 100%;
box-sizing: border-box;
}
.editor ul,
.editor ol {
padding-inline-start: 1.5em;
padding-inline-end: 0;
margin: 0.5em 0;
}
.editor p {
margin: 0 0 0.5em;
}
.editor p:last-child {
margin-bottom: 0;
}
.editor blockquote {
margin: 0.5em 0;
margin-inline-start: 1.5em;
margin-inline-end: 0;
padding: 0;
border: 0;
}
.editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
margin: 0.5em 0;
cursor: pointer;
}
.editor img.richTextImageSelected {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.resizeHandle {
position: absolute;
width: 12px;
height: 12px;
border: 2px solid white;
border-radius: 3px;
background: var(--primary);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
cursor: nwse-resize;
z-index: 2;
padding: 0;
}
.uploadError {
margin: 0;
padding: 6px 10px;
font-size: 12px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border-bottom: 1px solid rgba(239, 68, 68, 0.15);
}
+25 -360
View File
@@ -1,369 +1,34 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
Underline,
List,
ListOrdered,
ImagePlus,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
RichTextEditor as BaseRichTextEditor,
isEmptyRichText,
richTextToPlain,
type RichTextEditorProps,
} from '@meshkee/dashboard-ui'
import { uploadMediaFiles } from '../services/mediaService'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
allowImages?: boolean
editorMinHeight?: number
}
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
export { isEmptyRichText, richTextToPlain }
export type { RichTextEditorProps }
export function RichTextEditor({
value,
onChange,
placeholder,
allowImages = false,
editorMinHeight = 140,
allowImages,
onImageUpload,
...props
}: RichTextEditorProps) {
const { locale, dir } = useLocale()
const editorShellRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragRef = useRef<{ startX: number; startWidth: number; img: HTMLImageElement } | null>(
null,
)
const [uploadingImage, setUploadingImage] = useState(false)
const [uploadError, setUploadError] = useState('')
const [selectedImageEl, setSelectedImageEl] = useState<HTMLImageElement | null>(null)
const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(null)
const syncChange = useCallback(() => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML)
}
}, [onChange])
const updateHandlePosition = useCallback((img: HTMLImageElement | null) => {
if (!img || !editorShellRef.current) {
setHandlePos(null)
return
}
const shellRect = editorShellRef.current.getBoundingClientRect()
const imgRect = img.getBoundingClientRect()
setHandlePos({
top: imgRect.bottom - shellRect.top - 6,
left: imgRect.right - shellRect.left - 6,
})
}, [])
const clearImageSelection = useCallback(() => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((img) => {
img.classList.remove(SELECTED_IMAGE_CLASS)
})
setSelectedImageEl(null)
setHandlePos(null)
}, [])
const selectImage = useCallback(
(img: HTMLImageElement) => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
node.classList.remove(SELECTED_IMAGE_CLASS)
})
img.classList.add(SELECTED_IMAGE_CLASS)
setSelectedImageEl(img)
updateHandlePosition(img)
},
[updateHandlePosition],
)
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value
clearImageSelection()
}
}, [value, clearImageSelection])
useEffect(() => {
if (!allowImages) return
const editor = editorRef.current
if (!editor) return
function onEditorClick(e: MouseEvent) {
const target = e.target
if (!(target instanceof HTMLImageElement) || !editor!.contains(target)) return
e.preventDefault()
selectImage(target)
}
function onDocumentClick(e: MouseEvent) {
const target = e.target as Node
if (editor!.contains(target)) return
if (target instanceof Element && target.closest(`.${styles.resizeHandle}`)) return
clearImageSelection()
}
editor.addEventListener('click', onEditorClick)
document.addEventListener('click', onDocumentClick)
return () => {
editor.removeEventListener('click', onEditorClick)
document.removeEventListener('click', onDocumentClick)
}
}, [allowImages, selectImage, clearImageSelection])
useEffect(() => {
if (!selectedImageEl) return
function refreshHandle() {
if (selectedImageEl?.isConnected) {
updateHandlePosition(selectedImageEl)
}
}
window.addEventListener('resize', refreshHandle)
editorRef.current?.addEventListener('scroll', refreshHandle)
return () => {
window.removeEventListener('resize', refreshHandle)
editorRef.current?.removeEventListener('scroll', refreshHandle)
}
}, [selectedImageEl, updateHandlePosition])
function exec(cmd: string, arg?: string) {
document.execCommand(cmd, false, arg)
editorRef.current?.focus()
syncChange()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
let node: Node | null = selection.anchorNode
while (node && node !== editor) {
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
return node
}
node = node.parentNode
}
return null
}
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (!block) {
document.execCommand(direction > 0 ? 'indent' : 'outdent')
syncChange()
return
}
const current =
Number.parseFloat(block.style.paddingInlineStart) ||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
0
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
if (next === 0) {
block.style.paddingInlineStart = ''
} else {
block.style.paddingInlineStart = `${next}px`
}
syncChange()
}
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
e.preventDefault()
changeIndent(e.shiftKey ? -1 : 1)
}
function styleInsertedImage(img: HTMLImageElement) {
img.style.width = '100%'
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.draggable = false
}
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !editorRef.current) return
setUploadingImage(true)
setUploadError('')
try {
const uploaded = await uploadMediaFiles([file])
const url = uploaded[0]?.publicUrl
if (!url) throw new Error('Image upload failed.')
editorRef.current.focus()
document.execCommand('insertImage', false, url)
const imgs = editorRef.current.querySelectorAll('img')
const lastImg = imgs[imgs.length - 1]
if (lastImg instanceof HTMLImageElement) {
styleInsertedImage(lastImg)
selectImage(lastImg)
}
syncChange()
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Unable to upload image.')
} finally {
setUploadingImage(false)
}
}
function onResizeStart(e: React.MouseEvent) {
if (!selectedImageEl || !editorRef.current) return
e.preventDefault()
e.stopPropagation()
dragRef.current = {
startX: e.clientX,
startWidth: selectedImageEl.offsetWidth,
img: selectedImageEl,
}
function onMove(ev: MouseEvent) {
if (!dragRef.current || !editorRef.current) return
const delta = ev.clientX - dragRef.current.startX
const maxWidth = editorRef.current.clientWidth - 16
const nextWidth = Math.max(80, Math.min(maxWidth, dragRef.current.startWidth + delta))
dragRef.current.img.style.width = `${nextWidth}px`
dragRef.current.img.style.height = 'auto'
dragRef.current.img.style.maxWidth = '100%'
updateHandlePosition(dragRef.current.img)
}
function onUp() {
dragRef.current = null
syncChange()
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
return (
<div className={styles.wrapper} dir={dir} lang={locale}>
<div className={styles.toolbar} dir="ltr">
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<Bold size={16} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<Italic size={16} />
</button>
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
<Underline size={16} />
</button>
<span className={styles.divider} />
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<List size={16} />
</button>
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<ListOrdered size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={16} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={16} />
</button>
{allowImages && (
<>
<span className={styles.divider} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
title="Insert image"
aria-label="Insert image"
>
<ImagePlus size={16} />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleImageSelect}
/>
</>
)}
</div>
{uploadError && (
<p className={styles.uploadError} role="alert">
{uploadError}
</p>
)}
<div ref={editorShellRef} className={styles.editorShell}>
<div
ref={editorRef}
className={styles.editor}
style={{ minHeight: editorMinHeight }}
contentEditable
role="textbox"
aria-multiline="true"
dir={dir}
lang={locale}
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning
/>
{allowImages && selectedImageEl && handlePos && (
<button
type="button"
className={styles.resizeHandle}
style={{ top: handlePos.top, left: handlePos.left }}
onMouseDown={onResizeStart}
aria-label="Resize image"
/>
)}
</div>
</div>
<BaseRichTextEditor
{...props}
allowImages={allowImages}
onImageUpload={
allowImages
? onImageUpload ??
(async (file) => {
const uploaded = await uploadMediaFiles([file])
const url = uploaded[0]?.publicUrl
if (!url) throw new Error('Image upload failed.')
return url
})
: undefined
}
/>
)
}
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}
+10 -2
View File
@@ -68,7 +68,12 @@ function isGroupActive(group: NavGroup, pathname: string) {
return true
}
if (group.id === 'content') {
if (pathname.startsWith('/blog') || pathname.startsWith('/videos') || pathname.startsWith('/instructions')) {
if (
pathname.startsWith('/blog') ||
pathname.startsWith('/videos') ||
pathname.startsWith('/instructions') ||
pathname.startsWith('/workshops')
) {
return true
}
}
@@ -183,6 +188,9 @@ export function Sidebar({ open = false }: SidebarProps) {
...(hasBusinessModule(enabledModules, 'instructions')
? [{ labelKey: 'nav.content.instructions' as BusinessMessageKey, to: '/instructions' }]
: []),
...(hasBusinessModule(enabledModules, 'workshops')
? [{ labelKey: 'nav.workshops' as BusinessMessageKey, to: '/workshops' }]
: []),
],
},
{
@@ -222,7 +230,7 @@ export function Sidebar({ open = false }: SidebarProps) {
return items.filter((item) => {
if (item.type === 'group' && item.id === 'content') {
return hasAnyBusinessModule(enabledModules, ['blog', 'videos', 'instructions'])
return hasAnyBusinessModule(enabledModules, ['blog', 'videos', 'instructions', 'workshops'])
}
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
})
@@ -2,7 +2,7 @@
position: fixed;
bottom: 24px;
left: 24px;
z-index: 300;
z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
@@ -20,13 +20,23 @@
.cell {
flex: 0 0 auto;
display: flex;
scroll-snap-align: start;
min-width: 0;
}
.cellCompact {
align-self: stretch;
}
.cell > span {
display: block;
display: flex;
width: 100%;
min-height: 0;
}
.cellCompact > span {
height: 100%;
}
.cell > span > button {
@@ -154,6 +164,11 @@
transform: translateY(-2px);
}
.addCardCompact {
height: 100%;
min-height: 0;
}
.addIcon {
width: 44px;
height: 44px;
@@ -163,3 +178,8 @@
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1);
}
.addIconCompact {
width: 32px;
height: 32px;
}
@@ -36,7 +36,6 @@ export function WebsiteStaticImageGallery({
(a, b) => a.sortOrder - b.sortOrder || Number(a.id) - Number(b.id),
)
const fixedCount = itemCount != null && itemCount > 0 ? itemCount : null
const cellCount = fixedCount ?? sorted.length + 1
const assigned = new Map<number, WebsiteStaticImageItem>()
for (const image of sorted) {
@@ -59,13 +58,23 @@ export function WebsiteStaticImageGallery({
}
}
const cells = Array.from({ length: cellCount }, (_, index) => {
const isExtraAdd = !fixedCount && index === cellCount - 1
const image = isExtraAdd
? undefined
: (assigned.get(index) ?? (fixedCount ? undefined : sorted[index]))
return { index, image, isExtraAdd }
})
const imageCells = fixedCount
? Array.from({ length: fixedCount }, (_, index) => ({
index,
image: assigned.get(index),
isExtraAdd: false,
}))
: sorted.map((image, index) => ({
index,
image,
isExtraAdd: false,
}))
const addCell = !fixedCount
? { index: sorted.length, image: undefined, isExtraAdd: true }
: null
const compactAdd = Boolean(addCell && sorted.length > 0)
const cells = addCell ? [addCell, ...imageCells] : imageCells
const compactAddWidth = 56
return (
<div className={styles.track}>
@@ -85,11 +94,13 @@ export function WebsiteStaticImageGallery({
subtext ? { key: t('website.staticImages.subtext'), value: subtext } : null,
].filter((row): row is { key: string; value: string } => row != null)
const compact = isExtraAdd && compactAdd
return (
<div
key={`${index}-${image?.id ?? 'empty'}`}
className={styles.cell}
style={{ width: size.width }}
className={`${styles.cell} ${compact ? styles.cellCompact : ''}`}
style={{ width: compact ? compactAddWidth : size.width }}
>
{image ? (
<article className={styles.card}>
@@ -183,13 +194,17 @@ export function WebsiteStaticImageGallery({
<Tooltip label={label}>
<button
type="button"
className={styles.addCard}
className={`${styles.addCard} ${compact ? styles.addCardCompact : ''}`}
onClick={() => onAddAt(index)}
aria-label={label}
style={{ aspectRatio: cssAspect, minHeight: size.height }}
style={
compact
? { minHeight: size.height }
: { aspectRatio: cssAspect, minHeight: size.height }
}
>
<span className={styles.addIcon}>
<Plus size={28} />
<span className={`${styles.addIcon} ${compact ? styles.addIconCompact : ''}`}>
<Plus size={compact ? 20 : 28} />
</span>
</button>
</Tooltip>
@@ -0,0 +1,164 @@
import { Link } from 'react-router-dom'
import { Pencil, Users, Trash2, BadgeCheck, Clock } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Workshop } from '../types/workshop'
import {
formatWorkshopAuthor,
formatWorkshopCardMetaDate,
isWorkshopPublished,
} from '../services/workshopService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
interface WorkshopCardProps {
workshop: Workshop
registrationCount: number
onEdit: (id: string) => void
onRegistrations: (id: string) => void
onTogglePublished: (id: string) => void
onRemove: (id: string) => void
isTogglingStatus?: boolean
}
export function WorkshopCard({
workshop,
registrationCount,
onEdit,
onRegistrations,
onTogglePublished,
onRemove,
isTogglingStatus = false,
}: WorkshopCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const published = isWorkshopPublished(workshop)
const dateLocale = isFa ? 'fa' : 'en'
const metaDate = formatWorkshopCardMetaDate(workshop, dateLocale)
const eventTime = workshop.eventTime?.trim() || null
const titleLocale = textLocaleAttrs(workshop.title)
const abstractLocale = textLocaleAttrs(workshop.abstract)
const hasAbstract = Boolean(workshop.abstract?.trim())
const statusLabel = published ? t('workshop.card.unpublish') : t('workshop.card.publish')
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
return (
<article className={cardStyles.card}>
<Link to={`/workshops/detail/${workshop.id}`} className={cardStyles.clickable}>
<div className={cardStyles.imageWrap}>
{workshop.coverImageUrl ? (
<img
src={workshop.coverImageUrl}
alt={workshop.title}
className={cardStyles.image}
loading="lazy"
/>
) : (
<div className={cardStyles.imagePlaceholder} aria-hidden="true" />
)}
{!published && (
<span className={`${cardStyles.statusBadge} ${cardStyles.waitingBadge}`}>
<Clock size={11} aria-hidden="true" />
{t('workshop.card.draft')}
</span>
)}
{published && (
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
{t('workshop.card.published')}
</span>
)}
</div>
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
lang={titleLocale.lang}
dir={titleDir}
>
{workshop.title}
</h3>
{hasAbstract ? (
<p
className={[cardStyles.abstract, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractDir}
>
{workshop.abstract}
</p>
) : (
<p
className={cardStyles.abstract}
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{t('workshop.card.noSummary')}
</p>
)}
<p className={cardStyles.meta}>
<span>{formatWorkshopAuthor(workshop.author)}</span>
<span className={cardStyles.metaDot}>·</span>
<span>{metaDate}</span>
{eventTime ? (
<>
<span className={cardStyles.metaDot}>·</span>
<span dir="ltr">{eventTime}</span>
</>
) : null}
</p>
</div>
</Link>
<div className={controlStyles.controls}>
<Tooltip label={t('workshop.card.edit')}>
<button
type="button"
onClick={() => onEdit(workshop.id)}
aria-label={t('workshop.card.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={statusLabel}>
<button
type="button"
className={published ? cardStyles.verifyActive : cardStyles.verifyWaiting}
disabled={isTogglingStatus}
onClick={() => onTogglePublished(workshop.id)}
aria-label={statusLabel}
>
{published ? <BadgeCheck size={16} /> : <Clock size={16} />}
</button>
</Tooltip>
<Tooltip label={t('workshop.card.registrations')}>
<button
type="button"
className={controlStyles.iconBtn}
onClick={() => onRegistrations(workshop.id)}
aria-label={`${t('workshop.card.registrations')} (${registrationCount})`}
>
<Users size={16} />
{registrationCount > 0 && (
<span className={controlStyles.commentBadge}>{registrationCount}</span>
)}
</button>
</Tooltip>
<Tooltip label={t('workshop.card.remove')}>
<button
type="button"
className={controlStyles.danger}
onClick={() => onRemove(workshop.id)}
aria-label={t('workshop.card.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,68 @@
import type { Category } from '../types/category'
import { BlogCategoryRow } from './BlogCategoryRow'
import { getChildren, hasChildren } from '../utils/categories'
import styles from './CategoryTree.module.css'
interface WorkshopCategoryTreeProps {
categories: Category[]
expandedIds: Set<string>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
}
export function WorkshopCategoryTree({
categories,
expandedIds,
onToggle,
onAddSub,
onEdit,
onRemove,
parentId = null,
depth = 0,
}: WorkshopCategoryTreeProps) {
const nodes = getChildren(categories, parentId)
return (
<>
{nodes.map((category) => {
const childCount = hasChildren(categories, category.id)
const expanded = expandedIds.has(category.id)
return (
<div key={category.id}>
<BlogCategoryRow
category={category}
depth={depth}
hasChildren={childCount}
expanded={expanded}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
/>
{childCount && (
<div className={`${styles.childrenWrap} ${expanded ? styles.expanded : ''}`}>
<div className={styles.childrenInner}>
<WorkshopCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={onToggle}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
/>
</div>
</div>
)}
</div>
)
})}
</>
)
}
@@ -0,0 +1,219 @@
.list {
display: flex;
flex-direction: column;
gap: 0;
}
.tableWrap {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th,
.table td {
padding: 10px 12px;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.06);
}
.table td {
color: var(--text-primary);
vertical-align: middle;
}
.actionsCol {
width: 52px;
}
.cellCol {
text-align: right;
white-space: nowrap;
}
.table th.cellCol,
.table td.cellCol {
text-align: right;
}
.statusCol {
width: 120px;
}
.statusCell {
vertical-align: middle;
}
.statusControls {
display: flex;
align-items: center;
gap: 4px;
}
.statusBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 8px;
transition: background 0.2s, color 0.2s, opacity 0.2s;
}
.statusBtn:disabled:not(.statusBtnActive) {
opacity: 0.45;
cursor: not-allowed;
}
.statusBtnActive:disabled {
opacity: 1;
cursor: default;
}
.statusBtnPending {
color: #0f172a;
}
.statusBtnPending:hover:not(:disabled) {
background: rgba(15, 23, 42, 0.08);
color: #0f172a;
}
.statusBtnPending.statusBtnActive {
background: rgba(15, 23, 42, 0.12);
color: #0f172a;
}
.statusBtnApprove {
color: #15803d;
}
.statusBtnApprove:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
}
.statusBtnApprove.statusBtnActive {
background: rgba(34, 197, 94, 0.18);
color: #15803d;
}
.statusBtnReject {
color: #dc2626;
}
.statusBtnReject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.statusBtnReject.statusBtnActive {
background: rgba(239, 68, 68, 0.14);
color: #dc2626;
}
.actionsCell {
text-align: center;
}
.empty {
margin: 0;
padding: 24px 0;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
.error {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.08);
color: #b91c1c;
font-size: 13px;
}
.loading {
margin: 0;
padding: 24px 0;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
.modal {
position: relative;
max-width: 980px;
}
.content {
position: relative;
}
.body {
padding: 16px 18px 88px;
overflow-y: auto;
max-height: min(60vh, 520px);
}
.fabDock {
position: absolute;
inset-inline-end: 18px;
bottom: 18px;
z-index: 3;
}
.addFab {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.addFab:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.addFabActive {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.5);
}
@media (max-width: 768px) {
.body {
padding-bottom: 80px;
}
.fabDock {
inset-inline-end: 14px;
bottom: 14px;
}
.addFab {
width: 52px;
height: 52px;
}
}
@@ -0,0 +1,378 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X, Clock, Check, XCircle } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
addWorkshopRegistration,
formatWorkshopDate,
formatWorkshopRegistrationName,
listWorkshopRegistrations,
removeWorkshopRegistration,
updateWorkshopRegistrationStatus,
type WorkshopRegistration,
type WorkshopRegistrationStatus,
} from '../services/workshopService'
import type { BusinessCustomer } from '../services/customerService'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { AddWorkshopRegistrationModal } from './AddWorkshopRegistrationModal'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
import styles from './WorkshopRegistrationsModal.module.css'
interface WorkshopRegistrationsModalProps {
open: boolean
workshopId: string
workshopTitle: string
onClose: () => void
onRegistrationAdded?: () => void
onRegistrationRemoved?: () => void
}
const ANIMATION_MS = 220
const STATUS_CONTROLS: {
status: WorkshopRegistrationStatus
icon: typeof Clock
labelKey: 'workshop.registrations.status.pending' | 'workshop.registrations.status.approved' | 'workshop.registrations.status.rejected'
className: 'statusBtnPending' | 'statusBtnApprove' | 'statusBtnReject'
}[] = [
{
status: 'pending',
icon: Clock,
labelKey: 'workshop.registrations.status.pending',
className: 'statusBtnPending',
},
{
status: 'approved',
icon: Check,
labelKey: 'workshop.registrations.status.approved',
className: 'statusBtnApprove',
},
{
status: 'rejected',
icon: XCircle,
labelKey: 'workshop.registrations.status.rejected',
className: 'statusBtnReject',
},
]
export function WorkshopRegistrationsModal({
open,
workshopId,
workshopTitle,
onClose,
onRegistrationAdded,
onRegistrationRemoved,
}: WorkshopRegistrationsModalProps) {
const t = useT()
const { showToast } = useToast()
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [registrations, setRegistrations] = useState<WorkshopRegistration[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [addError, setAddError] = useState('')
const [addOpen, setAddOpen] = useState(false)
const [isAdding, setIsAdding] = useState(false)
const [updatingId, setUpdatingId] = useState<string | null>(null)
const [removingId, setRemovingId] = useState<string | null>(null)
const registeredUserIds = useMemo(
() => registrations.map((registration) => registration.userId),
[registrations],
)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
setAddOpen(false)
setAddError('')
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open || !workshopId) return
const controller = new AbortController()
void loadRegistrations(controller.signal)
return () => controller.abort()
}, [open, workshopId])
async function loadRegistrations(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listWorkshopRegistrations(workshopId, signal)
setRegistrations(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.registrations.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function handleAddCustomer(customer: BusinessCustomer) {
setIsAdding(true)
setAddError('')
try {
const result = await addWorkshopRegistration(workshopId, customer.id)
setRegistrations((current) => [result.registration, ...current])
setAddOpen(false)
showToast(t('workshop.registrations.toast.added'), 'success')
onRegistrationAdded?.()
} catch (err) {
if (err instanceof ApiError) {
setAddError(err.message)
} else {
setAddError(t('workshop.registrations.errorAdd'))
}
} finally {
setIsAdding(false)
}
}
function openAddModal() {
setAddError('')
setAddOpen(true)
}
function closeAddModal() {
if (isAdding) return
setAddOpen(false)
setAddError('')
}
async function handleStatusChange(
registrationId: string,
status: WorkshopRegistrationStatus,
) {
setUpdatingId(registrationId)
setError('')
try {
const result = await updateWorkshopRegistrationStatus(
workshopId,
registrationId,
status,
)
setRegistrations((current) =>
current.map((item) =>
item.id === registrationId ? result.registration : item,
),
)
showToast(t('workshop.registrations.toast.statusUpdated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.registrations.errorStatus'))
}
} finally {
setUpdatingId(null)
}
}
async function handleRemove(registrationId: string) {
setRemovingId(registrationId)
setError('')
try {
await removeWorkshopRegistration(workshopId, registrationId)
setRegistrations((current) => current.filter((item) => item.id !== registrationId))
showToast(t('workshop.registrations.toast.removed'), 'success')
onRegistrationRemoved?.()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.registrations.errorRemove'))
}
} finally {
setRemovingId(null)
}
}
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !addOpen) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, addOpen, onClose])
if (!mounted) return null
return createPortal(
<>
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="workshop-registrations-title"
lang={locale === 'fa' ? 'fa' : undefined}
dir={locale === 'fa' ? 'rtl' : undefined}
>
<div className={modalStyles.header}>
<div>
<h2 id="workshop-registrations-title" className={modalStyles.title}>
{t('workshop.registrations.title')}
</h2>
<p className={modalStyles.subtitle}>{workshopTitle}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={styles.content}>
<div className={styles.body}>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.loading}>{t('workshop.registrations.loading')}</p>
) : registrations.length === 0 ? (
<p className={styles.empty}>{t('workshop.registrations.empty')}</p>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('workshop.registrations.col.name')}</th>
<th className={styles.cellCol}>{t('workshop.registrations.col.cell')}</th>
<th>{t('workshop.registrations.col.date')}</th>
<th className={styles.statusCol}>{t('workshop.registrations.col.status')}</th>
<th className={styles.actionsCol} />
</tr>
</thead>
<tbody>
{registrations.map((registration) => (
<tr key={registration.id}>
<td>
{formatWorkshopRegistrationName(registration.user, dateLocale)}
</td>
<td className={styles.cellCol} dir="ltr">
{registration.user.cellNumber
? formatCellForDisplay(registration.user.cellNumber)
: '—'}
</td>
<td>{formatWorkshopDate(registration.createdAt, dateLocale)}</td>
<td className={styles.statusCell}>
<div className={styles.statusControls}>
{STATUS_CONTROLS.map(({ status, icon: Icon, labelKey, className }) => {
const isActive = registration.status === status
const isBusy =
updatingId === registration.id || removingId === registration.id
return (
<Tooltip key={status} label={t(labelKey)}>
<button
type="button"
className={[
styles.statusBtn,
styles[className],
isActive ? styles.statusBtnActive : '',
]
.filter(Boolean)
.join(' ')}
disabled={isBusy || isActive}
onClick={() => handleStatusChange(registration.id, status)}
aria-label={t(labelKey)}
aria-pressed={isActive}
>
<Icon size={16} />
</button>
</Tooltip>
)
})}
</div>
</td>
<td className={styles.actionsCell}>
<Tooltip label={t('workshop.registrations.remove')}>
<button
type="button"
className={modalStyles.removeRowBtn}
onClick={() => handleRemove(registration.id)}
disabled={
removingId === registration.id || updatingId === registration.id
}
aria-label={t('workshop.registrations.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className={styles.fabDock}>
<Tooltip label={t('workshop.registrations.add')}>
<button
type="button"
className={[styles.addFab, addOpen ? styles.addFabActive : '']
.filter(Boolean)
.join(' ')}
onClick={openAddModal}
disabled={isAdding}
aria-label={t('workshop.registrations.add')}
aria-haspopup="dialog"
aria-expanded={addOpen}
>
<Plus size={22} />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
<AddWorkshopRegistrationModal
open={addOpen}
excludeIds={registeredUserIds}
isAdding={isAdding}
error={addError}
onClose={closeAddModal}
onPick={handleAddCustomer}
/>
</>,
document.body,
)
}