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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isEmptyRichText(html: string | null | undefined): boolean {
|
||||
return richTextToPlain(html).length === 0
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user