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
@@ -15,6 +15,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-easy-crop": "^6.2.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -60,6 +60,11 @@ import { AddNewInstructionPage } from './pages/AddNewInstructionPage'
|
||||
import { InstructionCategoriesPage } from './pages/InstructionCategoriesPage'
|
||||
import { InstructionSettingsPage } from './pages/InstructionSettingsPage'
|
||||
import { InstructionDetailsPage } from './pages/InstructionDetailsPage'
|
||||
import { WorkshopPage } from './pages/WorkshopPage'
|
||||
import { WorkshopListPage } from './pages/WorkshopListPage'
|
||||
import { AddNewWorkshopPage } from './pages/AddNewWorkshopPage'
|
||||
import { WorkshopCategoriesPage } from './pages/WorkshopCategoriesPage'
|
||||
import { WorkshopDetailsPage } from './pages/WorkshopDetailsPage'
|
||||
import { ContentPage } from './pages/ContentPage'
|
||||
import { WebsitePage } from './pages/WebsitePage'
|
||||
import { WebsiteContactPage } from './pages/WebsiteContactPage'
|
||||
@@ -146,6 +151,12 @@ function App() {
|
||||
<Route path="instructions/edit/:id" element={<AddNewInstructionPage />} />
|
||||
<Route path="instructions/categories" element={<InstructionCategoriesPage />} />
|
||||
<Route path="instructions/settings" element={<InstructionSettingsPage />} />
|
||||
<Route path="workshops" element={<WorkshopPage />} />
|
||||
<Route path="workshops/list" element={<WorkshopListPage />} />
|
||||
<Route path="workshops/detail/:id" element={<WorkshopDetailsPage />} />
|
||||
<Route path="workshops/new" element={<AddNewWorkshopPage />} />
|
||||
<Route path="workshops/edit/:id" element={<AddNewWorkshopPage />} />
|
||||
<Route path="workshops/categories" element={<WorkshopCategoriesPage />} />
|
||||
<Route path="website" element={<WebsitePage />} />
|
||||
<Route path="website/static-images" element={<WebsiteStaticImagesPage />} />
|
||||
<Route path="website/special-items" element={<StoreSpecialsPage />} />
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from '../components/Toast.module.css'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{createPortal(
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ const en = {
|
||||
'app.poweredBy': 'powered by Meshkee.app',
|
||||
'app.userFallback': 'User',
|
||||
|
||||
'empty.list.part1': 'You currently have ',
|
||||
'empty.list.part2': '. To start ',
|
||||
'empty.list.part3': ', use the button below.',
|
||||
|
||||
'role.superAdmin': 'Super Admin',
|
||||
'role.owner': 'Business Owner',
|
||||
'role.admin': 'Admin',
|
||||
@@ -67,6 +71,11 @@ const en = {
|
||||
'nav.instructions.new': 'Add New Instruction',
|
||||
'nav.instructions.categories': 'Categories',
|
||||
'nav.instructions.settings': 'Settings',
|
||||
'nav.workshops': 'Workshops',
|
||||
'nav.workshops.overview': 'Overview',
|
||||
'nav.workshops.list': 'My Workshops',
|
||||
'nav.workshops.new': 'Add New Workshop',
|
||||
'nav.workshops.categories': 'Categories',
|
||||
'nav.website': 'Website',
|
||||
'nav.website.overview': 'Overview',
|
||||
'nav.website.staticImages': 'Static Images',
|
||||
@@ -208,6 +217,8 @@ const en = {
|
||||
'content.card.videos.link': 'Open videos',
|
||||
'content.card.instructions.desc': 'Tutorials with rich text and optional embedded video.',
|
||||
'content.card.instructions.link': 'Open instructions',
|
||||
'content.card.workshops.desc': 'Workshops with rich text, image gallery, and customer registration.',
|
||||
'content.card.workshops.link': 'Open workshops',
|
||||
'home.card.blog.title': 'Blog',
|
||||
'home.card.blog.desc': 'Create and manage blog posts and categories.',
|
||||
'home.card.blog.link': 'View blog',
|
||||
@@ -283,6 +294,8 @@ const en = {
|
||||
'products.list.clearFilters': 'Clear filters',
|
||||
'products.list.loading': 'Loading products...',
|
||||
'products.list.empty': 'You have not added any products yet.',
|
||||
'products.list.emptyNone': 'no products',
|
||||
'products.list.emptyAction': 'adding a product',
|
||||
'products.list.emptyCta': 'Add product',
|
||||
'products.list.emptyFiltered': 'No products match your filters.',
|
||||
'products.list.errorLoad': 'Unable to load products.',
|
||||
@@ -1006,6 +1019,8 @@ const en = {
|
||||
'blog.list.subtitle': '{count} posts · View, edit and manage your blog content.',
|
||||
'blog.list.loading': 'Loading blog posts...',
|
||||
'blog.list.empty': 'No blog posts yet.',
|
||||
'blog.list.emptyNone': 'no blog posts',
|
||||
'blog.list.emptyAction': 'publishing a blog post',
|
||||
'blog.list.emptyHint': 'Share news, stories, and updates with your audience.',
|
||||
'blog.list.emptyCta': 'Add blog',
|
||||
'blog.list.errorLoad': 'Unable to load blog posts.',
|
||||
@@ -1090,6 +1105,8 @@ const en = {
|
||||
'video.list.subtitle': '{count} videos · View, edit and manage your video content.',
|
||||
'video.list.loading': 'Loading videos...',
|
||||
'video.list.empty': 'No videos yet.',
|
||||
'video.list.emptyNone': 'no videos',
|
||||
'video.list.emptyAction': 'adding a video',
|
||||
'video.list.emptyHint': 'Share embedded videos from YouTube or Aparat with your audience.',
|
||||
'video.list.emptyCta': 'Add video',
|
||||
'video.list.errorLoad': 'Unable to load videos.',
|
||||
@@ -1168,6 +1185,8 @@ const en = {
|
||||
'instruction.list.subtitle': '{count} instructions · View, edit and manage your tutorials.',
|
||||
'instruction.list.loading': 'Loading instructions...',
|
||||
'instruction.list.empty': 'No instructions yet.',
|
||||
'instruction.list.emptyNone': 'no instructions',
|
||||
'instruction.list.emptyAction': 'adding an instruction',
|
||||
'instruction.list.emptyHint': 'Publish tutorials with rich text and optional video for your audience.',
|
||||
'instruction.list.emptyCta': 'Add instruction',
|
||||
'instruction.list.errorLoad': 'Unable to load instructions.',
|
||||
@@ -1232,6 +1251,113 @@ const en = {
|
||||
'instruction.details.noDescription': 'No description yet.',
|
||||
'instruction.details.author': 'Author',
|
||||
|
||||
'workshop.page.subtitle': 'Create and manage workshops with rich text, gallery images, and registrations.',
|
||||
'workshop.card.list.desc': 'View, edit and manage all your workshops.',
|
||||
'workshop.card.list.link': 'View workshops',
|
||||
'workshop.card.new.desc': 'Add a new workshop with thumbnail, rich text, and image gallery.',
|
||||
'workshop.card.new.link': 'Add workshop',
|
||||
'workshop.card.categories.title': 'Workshop Categories',
|
||||
'workshop.card.categories.desc': 'Organize workshops into categories and subcategories.',
|
||||
'workshop.card.categories.link': 'View categories',
|
||||
|
||||
'workshop.list.subtitle': '{count} workshops · View, edit and manage your workshop content.',
|
||||
'workshop.list.loading': 'Loading workshops...',
|
||||
'workshop.list.empty':
|
||||
"You don't have any workshops yet. To get started, use the button below to add one.",
|
||||
'workshop.list.emptyNone': 'no workshops',
|
||||
'workshop.list.emptyAction': 'registering a workshop',
|
||||
'workshop.list.emptyCta': 'Add workshop',
|
||||
'workshop.list.errorLoad': 'Unable to load workshops.',
|
||||
'workshop.list.errorDelete': 'Unable to delete workshop.',
|
||||
'workshop.list.errorStatus': 'Unable to update workshop status.',
|
||||
'workshop.list.toast.removed': 'Workshop removed.',
|
||||
'workshop.list.toast.published': 'Workshop published.',
|
||||
'workshop.list.toast.unpublished': 'Workshop unpublished.',
|
||||
'workshop.list.deleteTitle': 'Delete Workshop',
|
||||
'workshop.list.deleteMessage':
|
||||
'Are you sure you want to delete "{name}"? This action cannot be undone.',
|
||||
'workshop.list.addNew': 'Add new workshop',
|
||||
|
||||
'workshop.card.edit': 'Edit workshop',
|
||||
'workshop.card.publish': 'Publish workshop',
|
||||
'workshop.card.unpublish': 'Unpublish workshop',
|
||||
'workshop.card.registrations': 'View registrations',
|
||||
'workshop.card.remove': 'Remove workshop',
|
||||
'workshop.card.draft': 'Draft',
|
||||
'workshop.card.published': 'Published',
|
||||
'workshop.card.noSummary': 'No summary yet.',
|
||||
|
||||
'workshop.categories.title': 'Workshop Categories',
|
||||
'workshop.categories.subtitle': 'Organize your workshops into categories and subcategories.',
|
||||
'workshop.categories.empty': 'You have not added any categories yet.',
|
||||
'workshop.categories.errorLoad': 'Unable to load categories.',
|
||||
'workshop.categories.errorSave': 'Unable to save category.',
|
||||
'workshop.categories.errorDelete': 'Unable to delete category.',
|
||||
|
||||
'workshop.form.add.subtitle': 'Add a workshop with thumbnail, rich text, and image gallery.',
|
||||
'workshop.form.edit.subtitle': 'Update workshop details and save changes.',
|
||||
'workshop.form.cancel': 'Cancel',
|
||||
'workshop.form.saving': 'Saving...',
|
||||
'workshop.form.save': 'Save Workshop',
|
||||
'workshop.form.update': 'Update Workshop',
|
||||
'workshop.form.errorSave': 'Unable to save workshop.',
|
||||
'workshop.form.errorThumbnailUpload': 'Unable to upload thumbnail. Please try again.',
|
||||
'workshop.form.toast.created': 'Workshop created.',
|
||||
'workshop.form.toast.updated': 'Workshop updated.',
|
||||
'workshop.form.thumbnail': 'Thumbnail (1:1)',
|
||||
'workshop.form.uploadThumbnail': 'Upload thumbnail',
|
||||
'workshop.form.changeThumbnail': 'Change thumbnail',
|
||||
'workshop.form.cropHint': 'Click to select, then crop (1:1)',
|
||||
'workshop.form.category': 'Category',
|
||||
'workshop.form.categoryPlaceholder': 'Search and select category...',
|
||||
'workshop.form.titleFa': 'Title (FA)',
|
||||
'workshop.form.titleFaPlaceholder': 'عنوان فارسی',
|
||||
'workshop.form.titleEn': 'Title (EN)',
|
||||
'workshop.form.titleEnPlaceholder': 'English title',
|
||||
'workshop.form.abstract': 'Abstract',
|
||||
'workshop.form.abstractPlaceholder': 'Short summary shown in workshop listings',
|
||||
'workshop.form.description': 'Description',
|
||||
'workshop.form.eventDate': 'Event date',
|
||||
'workshop.form.eventDatePlaceholder': 'Select date',
|
||||
'workshop.form.eventTime': 'Time',
|
||||
'workshop.form.duration': 'Duration',
|
||||
'workshop.form.durationPlaceholder': 'e.g. 2 hours, 3 days, 1 week',
|
||||
'workshop.form.descriptionPlaceholder': 'Full workshop description with formatting...',
|
||||
'workshop.form.gallery': 'Gallery',
|
||||
'workshop.form.tags': 'Tags',
|
||||
|
||||
'workshop.details.loading': 'Loading workshop...',
|
||||
'workshop.details.notFound': 'Workshop not found.',
|
||||
'workshop.details.errorLoad': 'Unable to load workshop.',
|
||||
'workshop.details.back': 'Back to My Workshops',
|
||||
'workshop.details.noContent': 'No description yet.',
|
||||
'workshop.details.gallery': 'Gallery',
|
||||
'workshop.details.galleryImage': 'Gallery image {index}',
|
||||
|
||||
'workshop.registrations.title': 'Registered Users',
|
||||
'workshop.registrations.loading': 'Loading registrations...',
|
||||
'workshop.registrations.empty': 'No registrations yet.',
|
||||
'workshop.registrations.errorLoad': 'Unable to load registrations.',
|
||||
'workshop.registrations.add': 'Add customer',
|
||||
'workshop.registrations.searchLabel': 'Customer',
|
||||
'workshop.registrations.searchPlaceholder': 'Search by name, phone, or email',
|
||||
'workshop.registrations.searchHint': 'Only active customers of this business can be added.',
|
||||
'workshop.registrations.errorAdd': 'Unable to add registration.',
|
||||
'workshop.registrations.toast.added': 'Customer added to workshop.',
|
||||
'workshop.registrations.col.name': 'Name',
|
||||
'workshop.registrations.col.cell': 'Cell',
|
||||
'workshop.registrations.col.email': 'Email',
|
||||
'workshop.registrations.col.date': 'Registered',
|
||||
'workshop.registrations.col.status': 'Status',
|
||||
'workshop.registrations.status.pending': 'Pending',
|
||||
'workshop.registrations.status.approved': 'Approved',
|
||||
'workshop.registrations.status.rejected': 'Rejected',
|
||||
'workshop.registrations.remove': 'Remove registration',
|
||||
'workshop.registrations.errorStatus': 'Unable to update registration status.',
|
||||
'workshop.registrations.errorRemove': 'Unable to remove registration.',
|
||||
'workshop.registrations.toast.statusUpdated': 'Registration status updated.',
|
||||
'workshop.registrations.toast.removed': 'Registration removed.',
|
||||
|
||||
'portfolio.page.subtitle': 'Manage your portfolio items and showcase projects.',
|
||||
'portfolio.card.list.desc': 'View, edit and manage all your portfolio items.',
|
||||
'portfolio.card.list.link': 'View portfolios',
|
||||
@@ -1246,6 +1372,8 @@ const en = {
|
||||
'portfolio.list.subtitle': '{count} items · View, edit and manage your portfolio content.',
|
||||
'portfolio.list.loading': 'Loading portfolios...',
|
||||
'portfolio.list.empty': 'You have not added any portfolio items yet.',
|
||||
'portfolio.list.emptyNone': 'no portfolio items',
|
||||
'portfolio.list.emptyAction': 'adding a portfolio item',
|
||||
'portfolio.list.emptyCta': 'Add portfolio',
|
||||
'portfolio.list.errorLoad': 'Unable to load portfolios.',
|
||||
'portfolio.list.errorDelete': 'Unable to delete portfolio.',
|
||||
@@ -1562,23 +1690,28 @@ const en = {
|
||||
'website.specialItems.addItemsTo': 'Add store items to {title}',
|
||||
'website.specialItems.removeFromSpecial': 'Remove from special',
|
||||
'website.specialItems.pickSubtitle': 'Select store items to feature in this category.',
|
||||
'website.specialItems.pickSubtitleProducts':
|
||||
'Select products to feature in this category.',
|
||||
'website.specialItems.pickLoading': 'Loading store items...',
|
||||
'website.specialItems.pickLoadingProducts': 'Loading products...',
|
||||
'website.specialItems.pickEmpty': 'All store items are already in this category.',
|
||||
'website.specialItems.pickEmptyProducts': 'All products are already in this category.',
|
||||
'website.specialItems.pickSearch': 'Type 3+ characters to search store items',
|
||||
'website.specialItems.pickSearchProducts': 'Type 3+ characters to search products',
|
||||
'website.specialItems.pickErrorLoad': 'Unable to load store items.',
|
||||
'website.specialItems.pickErrorLoadProducts': 'Unable to load products.',
|
||||
|
||||
'website.settings.title': 'Website settings',
|
||||
'website.settings.subtitle': 'Configure how website content is sourced and displayed.',
|
||||
'website.settings.section': 'Special products',
|
||||
'website.settings.loading': 'Loading settings...',
|
||||
'website.settings.errorLoad': 'Unable to load settings.',
|
||||
'website.settings.errorSave': 'Unable to save settings.',
|
||||
'website.settings.saved': 'Settings saved.',
|
||||
'website.settings.specialSource': 'Read special products from',
|
||||
'website.settings.specialSource': 'Special products source',
|
||||
'website.settings.specialSourceDesc':
|
||||
'Choose whether featured products on the website come from catalog products or store items. Defaults to store items when the store module is enabled.',
|
||||
'website.settings.source.product': 'Product',
|
||||
'website.settings.source.storeItem': 'Store item',
|
||||
'Choose where your special products come from: items added to the store for sale, or catalog products (without price and stock).',
|
||||
'website.settings.source.product': 'Products',
|
||||
'website.settings.source.storeItem': 'Store',
|
||||
'website.pick.addOneItem': 'Add 1 item',
|
||||
'website.pick.addItems': 'Add {count} items',
|
||||
|
||||
@@ -1692,6 +1825,11 @@ const en = {
|
||||
'title.addInstruction': 'Add New Instruction',
|
||||
'title.editInstruction': 'Edit Instruction',
|
||||
'title.instructionDetails': 'Instruction Details',
|
||||
'title.workshops': 'Workshops',
|
||||
'title.myWorkshops': 'My Workshops',
|
||||
'title.addWorkshop': 'Add New Workshop',
|
||||
'title.editWorkshop': 'Edit Workshop',
|
||||
'title.workshopDetails': 'Workshop Details',
|
||||
'title.myVideos': 'My Videos',
|
||||
'title.addVideo': 'Add New Video',
|
||||
'title.editVideo': 'Edit Video',
|
||||
@@ -2021,6 +2159,10 @@ const fa: Record<MessageKey, string> = {
|
||||
'app.poweredBy': 'قدرتگرفته از Meshkee.app',
|
||||
'app.userFallback': 'کاربر',
|
||||
|
||||
'empty.list.part1': 'شما در حال حاضر ',
|
||||
'empty.list.part2': ' ندارید. برای شروع ',
|
||||
'empty.list.part3': ' میتوانید از دکمهٔ زیر استفاده نمایید.',
|
||||
|
||||
'role.superAdmin': 'سوپرادمین',
|
||||
'role.owner': 'صاحب کسبوکار',
|
||||
'role.admin': 'ادمین',
|
||||
@@ -2082,6 +2224,11 @@ const fa: Record<MessageKey, string> = {
|
||||
'nav.instructions.new': 'افزودن آموزش',
|
||||
'nav.instructions.categories': 'دستهبندیها',
|
||||
'nav.instructions.settings': 'تنظیمات',
|
||||
'nav.workshops': 'کارگاهها',
|
||||
'nav.workshops.overview': 'نمای کلی',
|
||||
'nav.workshops.list': 'کارگاههای من',
|
||||
'nav.workshops.new': 'افزودن کارگاه',
|
||||
'nav.workshops.categories': 'دستهبندیها',
|
||||
'nav.website': 'وبسایت',
|
||||
'nav.website.overview': 'نمای کلی',
|
||||
'nav.website.staticImages': 'تصاویر ثابت',
|
||||
@@ -2223,6 +2370,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'content.card.videos.link': 'ورود به ویدیوها',
|
||||
'content.card.instructions.desc': 'آموزشها با متن غنی و ویدیوی جاسازیشده اختیاری.',
|
||||
'content.card.instructions.link': 'ورود به آموزشها',
|
||||
'content.card.workshops.desc': 'کارگاهها با متن غنی، گالری تصویر و ثبتنام مشتری.',
|
||||
'content.card.workshops.link': 'ورود به کارگاهها',
|
||||
'home.card.blog.title': 'بلاگ',
|
||||
'home.card.blog.desc': 'مطالب و دستهبندیهای بلاگ را ایجاد و مدیریت کنید.',
|
||||
'home.card.blog.link': 'مشاهده بلاگ',
|
||||
@@ -2298,6 +2447,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'products.list.clearFilters': 'پاک کردن فیلترها',
|
||||
'products.list.loading': 'در حال بارگذاری محصولات...',
|
||||
'products.list.empty': 'هنوز محصولی ثبت نکردهاید.',
|
||||
'products.list.emptyNone': 'هیچ محصولی',
|
||||
'products.list.emptyAction': 'ثبت محصول',
|
||||
'products.list.emptyCta': 'افزودن محصول',
|
||||
'products.list.emptyFiltered': 'هیچ محصولی با فیلترهای شما مطابقت ندارد.',
|
||||
'products.list.errorLoad': 'بارگذاری محصولات ممکن نشد.',
|
||||
@@ -3019,6 +3170,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'blog.list.subtitle': '{count} مطلب · مشاهده، ویرایش و مدیریت محتوای بلاگ.',
|
||||
'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...',
|
||||
'blog.list.empty': 'هنوز مطلب بلاگی ندارید.',
|
||||
'blog.list.emptyNone': 'هیچ مطلب بلاگی',
|
||||
'blog.list.emptyAction': 'ثبت بلاگ',
|
||||
'blog.list.emptyHint': 'اخبار، داستانها و بهروزرسانیها را با مخاطبان خود به اشتراک بگذارید.',
|
||||
'blog.list.emptyCta': 'افزودن مطلب',
|
||||
'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.',
|
||||
@@ -3103,6 +3256,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'video.list.subtitle': '{count} ویدیو · مشاهده، ویرایش و مدیریت محتوای ویدیو.',
|
||||
'video.list.loading': 'در حال بارگذاری ویدیوها...',
|
||||
'video.list.empty': 'هنوز ویدیویی نیست.',
|
||||
'video.list.emptyNone': 'هیچ ویدیویی',
|
||||
'video.list.emptyAction': 'ثبت ویدیو',
|
||||
'video.list.emptyHint': 'ویدیوهای جاسازیشده از یوتیوب یا آپارات را با مخاطبان به اشتراک بگذارید.',
|
||||
'video.list.emptyCta': 'افزودن ویدیو',
|
||||
'video.list.errorLoad': 'بارگذاری ویدیوها ممکن نشد.',
|
||||
@@ -3181,6 +3336,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'instruction.list.subtitle': '{count} آموزش · مشاهده، ویرایش و مدیریت محتوای آموزشی.',
|
||||
'instruction.list.loading': 'در حال بارگذاری آموزشها...',
|
||||
'instruction.list.empty': 'هنوز آموزشی نیست.',
|
||||
'instruction.list.emptyNone': 'هیچ آموزشی',
|
||||
'instruction.list.emptyAction': 'ثبت آموزش',
|
||||
'instruction.list.emptyHint': 'آموزشها را با متن غنی و ویدیوی اختیاری برای مخاطبان منتشر کنید.',
|
||||
'instruction.list.emptyCta': 'افزودن آموزش',
|
||||
'instruction.list.errorLoad': 'بارگذاری آموزشها ممکن نشد.',
|
||||
@@ -3245,6 +3402,113 @@ const fa: Record<MessageKey, string> = {
|
||||
'instruction.details.noDescription': 'هنوز توضیحاتی نیست.',
|
||||
'instruction.details.author': 'نویسنده',
|
||||
|
||||
'workshop.page.subtitle': 'کارگاهها را با متن غنی، گالری تصویر و ثبتنام مدیریت کنید.',
|
||||
'workshop.card.list.desc': 'همه کارگاهها را مشاهده، ویرایش و مدیریت کنید.',
|
||||
'workshop.card.list.link': 'مشاهده کارگاهها',
|
||||
'workshop.card.new.desc': 'کارگاه جدید با تصویر شاخص، متن غنی و گالری تصویر اضافه کنید.',
|
||||
'workshop.card.new.link': 'افزودن کارگاه',
|
||||
'workshop.card.categories.title': 'دستهبندی کارگاهها',
|
||||
'workshop.card.categories.desc': 'کارگاهها را در دستهبندیها و زیردستهها سازماندهی کنید.',
|
||||
'workshop.card.categories.link': 'مشاهده دستهبندیها',
|
||||
|
||||
'workshop.list.subtitle': '{count} کارگاه · مشاهده، ویرایش و مدیریت محتوای کارگاه.',
|
||||
'workshop.list.loading': 'در حال بارگذاری کارگاهها...',
|
||||
'workshop.list.empty':
|
||||
'شما در حال حاضر هیچ کارگاهی ندارید. برای شروع ثبت کارگاه میتوانید از دکمهٔ زیر استفاده نمایید.',
|
||||
'workshop.list.emptyNone': 'هیچ کارگاهی',
|
||||
'workshop.list.emptyAction': 'ثبت کارگاه',
|
||||
'workshop.list.emptyCta': 'افزودن کارگاه',
|
||||
'workshop.list.errorLoad': 'بارگذاری کارگاهها ممکن نشد.',
|
||||
'workshop.list.errorDelete': 'حذف کارگاه ممکن نشد.',
|
||||
'workshop.list.errorStatus': 'بهروزرسانی وضعیت کارگاه ممکن نشد.',
|
||||
'workshop.list.toast.removed': 'کارگاه حذف شد.',
|
||||
'workshop.list.toast.published': 'کارگاه منتشر شد.',
|
||||
'workshop.list.toast.unpublished': 'انتشار کارگاه لغو شد.',
|
||||
'workshop.list.deleteTitle': 'حذف کارگاه',
|
||||
'workshop.list.deleteMessage':
|
||||
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
|
||||
'workshop.list.addNew': 'افزودن کارگاه جدید',
|
||||
|
||||
'workshop.card.edit': 'ویرایش کارگاه',
|
||||
'workshop.card.publish': 'انتشار کارگاه',
|
||||
'workshop.card.unpublish': 'لغو انتشار کارگاه',
|
||||
'workshop.card.registrations': 'مشاهده ثبتنامها',
|
||||
'workshop.card.remove': 'حذف کارگاه',
|
||||
'workshop.card.draft': 'پیشنویس',
|
||||
'workshop.card.published': 'منتشرشده',
|
||||
'workshop.card.noSummary': 'هنوز خلاصهای نیست.',
|
||||
|
||||
'workshop.categories.title': 'دستهبندی کارگاهها',
|
||||
'workshop.categories.subtitle': 'کارگاهها را در دستهبندیها و زیردستهها سازماندهی کنید.',
|
||||
'workshop.categories.empty': 'هنوز دستهبندی اضافه نکردهاید.',
|
||||
'workshop.categories.errorLoad': 'بارگذاری دستهبندیها ممکن نشد.',
|
||||
'workshop.categories.errorSave': 'ذخیره دستهبندی ممکن نشد.',
|
||||
'workshop.categories.errorDelete': 'حذف دستهبندی ممکن نشد.',
|
||||
|
||||
'workshop.form.add.subtitle': 'کارگاه با تصویر شاخص، متن غنی و گالری تصویر اضافه کنید.',
|
||||
'workshop.form.edit.subtitle': 'جزئیات کارگاه را بهروزرسانی و ذخیره کنید.',
|
||||
'workshop.form.cancel': 'انصراف',
|
||||
'workshop.form.saving': 'در حال ذخیره...',
|
||||
'workshop.form.save': 'ذخیره کارگاه',
|
||||
'workshop.form.update': 'بهروزرسانی کارگاه',
|
||||
'workshop.form.errorSave': 'ذخیره کارگاه ممکن نشد.',
|
||||
'workshop.form.errorThumbnailUpload': 'بارگذاری تصویر شاخص ممکن نشد. دوباره تلاش کنید.',
|
||||
'workshop.form.toast.created': 'کارگاه ایجاد شد.',
|
||||
'workshop.form.toast.updated': 'کارگاه بهروزرسانی شد.',
|
||||
'workshop.form.thumbnail': 'تصویر شاخص (۱:۱)',
|
||||
'workshop.form.uploadThumbnail': 'بارگذاری تصویر شاخص',
|
||||
'workshop.form.changeThumbnail': 'تغییر تصویر شاخص',
|
||||
'workshop.form.cropHint': 'برای انتخاب و برش (۱:۱) کلیک کنید',
|
||||
'workshop.form.category': 'دستهبندی',
|
||||
'workshop.form.categoryPlaceholder': 'جستجو و انتخاب دستهبندی...',
|
||||
'workshop.form.titleFa': 'عنوان (FA)',
|
||||
'workshop.form.titleFaPlaceholder': 'عنوان فارسی',
|
||||
'workshop.form.titleEn': 'عنوان (EN)',
|
||||
'workshop.form.titleEnPlaceholder': 'English title',
|
||||
'workshop.form.abstract': 'چکیده',
|
||||
'workshop.form.abstractPlaceholder': 'خلاصه کوتاه در فهرست کارگاهها',
|
||||
'workshop.form.description': 'توضیحات',
|
||||
'workshop.form.eventDate': 'زمان برگزاری',
|
||||
'workshop.form.eventDatePlaceholder': 'انتخاب تاریخ',
|
||||
'workshop.form.eventTime': 'ساعت',
|
||||
'workshop.form.duration': 'مدت',
|
||||
'workshop.form.durationPlaceholder': 'مثلاً ۲ ساعت، ۳ روز، ۱ هفته',
|
||||
'workshop.form.descriptionPlaceholder': 'توضیحات کامل کارگاه با قالببندی...',
|
||||
'workshop.form.gallery': 'گالری',
|
||||
'workshop.form.tags': 'برچسبها',
|
||||
|
||||
'workshop.details.loading': 'در حال بارگذاری کارگاه...',
|
||||
'workshop.details.notFound': 'کارگاه یافت نشد.',
|
||||
'workshop.details.errorLoad': 'بارگذاری کارگاه ممکن نشد.',
|
||||
'workshop.details.back': 'بازگشت به کارگاههای من',
|
||||
'workshop.details.noContent': 'هنوز توضیحی ثبت نشده است.',
|
||||
'workshop.details.gallery': 'گالری',
|
||||
'workshop.details.galleryImage': 'تصویر گالری {index}',
|
||||
|
||||
'workshop.registrations.title': 'کاربران ثبتنامشده',
|
||||
'workshop.registrations.loading': 'در حال بارگذاری ثبتنامها...',
|
||||
'workshop.registrations.empty': 'هنوز ثبتنامی وجود ندارد.',
|
||||
'workshop.registrations.errorLoad': 'بارگذاری ثبتنامها ممکن نشد.',
|
||||
'workshop.registrations.add': 'افزودن مشتری',
|
||||
'workshop.registrations.searchLabel': 'مشتری',
|
||||
'workshop.registrations.searchPlaceholder': 'جستجو با نام، موبایل یا ایمیل',
|
||||
'workshop.registrations.searchHint': 'فقط مشتریان فعال این کسبوکار قابل افزودن هستند.',
|
||||
'workshop.registrations.errorAdd': 'افزودن ثبتنام ممکن نشد.',
|
||||
'workshop.registrations.toast.added': 'مشتری به کارگاه اضافه شد.',
|
||||
'workshop.registrations.col.name': 'نام',
|
||||
'workshop.registrations.col.cell': 'موبایل',
|
||||
'workshop.registrations.col.email': 'ایمیل',
|
||||
'workshop.registrations.col.date': 'تاریخ ثبتنام',
|
||||
'workshop.registrations.col.status': 'وضعیت',
|
||||
'workshop.registrations.status.pending': 'در انتظار',
|
||||
'workshop.registrations.status.approved': 'تأیید شده',
|
||||
'workshop.registrations.status.rejected': 'رد شده',
|
||||
'workshop.registrations.remove': 'حذف ثبتنام',
|
||||
'workshop.registrations.errorStatus': 'بهروزرسانی وضعیت ثبتنام ممکن نشد.',
|
||||
'workshop.registrations.errorRemove': 'حذف ثبتنام ممکن نشد.',
|
||||
'workshop.registrations.toast.statusUpdated': 'وضعیت ثبتنام بهروزرسانی شد.',
|
||||
'workshop.registrations.toast.removed': 'ثبتنام حذف شد.',
|
||||
|
||||
'portfolio.page.subtitle': 'نمونه کارها را مدیریت کنید و پروژهها را به نمایش بگذارید.',
|
||||
'portfolio.card.list.desc': 'همه نمونه کارها را مشاهده، ویرایش و مدیریت کنید.',
|
||||
'portfolio.card.list.link': 'مشاهده نمونه کارها',
|
||||
@@ -3260,6 +3524,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'portfolio.list.subtitle': '{count} مورد · مشاهده، ویرایش و مدیریت محتوای نمونه کار.',
|
||||
'portfolio.list.loading': 'در حال بارگذاری نمونه کارها...',
|
||||
'portfolio.list.empty': 'هنوز نمونه کاری ثبت نکردهاید.',
|
||||
'portfolio.list.emptyNone': 'هیچ نمونه کاری',
|
||||
'portfolio.list.emptyAction': 'ثبت نمونه کار',
|
||||
'portfolio.list.emptyCta': 'افزودن نمونه کار',
|
||||
'portfolio.list.errorLoad': 'بارگذاری نمونه کارها ممکن نشد.',
|
||||
'portfolio.list.errorDelete': 'حذف نمونه کار ممکن نشد.',
|
||||
@@ -3574,23 +3840,28 @@ const fa: Record<MessageKey, string> = {
|
||||
'website.specialItems.addItemsTo': 'افزودن محصولات فروشگاه به {title}',
|
||||
'website.specialItems.removeFromSpecial': 'حذف از ویژه',
|
||||
'website.specialItems.pickSubtitle': 'محصولات فروشگاه را برای نمایش در این دسته انتخاب کنید.',
|
||||
'website.specialItems.pickSubtitleProducts':
|
||||
'محصولات را برای نمایش در این دسته انتخاب کنید.',
|
||||
'website.specialItems.pickLoading': 'در حال بارگذاری محصولات فروشگاه...',
|
||||
'website.specialItems.pickLoadingProducts': 'در حال بارگذاری محصولات...',
|
||||
'website.specialItems.pickEmpty': 'همه محصولات فروشگاه قبلاً در این دسته هستند.',
|
||||
'website.specialItems.pickEmptyProducts': 'همه محصولات قبلاً در این دسته هستند.',
|
||||
'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی محصولات فروشگاه تایپ کنید',
|
||||
'website.specialItems.pickSearchProducts': 'حداقل ۳ حرف برای جستجوی محصولات تایپ کنید',
|
||||
'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
|
||||
'website.specialItems.pickErrorLoadProducts': 'بارگذاری محصولات ممکن نشد.',
|
||||
|
||||
'website.settings.title': 'تنظیمات وبسایت',
|
||||
'website.settings.subtitle': 'نحوه منبعگیری و نمایش محتوای وبسایت را پیکربندی کنید.',
|
||||
'website.settings.section': 'محصولات ویژه',
|
||||
'website.settings.loading': 'در حال بارگذاری تنظیمات...',
|
||||
'website.settings.errorLoad': 'بارگذاری تنظیمات ممکن نشد.',
|
||||
'website.settings.errorSave': 'ذخیره تنظیمات ممکن نشد.',
|
||||
'website.settings.saved': 'تنظیمات ذخیره شد.',
|
||||
'website.settings.specialSource': 'خواندن محصولات ویژه از',
|
||||
'website.settings.specialSource': 'سورس محصولات ویژه',
|
||||
'website.settings.specialSourceDesc':
|
||||
'انتخاب کنید محصولات ویژه وبسایت از کاتالوگ محصول خوانده شوند یا از محصولات فروشگاه. اگر ماژول فروشگاه فعال باشد، پیشفرض محصول فروشگاه است.',
|
||||
'website.settings.source.product': 'محصول',
|
||||
'website.settings.source.storeItem': 'محصول فروشگاه',
|
||||
'مشخص کنید محصولات ویژه ی شما از کدام بخش خوانده شود: محصولاتی که برای فروش به فروشگاه اضافه شده است و یا خود محصولات (بدون قیمت و موجودی)',
|
||||
'website.settings.source.product': 'محصولات',
|
||||
'website.settings.source.storeItem': 'فروشگاه',
|
||||
'website.pick.addOneItem': 'افزودن ۱ قلم',
|
||||
'website.pick.addItems': 'افزودن {count} قلم',
|
||||
|
||||
@@ -3706,6 +3977,11 @@ const fa: Record<MessageKey, string> = {
|
||||
'title.addInstruction': 'افزودن آموزش',
|
||||
'title.editInstruction': 'ویرایش آموزش',
|
||||
'title.instructionDetails': 'جزئیات آموزش',
|
||||
'title.workshops': 'کارگاهها',
|
||||
'title.myWorkshops': 'کارگاههای من',
|
||||
'title.addWorkshop': 'افزودن کارگاه',
|
||||
'title.editWorkshop': 'ویرایش کارگاه',
|
||||
'title.workshopDetails': 'جزئیات کارگاه',
|
||||
'title.myVideos': 'ویدیوهای من',
|
||||
'title.addVideo': 'افزودن ویدیو',
|
||||
'title.editVideo': 'ویرایش ویدیو',
|
||||
@@ -4077,6 +4353,11 @@ const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
|
||||
'Add New Video': 'nav.videos.new',
|
||||
'Edit Video': 'bc.editVideo',
|
||||
'Video Details': 'bc.videoDetails',
|
||||
Workshops: 'nav.workshops',
|
||||
'My Workshops': 'nav.workshops.list',
|
||||
'Add New Workshop': 'title.addWorkshop',
|
||||
'Edit Workshop': 'title.editWorkshop',
|
||||
'Workshop Details': 'title.workshopDetails',
|
||||
Website: 'nav.website',
|
||||
'Static Images': 'nav.website.staticImages',
|
||||
Sliders: 'nav.website.sliders',
|
||||
@@ -4179,6 +4460,12 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
|
||||
{ match: '/instructions/categories', labels: [t('title.content'), t('title.instructions'), t('title.categories')] },
|
||||
{ match: '/instructions/settings', labels: [t('title.content'), t('title.instructions'), t('title.settings')] },
|
||||
{ match: '/instructions', labels: [t('title.content'), t('title.instructions')] },
|
||||
{ match: '/workshops/list', labels: [t('title.content'), t('title.workshops'), t('title.myWorkshops')] },
|
||||
{ match: /^\/workshops\/detail\/[^/]+$/, labels: [t('title.content'), t('title.workshops'), t('title.workshopDetails')] },
|
||||
{ match: '/workshops/new', labels: [t('title.content'), t('title.workshops'), t('title.addWorkshop')] },
|
||||
{ match: /^\/workshops\/edit\/[^/]+$/, labels: [t('title.content'), t('title.workshops'), t('title.editWorkshop')] },
|
||||
{ match: '/workshops/categories', labels: [t('title.content'), t('title.workshops'), t('title.categories')] },
|
||||
{ match: '/workshops', labels: [t('title.content'), t('title.workshops')] },
|
||||
{ match: '/portfolios/list', labels: [t('title.portfolios'), t('title.myPortfolios')] },
|
||||
{
|
||||
match: /^\/portfolios\/detail\/[^/]+$/,
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
--blur-glass: 28px;
|
||||
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
|
||||
--modal-overlay-blur: 16px;
|
||||
--toast-z-index: 11000;
|
||||
--toast-bg: rgba(255, 255, 255, 0.94);
|
||||
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
@@ -319,17 +319,17 @@ export function AddNewPortfolioPage() {
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>{t('portfolio.form.mainText')}</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder={t('portfolio.form.mainTextPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('portfolio.form.mainText')}</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder={t('portfolio.form.mainTextPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
|
||||
@@ -41,8 +41,21 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scheduleRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumbnailField {
|
||||
align-self: start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumbnailField label {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.topFields {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||
import { SearchableSelect } from '../components/SearchableSelect'
|
||||
import { ImageCropper } from '../components/ImageCropper'
|
||||
import { ImageUploader } from '../components/ImageUploader'
|
||||
import { TagInput } from '../components/TagInput'
|
||||
import { RichTextEditor } from '../components/RichTextEditor'
|
||||
import { JalaliDatePicker } from '../components/JalaliDatePicker'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listWorkshopCategories,
|
||||
mapWorkshopCategoryToUi,
|
||||
} from '../services/workshopCategoryService'
|
||||
import {
|
||||
resolveDataUrlToMediaId,
|
||||
resolveDataUrlsToMediaIds,
|
||||
resolveHtmlEmbeddedDataUrls,
|
||||
} from '../services/mediaService'
|
||||
import {
|
||||
createWorkshop,
|
||||
getWorkshop,
|
||||
mapWorkshopApiToFormState,
|
||||
updateWorkshop,
|
||||
} from '../services/workshopService'
|
||||
import type { Category } from '../types/category'
|
||||
import { flattenCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
|
||||
export function AddNewWorkshopPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleFa, setTitleFa] = useState('')
|
||||
const [titleEn, setTitleEn] = useState('')
|
||||
const [abstract, setAbstract] = useState('')
|
||||
const [descriptionHtml, setDescriptionHtml] = useState('')
|
||||
const [coverImage, setCoverImage] = useState<string | null>(null)
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [eventDate, setEventDate] = useState('')
|
||||
const [eventTime, setEventTime] = useState('')
|
||||
const [duration, setDuration] = useState('')
|
||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
||||
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(!isEdit)
|
||||
const [isLoadingWorkshop, setIsLoadingWorkshop] = useState(isEdit)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const categoryOptions = flattenCategories(categories)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const items = await listWorkshopCategories(controller.signal)
|
||||
setCategories(items.map(mapWorkshopCategoryToUi))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorLoad'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCategories()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit || !id) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadWorkshop() {
|
||||
setIsLoadingWorkshop(true)
|
||||
setError('')
|
||||
try {
|
||||
const workshop = await getWorkshop(id!, controller.signal)
|
||||
const form = mapWorkshopApiToFormState(workshop)
|
||||
setCategoryId(form.categoryId)
|
||||
setTitleFa(form.titleFa)
|
||||
setTitleEn(form.titleEn)
|
||||
setAbstract(form.abstract)
|
||||
setDescriptionHtml(form.descriptionHtml)
|
||||
setCoverImage(form.coverImage)
|
||||
setImages(form.images)
|
||||
setTags(form.tags)
|
||||
setEventDate(form.eventDate)
|
||||
setEventTime(form.eventTime)
|
||||
setDuration(form.duration)
|
||||
setFeaturedMediaId(form.featuredMediaId)
|
||||
setGalleryMediaIds(form.galleryMediaIds)
|
||||
setLoaded(true)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.details.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingWorkshop(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkshop()
|
||||
return () => controller.abort()
|
||||
}, [isEdit, id, navigate])
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isLoadingWorkshop ? t('workshop.list.loading') : t('workshop.details.errorLoad')}
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
|
||||
coverImage,
|
||||
'workshop-thumbnail.jpg',
|
||||
featuredMediaId,
|
||||
)
|
||||
const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml)
|
||||
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(images, galleryMediaIds)
|
||||
|
||||
if (coverImage?.startsWith('data:') && !nextFeaturedMediaId) {
|
||||
setError(t('workshop.form.errorThumbnailUpload'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
titleFa: titleFa.trim(),
|
||||
titleEn: titleEn.trim() || undefined,
|
||||
abstract: abstract.trim(),
|
||||
descriptionHtml: resolvedDescriptionHtml,
|
||||
categoryId: categoryId || undefined,
|
||||
featuredMediaId: nextFeaturedMediaId ?? undefined,
|
||||
galleryMediaIds: nextGalleryMediaIds,
|
||||
tags,
|
||||
eventDate: eventDate.trim() || null,
|
||||
eventTime: eventTime.trim() || null,
|
||||
duration: duration.trim() || null,
|
||||
}
|
||||
|
||||
if (isEdit && id) {
|
||||
await updateWorkshop(id, {
|
||||
...payload,
|
||||
categoryId: categoryId || null,
|
||||
featuredMediaId: nextFeaturedMediaId,
|
||||
})
|
||||
showToast(t('workshop.form.toast.updated'), 'success')
|
||||
} else {
|
||||
await createWorkshop({ ...payload, status: 'draft' })
|
||||
showToast(t('workshop.form.toast.created'), 'success')
|
||||
}
|
||||
|
||||
navigate('/workshops/list')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.form.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={
|
||||
isEdit
|
||||
? [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'My Workshops', href: '/workshops/list' },
|
||||
{ label: 'Edit Workshop' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'Add New Workshop' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? t('title.editWorkshop') : t('title.addWorkshop')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit ? t('workshop.form.edit.subtitle') : t('workshop.form.add.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
|
||||
<label>{t('workshop.form.thumbnail')}</label>
|
||||
<ImageCropper
|
||||
value={coverImage}
|
||||
onChange={setCoverImage}
|
||||
aspect={1}
|
||||
uploadLabel={t('workshop.form.uploadThumbnail')}
|
||||
hint={t('workshop.form.cropHint')}
|
||||
changeLabel={t('workshop.form.changeThumbnail')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formColumn}>
|
||||
<div className={styles.field}>
|
||||
<label>{t('workshop.form.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t('workshop.form.categoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-title-fa">{t('workshop.form.titleFa')}</label>
|
||||
<input
|
||||
id="workshop-title-fa"
|
||||
name="titleFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
className="faText"
|
||||
placeholder={t('workshop.form.titleFaPlaceholder')}
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-title-en">{t('workshop.form.titleEn')}</label>
|
||||
<input
|
||||
id="workshop-title-en"
|
||||
name="titleEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder={t('workshop.form.titleEnPlaceholder')}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-abstract">{t('workshop.form.abstract')}</label>
|
||||
<textarea
|
||||
id="workshop-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder={t('workshop.form.abstractPlaceholder')}
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scheduleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-event-date">{t('workshop.form.eventDate')}</label>
|
||||
<JalaliDatePicker
|
||||
id="workshop-event-date"
|
||||
value={eventDate}
|
||||
onChange={setEventDate}
|
||||
disabled={isSubmitting}
|
||||
placeholder={t('workshop.form.eventDatePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-event-time">{t('workshop.form.eventTime')}</label>
|
||||
<input
|
||||
id="workshop-event-time"
|
||||
name="eventTime"
|
||||
type="time"
|
||||
value={eventTime}
|
||||
onChange={(e) => setEventTime(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="workshop-duration">{t('workshop.form.duration')}</label>
|
||||
<input
|
||||
id="workshop-duration"
|
||||
name="duration"
|
||||
type="text"
|
||||
placeholder={t('workshop.form.durationPlaceholder')}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('workshop.form.description')}</label>
|
||||
<RichTextEditor
|
||||
value={descriptionHtml}
|
||||
onChange={setDescriptionHtml}
|
||||
placeholder={t('workshop.form.descriptionPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('workshop.form.gallery')}</label>
|
||||
<ImageUploader images={images} onChange={setImages} />
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('workshop.form.tags')}</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/workshops/list" className={styles.cancelBtn}>
|
||||
{t('workshop.form.cancel')}
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t('workshop.form.saving')
|
||||
: isEdit
|
||||
? t('workshop.form.update')
|
||||
: t('workshop.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FileText, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { BlogCard } from '../components/BlogCard'
|
||||
import { BlogCommentsModal } from '../components/BlogCommentsModal'
|
||||
@@ -182,9 +182,9 @@ export function BlogListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('blog.list.loading')}</p>
|
||||
) : blogs.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="blog"
|
||||
icon={FileText}
|
||||
message={t('blog.list.empty')}
|
||||
actionLabel={t('blog.list.emptyCta')}
|
||||
actionTo="/blog/new"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, GraduationCap } from 'lucide-react'
|
||||
import { FileText, GraduationCap, Presentation } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
@@ -14,7 +14,7 @@ const contentSections: {
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
moduleId: 'blog' | 'videos' | 'instructions'
|
||||
moduleId: 'blog' | 'videos' | 'instructions' | 'workshops'
|
||||
}[] = [
|
||||
{
|
||||
icon: FileText,
|
||||
@@ -32,6 +32,14 @@ const contentSections: {
|
||||
href: '/instructions',
|
||||
moduleId: 'instructions',
|
||||
},
|
||||
{
|
||||
icon: Presentation,
|
||||
titleKey: 'nav.workshops',
|
||||
descKey: 'content.card.workshops.desc',
|
||||
linkKey: 'content.card.workshops.link',
|
||||
href: '/workshops',
|
||||
moduleId: 'workshops',
|
||||
},
|
||||
]
|
||||
|
||||
export function ContentPage() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { listBlogs } from '../services/blogService'
|
||||
import { listPortfolios } from '../services/portfolioService'
|
||||
import { listVideos } from '../services/videoService'
|
||||
import { listInstructions } from '../services/instructionService'
|
||||
import { listWorkshops } from '../services/workshopService'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import {
|
||||
hasBusinessModule,
|
||||
@@ -93,7 +94,7 @@ const sections: {
|
||||
countLabelKey: 'home.card.content.count',
|
||||
href: '/content',
|
||||
countKey: 'content',
|
||||
anyModuleIds: ['blog', 'videos', 'instructions'],
|
||||
anyModuleIds: ['blog', 'videos', 'instructions', 'workshops'],
|
||||
},
|
||||
{
|
||||
icon: Briefcase,
|
||||
@@ -110,7 +111,8 @@ const sections: {
|
||||
type SectionCounts = Partial<Record<CountKey, number>>
|
||||
|
||||
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
const [products, store, customers, blog, portfolios, videos, instructions] = await Promise.all([
|
||||
const [products, store, customers, blog, portfolios, videos, instructions, workshops] =
|
||||
await Promise.all([
|
||||
listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listStoreItems(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
|
||||
@@ -118,6 +120,7 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listVideos(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listInstructions(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listWorkshops(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
])
|
||||
|
||||
const counts: SectionCounts = {}
|
||||
@@ -125,8 +128,10 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
if (store !== null) counts.store = store
|
||||
if (customers !== null) counts.customers = customers
|
||||
if (portfolios !== null) counts.portfolios = portfolios
|
||||
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0)
|
||||
if (blog !== null || videos !== null || instructions !== null) counts.content = contentTotal
|
||||
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0) + (workshops ?? 0)
|
||||
if (blog !== null || videos !== null || instructions !== null || workshops !== null) {
|
||||
counts.content = contentTotal
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { InstructionCard } from '../components/InstructionCard'
|
||||
import { InstructionCommentsModal } from '../components/InstructionCommentsModal'
|
||||
@@ -159,9 +159,9 @@ export function InstructionListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('instruction.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="instruction"
|
||||
icon={Play}
|
||||
message={t('instruction.list.empty')}
|
||||
actionLabel={t('instruction.list.emptyCta')}
|
||||
actionTo="/instructions/new"
|
||||
/>
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
}
|
||||
|
||||
.businessName {
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.appName {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { EmptyState, ListEmptyState } from '../components/EmptyState'
|
||||
import { ProductCard } from '../components/ProductCard'
|
||||
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
@@ -258,12 +258,16 @@ export function MyProductsPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('products.list.loading')}</p>
|
||||
) : products.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
|
||||
actionLabel={appliedName ? undefined : t('products.list.emptyCta')}
|
||||
actionTo={appliedName ? undefined : '/products/new'}
|
||||
/>
|
||||
appliedName ? (
|
||||
<EmptyState message={t('products.list.emptyFiltered')} />
|
||||
) : (
|
||||
<ListEmptyState
|
||||
entity="products"
|
||||
icon={Package}
|
||||
actionLabel={t('products.list.emptyCta')}
|
||||
actionTo="/products/new"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Briefcase, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { PortfolioCard } from '../components/PortfolioCard'
|
||||
import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
|
||||
@@ -190,9 +190,9 @@ export function PortfolioListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('portfolio.list.loading')}</p>
|
||||
) : portfolios.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="portfolio"
|
||||
icon={Briefcase}
|
||||
message={t('portfolio.list.empty')}
|
||||
actionLabel={t('portfolio.list.emptyCta')}
|
||||
actionTo="/portfolios/new"
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
listStoreSpecials,
|
||||
updateStoreSpecial,
|
||||
} from '../services/storeSpecialService'
|
||||
import {
|
||||
getSettings,
|
||||
type SpecialProductsSource,
|
||||
} from '../services/settingsService'
|
||||
import { hasBusinessModule, type BusinessModuleId } from '../utils/businessModules'
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import type { StoreSpecial } from '../types/storeSpecial'
|
||||
import type { StoreProductListing } from '../utils/storeProductGroups'
|
||||
@@ -29,11 +34,23 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import fabStyles from './StoreItemsPage.module.css'
|
||||
import styles from './StoreSpecialsPage.module.css'
|
||||
|
||||
function defaultSpecialProductsSource(
|
||||
enabledModules: string[] | undefined,
|
||||
): SpecialProductsSource {
|
||||
return hasBusinessModule(
|
||||
enabledModules as BusinessModuleId[] | undefined,
|
||||
'store',
|
||||
)
|
||||
? 'store_item'
|
||||
: 'product'
|
||||
}
|
||||
|
||||
export function StoreSpecialsPage() {
|
||||
const t = useT()
|
||||
const { itemCount, hasItems, addVariant } = useDraftCart()
|
||||
const { showToast } = useToast()
|
||||
const [specials, setSpecials] = useState<StoreSpecial[]>([])
|
||||
const [specialSource, setSpecialSource] = useState<SpecialProductsSource>('store_item')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -61,10 +78,36 @@ export function StoreSpecialsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSpecials(controller.signal)
|
||||
void loadPage(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadPage(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [specialsData, settingsData] = await Promise.all([
|
||||
listStoreSpecials(1, 50, signal),
|
||||
getSettings(signal),
|
||||
])
|
||||
setSpecials(specialsData.items)
|
||||
setSpecialSource(
|
||||
settingsData.settings.website?.specialProductsSource ??
|
||||
defaultSpecialProductsSource(settingsData.settings.modules?.enabled),
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('website.specialItems.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSpecials(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
@@ -212,19 +255,23 @@ export function StoreSpecialsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddItems(storeItemIds: string[]) {
|
||||
async function handleAddItems(ids: string[]) {
|
||||
if (!pickItemsTarget) return
|
||||
|
||||
const existingIds = pickItemsTarget.items.map((item) => item.id)
|
||||
const nextIds = [...existingIds, ...storeItemIds]
|
||||
const fromProducts = specialSource === 'product'
|
||||
const existingIds = pickItemsTarget.items.map((item) =>
|
||||
fromProducts ? item.productId : item.id,
|
||||
)
|
||||
const nextIds = [...existingIds, ...ids]
|
||||
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await updateStoreSpecial(pickItemsTarget.id, {
|
||||
storeItemIds: nextIds,
|
||||
})
|
||||
const result = await updateStoreSpecial(
|
||||
pickItemsTarget.id,
|
||||
fromProducts ? { productIds: nextIds } : { storeItemIds: nextIds },
|
||||
)
|
||||
replaceSpecial(result.special)
|
||||
setPickItemsTarget(null)
|
||||
showToast(t('website.specialItems.toastAdded'), 'success')
|
||||
@@ -402,7 +449,12 @@ export function StoreSpecialsPage() {
|
||||
<PickStoreSpecialItemsModal
|
||||
open={!!pickItemsTarget}
|
||||
specialTitle={pickItemsTarget?.title ?? ''}
|
||||
existingStoreItemIds={pickItemsTarget?.items.map((item) => item.id) ?? []}
|
||||
source={specialSource}
|
||||
existingIds={
|
||||
pickItemsTarget?.items.map((item) =>
|
||||
specialSource === 'product' ? item.productId : item.id,
|
||||
) ?? []
|
||||
}
|
||||
onClose={() => !isSaving && setPickItemsTarget(null)}
|
||||
onConfirm={handleAddItems}
|
||||
isSubmitting={isSaving}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { VideoCard } from '../components/VideoCard'
|
||||
import { VideoCommentsModal } from '../components/VideoCommentsModal'
|
||||
@@ -159,9 +159,9 @@ export function VideoListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('video.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<EmptyState
|
||||
<ListEmptyState
|
||||
entity="video"
|
||||
icon={Play}
|
||||
message={t('video.list.empty')}
|
||||
actionLabel={t('video.list.emptyCta')}
|
||||
actionTo="/videos/new"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import sharedStyles from './ProductSettingsPage.module.css'
|
||||
import styles from './WebsiteSettingsPage.module.css'
|
||||
|
||||
const SOURCE_OPTIONS: SpecialProductsSource[] = ['product', 'store_item']
|
||||
const SOURCE_OPTIONS: SpecialProductsSource[] = ['store_item', 'product']
|
||||
|
||||
function defaultSpecialProductsSource(
|
||||
enabledModules: string[] | undefined,
|
||||
@@ -113,8 +113,6 @@ export function WebsiteSettingsPage() {
|
||||
)}
|
||||
|
||||
<section className={sharedStyles.panel}>
|
||||
<h3 className={sharedStyles.sectionTitle}>{t('website.settings.section')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>{t('website.settings.loading')}</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FolderTree, Plus } from 'lucide-react'
|
||||
import { CategoryModal } from '../components/CategoryModal'
|
||||
import { WorkshopCategoryTree } from '../components/WorkshopCategoryTree'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import {
|
||||
createWorkshopCategory,
|
||||
deleteWorkshopCategory,
|
||||
listWorkshopCategories,
|
||||
mapWorkshopCategoryToUi,
|
||||
toWorkshopCategoryFormPayload,
|
||||
updateWorkshopCategory,
|
||||
} from '../services/workshopCategoryService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
export function WorkshopCategoriesPage() {
|
||||
const t = useT()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [defaultParentId, setDefaultParentId] = useState('')
|
||||
const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadCategories(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadCategories(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const items = await listWorkshopCategories(signal)
|
||||
setCategories(items.map(mapWorkshopCategoryToUi))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal(parentId = '', title?: string) {
|
||||
setEditingCategory(null)
|
||||
setDefaultParentId(parentId)
|
||||
setModalTitle(title ?? t('categories.add'))
|
||||
setModalOpen(true)
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId))
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle(t('categories.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(data: CategoryFormData) {
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = toWorkshopCategoryFormPayload(data)
|
||||
|
||||
if (editingCategory) {
|
||||
const result = await updateWorkshopCategory(editingCategory.id, {
|
||||
...payload,
|
||||
parentId: data.parentId || null,
|
||||
})
|
||||
setCategories((prev) =>
|
||||
prev.map((category) =>
|
||||
category.id === editingCategory.id
|
||||
? mapWorkshopCategoryToUi(result.category)
|
||||
: category,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const result = await createWorkshopCategory(payload)
|
||||
setCategories((prev) => [...prev, mapWorkshopCategoryToUi(result.category)])
|
||||
if (data.parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(data.parentId))
|
||||
}
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await deleteWorkshopCategory(deleteTarget.id)
|
||||
const removed = new Set(result.deletedIds)
|
||||
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
removed.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.categories.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'Categories' },
|
||||
]}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="WORKSHOP CATEGORIES">{t('workshop.categories.title')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('workshop.categories.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTree}
|
||||
message={t('workshop.categories.empty')}
|
||||
actionLabel={t('categories.add')}
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<WorkshopCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
onClose={() => {
|
||||
if (!isSubmitting) {
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
}
|
||||
}}
|
||||
title={modalTitle}
|
||||
defaultParentId={defaultParentId}
|
||||
categories={categories}
|
||||
editingCategory={editingCategory}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('categories.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className={styles.fabDock}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label={t('categories.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ImageLightbox } from '../components/ImageLightbox'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { formatWorkshopDate, formatWorkshopDuration, getWorkshopDetail } from '../services/workshopService'
|
||||
import type { WorkshopDetail } from '../types/workshop'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './PortfolioDetailsPage.module.css'
|
||||
|
||||
export function WorkshopDetailsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { id } = useParams()
|
||||
const [workshop, setWorkshop] = useState<WorkshopDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadWorkshop() {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getWorkshopDetail(id!, controller.signal)
|
||||
setWorkshop(data)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.details.errorLoad'))
|
||||
}
|
||||
setWorkshop(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkshop()
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
const galleryImages = useMemo(
|
||||
() => workshop?.gallery.map((item) => item.url).filter(Boolean) ?? [],
|
||||
[workshop],
|
||||
)
|
||||
|
||||
function openLightbox(index: number) {
|
||||
setLightboxIndex(index)
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>{t('workshop.details.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !workshop || !id) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.error}>{error || t('workshop.details.notFound')}</p>
|
||||
<Link to="/workshops/list" className={styles.backLink}>
|
||||
{t('workshop.details.back')}
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const displayDate = formatWorkshopDate(
|
||||
workshop.publishedAt ?? workshop.createdAt,
|
||||
locale === 'fa' ? 'fa' : 'en',
|
||||
)
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const hasSchedule = Boolean(workshop.eventDate || workshop.eventTime || workshop.duration)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'My Workshops', href: '/workshops/list' },
|
||||
{ label: workshop.title },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>{workshop.title}</h2>
|
||||
{workshop.abstract && (
|
||||
<p className={pageStyles.pageSubtitleContent}>{workshop.abstract}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className={styles.portfolioDetail}>
|
||||
<div
|
||||
className={`${styles.heroImageWrap} ${
|
||||
workshop.coverImageUrl ? '' : styles.heroImageWrapEmpty
|
||||
}`}
|
||||
>
|
||||
{workshop.coverImageUrl ? (
|
||||
<img
|
||||
src={workshop.coverImageUrl}
|
||||
alt={workshop.title}
|
||||
className={styles.heroImage}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.heroPlaceholder} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
{hasSchedule && (
|
||||
<>
|
||||
<span className={styles.metaItem}>
|
||||
<CalendarDays size={15} />
|
||||
{[
|
||||
workshop.eventDate
|
||||
? formatWorkshopDate(workshop.eventDate, dateLocale)
|
||||
: null,
|
||||
workshop.eventTime || null,
|
||||
workshop.duration ? formatWorkshopDuration(workshop.duration) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
<span className={styles.metaDot}>·</span>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.metaItem}>
|
||||
<CalendarDays size={15} />
|
||||
{displayDate}
|
||||
</span>
|
||||
{(workshop.categoryName || workshop.tags.length > 0) && (
|
||||
<>
|
||||
<span className={styles.metaDot}>·</span>
|
||||
<div className={styles.metaChips}>
|
||||
{workshop.categoryName && (
|
||||
<span className={styles.categoryChip}>{workshop.categoryName}</span>
|
||||
)}
|
||||
{workshop.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tagChip}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.contentPanel}>
|
||||
{workshop.descriptionHtml ? (
|
||||
<div
|
||||
className={styles.content}
|
||||
dangerouslySetInnerHTML={{ __html: workshop.descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content}>{t('workshop.details.noContent')}</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{galleryImages.length > 0 && (
|
||||
<section className={styles.galleryBand} aria-label={t('workshop.details.gallery')}>
|
||||
<div className={styles.galleryInner}>
|
||||
<h3 className={styles.galleryTitle}>{t('workshop.details.gallery')}</h3>
|
||||
<div className={styles.galleryGrid}>
|
||||
{galleryImages.map((src, index) => (
|
||||
<button
|
||||
key={`${src}-${index}`}
|
||||
type="button"
|
||||
className={styles.galleryItem}
|
||||
onClick={() => openLightbox(index)}
|
||||
aria-label={t('workshop.details.galleryImage', { index: index + 1 })}
|
||||
>
|
||||
<img src={src} alt={`${workshop.title} gallery ${index + 1}`} loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{galleryImages.length > 0 && (
|
||||
<ImageLightbox
|
||||
open={lightboxOpen}
|
||||
images={galleryImages}
|
||||
initialIndex={lightboxIndex}
|
||||
alt={workshop.title}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Presentation, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ListEmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { WorkshopCard } from '../components/WorkshopCard'
|
||||
import { WorkshopRegistrationsModal } from '../components/WorkshopRegistrationsModal'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
WORKSHOPS_PER_PAGE,
|
||||
deleteWorkshop,
|
||||
isWorkshopPublished,
|
||||
listWorkshops,
|
||||
mapWorkshopApiToUi,
|
||||
resolveWorkshopTitle,
|
||||
setWorkshopPublished,
|
||||
} from '../services/workshopService'
|
||||
import type { Workshop } from '../types/workshop'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogPage.module.css'
|
||||
|
||||
export function WorkshopListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [workshops, setWorkshops] = useState<Workshop[]>([])
|
||||
const [totalWorkshops, setTotalWorkshops] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [registrationCounts, setRegistrationCounts] = useState<Record<string, number>>({})
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
const [registrationsTarget, setRegistrationsTarget] = useState<{
|
||||
id: string
|
||||
title: string
|
||||
} | null>(null)
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalWorkshops / WORKSHOPS_PER_PAGE))
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadWorkshops(currentPage, controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [currentPage])
|
||||
|
||||
async function loadWorkshops(page: number, signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listWorkshops(page, WORKSHOPS_PER_PAGE, signal)
|
||||
setWorkshops(data.items)
|
||||
setTotalWorkshops(data.total)
|
||||
setRegistrationCounts(
|
||||
Object.fromEntries(
|
||||
data.items.map((workshop) => [workshop.id, workshop.registrationCount ?? 0]),
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.list.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(id: string) {
|
||||
navigate(`/workshops/edit/${id}`)
|
||||
}
|
||||
|
||||
function handleRegistrations(id: string) {
|
||||
const workshop = workshops.find((item) => item.id === id)
|
||||
if (workshop) {
|
||||
setRegistrationsTarget({
|
||||
id,
|
||||
title: resolveWorkshopTitle(workshop, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveRequest(id: string) {
|
||||
const workshop = workshops.find((item) => item.id === id)
|
||||
if (workshop) {
|
||||
setDeleteTarget({
|
||||
id,
|
||||
title: resolveWorkshopTitle(workshop, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsDeleting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await deleteWorkshop(deleteTarget.id)
|
||||
showToast(t('workshop.list.toast.removed'), 'success')
|
||||
const nextTotal = totalWorkshops - 1
|
||||
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / WORKSHOPS_PER_PAGE))
|
||||
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
||||
setDeleteTarget(null)
|
||||
setCurrentPage(nextPage)
|
||||
await loadWorkshops(nextPage)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.list.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setCurrentPage(page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function handleTogglePublished(id: string) {
|
||||
const workshop = workshops.find((item) => item.id === id)
|
||||
if (!workshop) return
|
||||
|
||||
const nextPublished = !isWorkshopPublished(workshop)
|
||||
setTogglingId(id)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await setWorkshopPublished(id, nextPublished)
|
||||
setWorkshops((prev) =>
|
||||
prev.map((item) => (item.id === id ? mapWorkshopApiToUi(result.workshop) : item)),
|
||||
)
|
||||
showToast(
|
||||
nextPublished ? t('workshop.list.toast.published') : t('workshop.list.toast.unpublished'),
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('workshop.list.errorStatus'))
|
||||
}
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Workshops', href: '/workshops' },
|
||||
{ label: 'My Workshops' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="MY WORKSHOPS">{t('title.myWorkshops')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{t('workshop.list.subtitle', { count: totalWorkshops })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('workshop.list.loading')}</p>
|
||||
) : workshops.length === 0 ? (
|
||||
<ListEmptyState
|
||||
entity="workshop"
|
||||
icon={Presentation}
|
||||
actionLabel={t('workshop.list.emptyCta')}
|
||||
actionTo="/workshops/new"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.gridCols5}>
|
||||
{workshops.map((workshop) => (
|
||||
<WorkshopCard
|
||||
key={workshop.id}
|
||||
workshop={workshop}
|
||||
registrationCount={registrationCounts[workshop.id] ?? workshop.registrationCount}
|
||||
onEdit={handleEdit}
|
||||
onRegistrations={handleRegistrations}
|
||||
onTogglePublished={handleTogglePublished}
|
||||
onRemove={handleRemoveRequest}
|
||||
isTogglingStatus={togglingId === workshop.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && workshops.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => navigate('/workshops/new')}
|
||||
aria-label={t('workshop.list.addNew')}
|
||||
>
|
||||
<Plus size={26} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('workshop.list.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('workshop.list.deleteMessage', { name: deleteTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isDeleting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{registrationsTarget && (
|
||||
<WorkshopRegistrationsModal
|
||||
open={!!registrationsTarget}
|
||||
workshopId={registrationsTarget.id}
|
||||
workshopTitle={registrationsTarget.title}
|
||||
onClose={() => setRegistrationsTarget(null)}
|
||||
onRegistrationAdded={() => {
|
||||
setRegistrationCounts((current) => ({
|
||||
...current,
|
||||
[registrationsTarget.id]:
|
||||
(current[registrationsTarget.id] ?? 0) + 1,
|
||||
}))
|
||||
}}
|
||||
onRegistrationRemoved={() => {
|
||||
setRegistrationCounts((current) => ({
|
||||
...current,
|
||||
[registrationsTarget.id]: Math.max(
|
||||
0,
|
||||
(current[registrationsTarget.id] ?? 0) - 1,
|
||||
),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Presentation, PlusCircle, FolderTree } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const workshopSections: {
|
||||
icon: typeof Presentation
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
wide?: boolean
|
||||
}[] = [
|
||||
{
|
||||
icon: Presentation,
|
||||
titleKey: 'nav.workshops.list',
|
||||
descKey: 'workshop.card.list.desc',
|
||||
linkKey: 'workshop.card.list.link',
|
||||
href: '/workshops/list',
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
icon: PlusCircle,
|
||||
titleKey: 'nav.workshops.new',
|
||||
descKey: 'workshop.card.new.desc',
|
||||
linkKey: 'workshop.card.new.link',
|
||||
href: '/workshops/new',
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
titleKey: 'workshop.card.categories.title',
|
||||
descKey: 'workshop.card.categories.desc',
|
||||
linkKey: 'workshop.card.categories.link',
|
||||
href: '/workshops/categories',
|
||||
},
|
||||
]
|
||||
|
||||
export function WorkshopPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Content', href: '/content' },
|
||||
{ label: 'Workshops' },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="WORKSHOPS">{t('title.workshops')}</PageTitle>
|
||||
<p className={styles.pageSubtitle}>{t('workshop.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{workshopSections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
className={section.wide ? styles.span4 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export interface CreateStoreSpecialPayload {
|
||||
key: string
|
||||
title: string
|
||||
storeItemIds?: string[]
|
||||
productIds?: string[]
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export interface UpdateStoreSpecialPayload {
|
||||
key?: string
|
||||
title?: string
|
||||
storeItemIds?: string[]
|
||||
productIds?: string[]
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import type {
|
||||
CategoryFormData,
|
||||
CreateCategoryPayload,
|
||||
ProductCategoriesResponse,
|
||||
ProductCategory,
|
||||
UpdateCategoryPayload,
|
||||
} from '../types/category'
|
||||
|
||||
const ENTITY_TYPE = 'workshop' as const
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/categories${suffix}`
|
||||
}
|
||||
|
||||
export async function listWorkshopCategories(signal?: AbortSignal) {
|
||||
const data = await apiRequest<ProductCategoriesResponse>(
|
||||
`${businessPath()}?entityType=${ENTITY_TYPE}`,
|
||||
{ auth: true, signal },
|
||||
)
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function createWorkshopCategory(payload: CreateCategoryPayload) {
|
||||
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { ...payload, entityType: ENTITY_TYPE },
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateWorkshopCategory(categoryId: string, payload: UpdateCategoryPayload) {
|
||||
return apiRequest<{ message: string; category: ProductCategory }>(
|
||||
businessPath(`/${categoryId}`),
|
||||
{
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteWorkshopCategory(categoryId: string) {
|
||||
return apiRequest<{ message: string; deletedIds: string[] }>(
|
||||
businessPath(`/${categoryId}`),
|
||||
{
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function toWorkshopCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
|
||||
return {
|
||||
parentId: data.parentId || undefined,
|
||||
name: data.nameEn,
|
||||
nameFa: data.nameFa,
|
||||
description: data.description || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapWorkshopCategoryToUi(category: ProductCategory) {
|
||||
return {
|
||||
id: category.id,
|
||||
nameEn: category.name,
|
||||
nameFa: category.nameFa ?? '',
|
||||
description: category.description ?? '',
|
||||
parentId: category.parentId,
|
||||
variationCount: category.variationCount ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import { extractFirstImageUrl } from '../utils/htmlImages'
|
||||
import type {
|
||||
Workshop,
|
||||
WorkshopAuthor,
|
||||
WorkshopDetail,
|
||||
WorkshopGalleryItem,
|
||||
WorkshopStatus,
|
||||
} from '../types/workshop'
|
||||
|
||||
export const WORKSHOPS_PER_PAGE = 12
|
||||
|
||||
export interface WorkshopApi {
|
||||
id: string
|
||||
businessId: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
descriptionHtml: string
|
||||
status: WorkshopStatus
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
tags: string[]
|
||||
coverImageUrl: string | null
|
||||
featuredMediaId: string | null
|
||||
gallery: WorkshopGalleryItem[]
|
||||
galleryMediaIds: string[]
|
||||
registrationCount: number
|
||||
authorId?: string | null
|
||||
author?: WorkshopAuthor | null
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
eventDate: string | null
|
||||
eventTime: string | null
|
||||
duration: string | null
|
||||
}
|
||||
|
||||
export interface WorkshopsListResponse {
|
||||
items: WorkshopApi[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ListWorkshopsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: WorkshopStatus
|
||||
categoryId?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface WorkshopRegistrationUser {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
firstNameEn: string | null
|
||||
lastNameEn: string | null
|
||||
email: string | null
|
||||
cellNumber: string | null
|
||||
}
|
||||
|
||||
export type WorkshopRegistrationStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
export interface WorkshopRegistration {
|
||||
id: string
|
||||
workshopId: string
|
||||
userId: string
|
||||
status: WorkshopRegistrationStatus
|
||||
createdAt: string
|
||||
user: WorkshopRegistrationUser
|
||||
}
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/workshops${suffix}`
|
||||
}
|
||||
|
||||
export interface CreateWorkshopPayload {
|
||||
titleFa: string
|
||||
titleEn?: string
|
||||
abstract?: string
|
||||
descriptionHtml?: string
|
||||
categoryId?: string
|
||||
status?: WorkshopStatus
|
||||
featuredMediaId?: string
|
||||
galleryMediaIds?: string[]
|
||||
tags?: string[]
|
||||
eventDate?: string | null
|
||||
eventTime?: string | null
|
||||
duration?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateWorkshopPayload {
|
||||
titleFa?: string
|
||||
titleEn?: string | null
|
||||
abstract?: string | null
|
||||
descriptionHtml?: string | null
|
||||
categoryId?: string | null
|
||||
status?: WorkshopStatus
|
||||
featuredMediaId?: string | null
|
||||
galleryMediaIds?: string[]
|
||||
tags?: string[]
|
||||
eventDate?: string | null
|
||||
eventTime?: string | null
|
||||
duration?: string | null
|
||||
}
|
||||
|
||||
export function resolveWorkshopTitle(
|
||||
workshop: Pick<Workshop, 'titleFa' | 'titleEn' | 'title'>,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (locale === 'fa') {
|
||||
return workshop.titleFa || workshop.title
|
||||
}
|
||||
return workshop.titleEn?.trim() || workshop.titleFa || workshop.title
|
||||
}
|
||||
|
||||
export function resolveWorkshopCoverImageUrl(workshop: WorkshopApi): string | null {
|
||||
const featured = workshop.coverImageUrl?.trim()
|
||||
if (featured) return featured
|
||||
|
||||
const galleryUrl = workshop.gallery?.map((item) => item.url?.trim()).find(Boolean)
|
||||
if (galleryUrl) return galleryUrl
|
||||
|
||||
return extractFirstImageUrl(workshop.descriptionHtml) || null
|
||||
}
|
||||
|
||||
export function mapWorkshopApiToUi(workshop: WorkshopApi): Workshop {
|
||||
return {
|
||||
id: workshop.id,
|
||||
title: workshop.titleFa || workshop.title,
|
||||
titleFa: workshop.titleFa || workshop.title,
|
||||
titleEn: workshop.titleEn ?? null,
|
||||
slug: workshop.slug,
|
||||
abstract: workshop.abstract,
|
||||
status: workshop.status,
|
||||
categoryId: workshop.categoryId,
|
||||
categoryName: workshop.categoryName,
|
||||
tags: workshop.tags,
|
||||
coverImageUrl: resolveWorkshopCoverImageUrl(workshop),
|
||||
registrationCount: workshop.registrationCount ?? 0,
|
||||
author: workshop.author ?? null,
|
||||
publishedAt: workshop.publishedAt,
|
||||
createdAt: workshop.createdAt,
|
||||
updatedAt: workshop.updatedAt,
|
||||
eventDate: workshop.eventDate ?? null,
|
||||
eventTime: workshop.eventTime ?? null,
|
||||
duration: workshop.duration ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapWorkshopDetailFromApi(workshop: WorkshopApi): WorkshopDetail {
|
||||
return {
|
||||
...mapWorkshopApiToUi(workshop),
|
||||
descriptionHtml: workshop.descriptionHtml,
|
||||
gallery: workshop.gallery ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWorkshopAuthor(author: WorkshopAuthor | null): string {
|
||||
if (!author) return 'Unknown author'
|
||||
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || author.email || 'Unknown author'
|
||||
}
|
||||
|
||||
export function formatWorkshopRegistrationName(
|
||||
user: WorkshopRegistrationUser,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (locale === 'fa') {
|
||||
const faName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
if (faName) return faName
|
||||
}
|
||||
const enName = [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ').trim()
|
||||
if (enName) return enName
|
||||
const fallback = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
return fallback || user.email || '—'
|
||||
}
|
||||
|
||||
export function formatWorkshopDate(
|
||||
value: string | null,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatWorkshopCardDate(
|
||||
value: string | null,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatWorkshopCardMetaDate(
|
||||
workshop: Pick<Workshop, 'eventDate' | 'publishedAt' | 'createdAt'>,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (workshop.eventDate) {
|
||||
return formatWorkshopCardDate(workshop.eventDate, locale)
|
||||
}
|
||||
return formatWorkshopCardDate(workshop.publishedAt ?? workshop.createdAt, locale)
|
||||
}
|
||||
|
||||
export function isWorkshopPublished(workshop: Pick<Workshop, 'status'>): boolean {
|
||||
return workshop.status === 'published'
|
||||
}
|
||||
|
||||
export async function setWorkshopPublished(workshopId: string, published: boolean) {
|
||||
return updateWorkshop(workshopId, { status: published ? 'published' : 'draft' })
|
||||
}
|
||||
|
||||
export function formatWorkshopDuration(
|
||||
duration: string | null | undefined,
|
||||
): string {
|
||||
const trimmed = duration?.trim()
|
||||
return trimmed || '—'
|
||||
}
|
||||
|
||||
function normalizeGregorianDate(value: string | null | undefined): string {
|
||||
if (!value?.trim()) return ''
|
||||
return value.trim().match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''
|
||||
}
|
||||
|
||||
function normalizeTimeInput(value: string | null | undefined): string {
|
||||
if (!value?.trim()) return ''
|
||||
return value.trim().match(/^(\d{2}:\d{2})/)?.[1] ?? ''
|
||||
}
|
||||
|
||||
function normalizeWorkshopDuration(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeRichTextHtml(html: string | null | undefined): string {
|
||||
const trimmed = html?.trim() ?? ''
|
||||
if (!trimmed) return ''
|
||||
if (/<[a-z][\s\S]*>/i.test(trimmed)) return trimmed
|
||||
return `<p>${trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</p>`
|
||||
}
|
||||
|
||||
export function mapWorkshopApiToFormState(workshop: WorkshopApi) {
|
||||
const legacyDuration = (workshop as WorkshopApi & { durationMinutes?: number | null })
|
||||
.durationMinutes
|
||||
const gallery = workshop.gallery ?? []
|
||||
const images: string[] = []
|
||||
const galleryMediaIds: string[] = []
|
||||
|
||||
for (const item of gallery) {
|
||||
const url = item.url?.trim()
|
||||
if (!url) continue
|
||||
images.push(url)
|
||||
if (item.mediaId) galleryMediaIds.push(item.mediaId)
|
||||
}
|
||||
|
||||
if (galleryMediaIds.length === 0 && workshop.galleryMediaIds?.length) {
|
||||
galleryMediaIds.push(
|
||||
...workshop.galleryMediaIds.filter((id) => typeof id === 'string' && id.trim()),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
titleFa: workshop.titleFa || workshop.title,
|
||||
titleEn: workshop.titleEn ?? '',
|
||||
abstract: workshop.abstract ?? '',
|
||||
descriptionHtml: normalizeRichTextHtml(workshop.descriptionHtml),
|
||||
categoryId: workshop.categoryId ?? '',
|
||||
tags: Array.isArray(workshop.tags) ? workshop.tags : [],
|
||||
eventDate: normalizeGregorianDate(workshop.eventDate),
|
||||
eventTime: normalizeTimeInput(workshop.eventTime),
|
||||
duration:
|
||||
normalizeWorkshopDuration(workshop.duration) ||
|
||||
normalizeWorkshopDuration(legacyDuration),
|
||||
coverImage: resolveWorkshopCoverImageUrl(workshop),
|
||||
featuredMediaId: workshop.featuredMediaId,
|
||||
images,
|
||||
galleryMediaIds,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorkshops(
|
||||
page = 1,
|
||||
pageSize = WORKSHOPS_PER_PAGE,
|
||||
signal?: AbortSignal,
|
||||
filters?: Omit<ListWorkshopsParams, 'page' | 'pageSize'>,
|
||||
) {
|
||||
const q = new URLSearchParams()
|
||||
q.set('page', String(page))
|
||||
q.set('pageSize', String(pageSize))
|
||||
if (filters?.status) q.set('status', filters.status)
|
||||
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
|
||||
if (filters?.title) q.set('title', filters.title)
|
||||
|
||||
const data = await apiRequest<WorkshopsListResponse>(`${businessPath()}?${q.toString()}`, {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map(mapWorkshopApiToUi),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkshop(workshopId: string, signal?: AbortSignal) {
|
||||
const data = await apiRequest<{ workshop: WorkshopApi }>(businessPath(`/${workshopId}`), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
return data.workshop
|
||||
}
|
||||
|
||||
export async function getWorkshopDetail(workshopId: string, signal?: AbortSignal) {
|
||||
const workshop = await getWorkshop(workshopId, signal)
|
||||
return mapWorkshopDetailFromApi(workshop)
|
||||
}
|
||||
|
||||
export async function listWorkshopRegistrations(workshopId: string, signal?: AbortSignal) {
|
||||
return apiRequest<{ items: WorkshopRegistration[] }>(
|
||||
businessPath(`/${workshopId}/registrations`),
|
||||
{ auth: true, signal },
|
||||
)
|
||||
}
|
||||
|
||||
export async function addWorkshopRegistration(workshopId: string, userId: string) {
|
||||
return apiRequest<{ message: string; registration: WorkshopRegistration }>(
|
||||
businessPath(`/${workshopId}/registrations`),
|
||||
{
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { userId },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateWorkshopRegistrationStatus(
|
||||
workshopId: string,
|
||||
registrationId: string,
|
||||
status: WorkshopRegistrationStatus,
|
||||
) {
|
||||
return apiRequest<{ message: string; registration: WorkshopRegistration }>(
|
||||
businessPath(`/${workshopId}/registrations/${registrationId}`),
|
||||
{
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: { status },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function removeWorkshopRegistration(
|
||||
workshopId: string,
|
||||
registrationId: string,
|
||||
) {
|
||||
return apiRequest<{ message: string }>(
|
||||
businessPath(`/${workshopId}/registrations/${registrationId}`),
|
||||
{
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function createWorkshop(payload: CreateWorkshopPayload) {
|
||||
return apiRequest<{ message: string; workshop: WorkshopApi }>(businessPath(), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateWorkshop(workshopId: string, payload: UpdateWorkshopPayload) {
|
||||
return apiRequest<{ message: string; workshop: WorkshopApi }>(
|
||||
businessPath(`/${workshopId}`),
|
||||
{
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteWorkshop(workshopId: string) {
|
||||
return apiRequest<{ message: string }>(businessPath(`/${workshopId}`), {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type WorkshopStatus = 'draft' | 'published' | 'archived'
|
||||
|
||||
export interface WorkshopAuthor {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface WorkshopGalleryItem {
|
||||
mediaId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Workshop {
|
||||
id: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
status: WorkshopStatus
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
tags: string[]
|
||||
coverImageUrl: string | null
|
||||
registrationCount: number
|
||||
author: WorkshopAuthor | null
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
eventDate: string | null
|
||||
eventTime: string | null
|
||||
duration: string | null
|
||||
}
|
||||
|
||||
export interface WorkshopDetail extends Workshop {
|
||||
descriptionHtml: string
|
||||
gallery: WorkshopGalleryItem[]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'warehouse',
|
||||
'videos',
|
||||
'instructions',
|
||||
'workshops',
|
||||
'finance',
|
||||
] as const
|
||||
|
||||
@@ -35,13 +36,19 @@ export const HOME_CHART_IDS = [
|
||||
|
||||
export type HomeChartId = (typeof HOME_CHART_IDS)[number]
|
||||
|
||||
/** Modules enabled when a new business is created (super-admin). */
|
||||
export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||
'products',
|
||||
'blog',
|
||||
'finance',
|
||||
]
|
||||
|
||||
/**
|
||||
* Existing tenants without saved modules keep every business-dashboard module
|
||||
* enabled. Customer modules stay opt-in.
|
||||
* enabled except opt-in modules. Customer modules stay opt-in.
|
||||
*/
|
||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||
]
|
||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
||||
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
|
||||
|
||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||
'orders_30d',
|
||||
|
||||
@@ -7,6 +7,9 @@ export default defineConfig({
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', 'react-router-dom'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['react-multi-date-picker', 'react-date-object', 'react-element-popper'],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
EmptyState,
|
||||
EmptyStateEmphasis,
|
||||
EmptyTableRow,
|
||||
type EmptyStateProps,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { EmptyState } from './EmptyState'
|
||||
import type { Order } from '../services/orderService'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
@@ -106,7 +107,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
||||
</div>
|
||||
|
||||
{order.items.length === 0 ? (
|
||||
<p className={styles.empty}>{t('orderItems.empty')}</p>
|
||||
<EmptyState layout="inline" message={t('orderItems.empty')} />
|
||||
) : (
|
||||
<div className={styles.tableBlock}>
|
||||
<div className={styles.tableHead}>
|
||||
|
||||
@@ -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: var(--elevated-surface);
|
||||
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 {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
--surface: rgba(255, 255, 255, 0.82);
|
||||
--card-media-bg: #ffffff;
|
||||
--elevated-surface: rgba(255, 255, 255, 0.96);
|
||||
--toast-bg: rgba(255, 255, 255, 0.94);
|
||||
--icon-bg: color-mix(in srgb, var(--primary) 14%, #ffffff);
|
||||
--icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9);
|
||||
}
|
||||
@@ -55,6 +56,7 @@ html[data-theme='dark'] {
|
||||
--surface: rgba(55, 59, 68, 0.95);
|
||||
--card-media-bg: #32363f;
|
||||
--elevated-surface: #3a3e48;
|
||||
--toast-bg: rgba(58, 62, 72, 0.96);
|
||||
|
||||
--card-hover-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { MapPin, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
|
||||
import { AddressFormModal } from '../components/AddressFormModal'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
@@ -125,10 +126,12 @@ export function AddressesPage() {
|
||||
{loading && <p className={styles.status}>{t('addresses.loading')}</p>}
|
||||
|
||||
{!loading && addresses.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<MapPin size={28} />
|
||||
<p>{t('addresses.empty')}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
message={t('addresses.empty')}
|
||||
actionLabel={t('addresses.add')}
|
||||
onAction={openCreateModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && addresses.length > 0 && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PageTitle } from '../components/PageTitle'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
@@ -97,10 +98,7 @@ export function FavoritesPage() {
|
||||
{loading && <p className={styles.status}>{t('favorites.loading')}</p>}
|
||||
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<Heart size={32} />
|
||||
<p>{t('favorites.empty')}</p>
|
||||
</div>
|
||||
<EmptyState icon={Heart} message={t('favorites.empty')} layout="page" />
|
||||
)}
|
||||
|
||||
{!!data?.items.length && (
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
}
|
||||
|
||||
.domain {
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.appName {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Package, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { UserProductCard } from '../components/UserProductCard'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
@@ -141,13 +142,12 @@ export function MyProductsPage() {
|
||||
{loading ? <p className={styles.status}>{t('myProducts.loading')}</p> : null}
|
||||
|
||||
{!loading && !error && products.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Package size={32} />
|
||||
<p>{t('myProducts.empty')}</p>
|
||||
<Link to="/my-products/new" className={styles.emptyLink}>
|
||||
{t('myProducts.add')}
|
||||
</Link>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={t('myProducts.empty')}
|
||||
actionLabel={t('myProducts.add')}
|
||||
actionTo="/my-products/new"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && products.length > 0 ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui'
|
||||
import { EmptyTableRow } from '../components/EmptyState'
|
||||
import { OrderItemsModal } from '../components/OrderItemsModal'
|
||||
import { OrderRow } from '../components/OrderRow'
|
||||
import { useT } from '../i18n/useT'
|
||||
@@ -131,11 +132,7 @@ export function OrdersPage() {
|
||||
)}
|
||||
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('orders.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow colSpan={COLUMN_COUNT} message={t('orders.empty')} />
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
EmptyState,
|
||||
EmptyStateEmphasis,
|
||||
EmptyTableRow,
|
||||
type EmptyStateProps,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
@@ -1,83 +0,0 @@
|
||||
.wrapper {
|
||||
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;
|
||||
}
|
||||
|
||||
.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: 3px 6px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
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: 16px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 88px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor ul,
|
||||
.editor ol {
|
||||
padding-left: 1.4em;
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
|
||||
.editor p {
|
||||
margin: 0 0 0.35em;
|
||||
}
|
||||
|
||||
.editor p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor blockquote {
|
||||
margin: 0.35em 0;
|
||||
margin-inline-start: 1.4em;
|
||||
margin-inline-end: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
@@ -1,177 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Bold,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Underline,
|
||||
} from 'lucide-react'
|
||||
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
|
||||
/** Compact editor height for short fields like invoice top text. */
|
||||
editorMinHeight?: number
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
editorMinHeight = 88,
|
||||
}: RichTextEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const syncChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||
editorRef.current.innerHTML = value
|
||||
}
|
||||
}, [value])
|
||||
|
||||
function exec(command: string) {
|
||||
editorRef.current?.focus()
|
||||
document.execCommand(command)
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
|
||||
<Bold size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('underline')}
|
||||
title="Underline"
|
||||
aria-label="Underline"
|
||||
>
|
||||
<Underline size={14} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('insertUnorderedList')}
|
||||
title="Bullet list"
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('insertOrderedList')}
|
||||
title="Numbered list"
|
||||
aria-label="Numbered list"
|
||||
>
|
||||
<ListOrdered size={14} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeIndent(-1)}
|
||||
title="Decrease indent"
|
||||
aria-label="Decrease indent"
|
||||
>
|
||||
<IndentDecrease size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeIndent(1)}
|
||||
title="Increase indent"
|
||||
aria-label="Increase indent"
|
||||
>
|
||||
<IndentIncrease size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={styles.editor}
|
||||
style={{ minHeight: editorMinHeight }}
|
||||
contentEditable
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder={placeholder}
|
||||
onInput={syncChange}
|
||||
onKeyDown={onEditorKeyDown}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** True when HTML is empty or only whitespace / empty tags. */
|
||||
export function isEmptyRichText(html: string | null | undefined): boolean {
|
||||
return richTextToPlain(html).length === 0
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
RichTextEditor,
|
||||
isEmptyRichText,
|
||||
richTextToPlain,
|
||||
type RichTextEditorProps,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
/* Above Modal overlay (z-index 300) so success/error feedback is visible while dialogs are open */
|
||||
z-index: 400;
|
||||
left: 24px;
|
||||
z-index: var(--toast-z-index, 11000);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -20,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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from '../components/Toast.module.css'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{createPortal(
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
--blur-glass: 28px;
|
||||
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
|
||||
--modal-overlay-blur: 16px;
|
||||
--toast-z-index: 11000;
|
||||
--toast-bg: rgba(255, 255, 255, 0.94);
|
||||
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
|
||||
--select-arrow-size: 16px;
|
||||
--select-arrow-offset: 12px;
|
||||
--select-padding-end: 2.5rem;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { EmptyTableRow } from '../components/EmptyState'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
@@ -236,14 +237,13 @@ export function SettingsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoiceTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>No invoice templates yet. Add one to speed up issuing invoices.</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={5}
|
||||
icon={SettingsIcon}
|
||||
message="No invoice templates yet. Add one to speed up issuing invoices."
|
||||
actionLabel="Add template"
|
||||
actionTo="/settings/invoice-templates/new"
|
||||
/>
|
||||
) : null}
|
||||
{invoiceTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
@@ -330,14 +330,13 @@ export function SettingsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && itemTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>No predefined items yet.</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={6}
|
||||
icon={SettingsIcon}
|
||||
message="No predefined items yet. Add one to reuse when building invoices."
|
||||
actionLabel="Add item"
|
||||
onAction={openCreateItem}
|
||||
/>
|
||||
) : null}
|
||||
{itemTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'warehouse',
|
||||
'videos',
|
||||
'instructions',
|
||||
'workshops',
|
||||
'finance',
|
||||
] as const
|
||||
|
||||
@@ -35,6 +36,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
|
||||
warehouse: 'Warehouse',
|
||||
videos: 'Videos',
|
||||
instructions: 'Instructions',
|
||||
workshops: 'Workshops',
|
||||
finance: 'Finance',
|
||||
}
|
||||
|
||||
@@ -68,11 +70,10 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
|
||||
|
||||
/**
|
||||
* Existing tenants without saved modules keep every business-dashboard module
|
||||
* enabled. Customer modules stay opt-in.
|
||||
* enabled except opt-in modules. Customer modules stay opt-in.
|
||||
*/
|
||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||
]
|
||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
||||
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
|
||||
|
||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||
'orders_30d',
|
||||
|
||||
Generated
+31
@@ -19,6 +19,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-easy-crop": "^6.2.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1275,6 +1276,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
@@ -1300,6 +1307,30 @@
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
--blur-glass: 28px;
|
||||
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
|
||||
--modal-overlay-blur: 16px;
|
||||
--toast-z-index: 11000;
|
||||
--toast-bg: rgba(255, 255, 255, 0.94);
|
||||
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
.layoutPage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 32px 0 48px;
|
||||
}
|
||||
|
||||
.rootDashed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
margin-inline: auto;
|
||||
padding: 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rootDashedInline {
|
||||
max-width: 100%;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.messageDashed {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageOnly {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.85;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.messageEmphasis {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addBox {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
min-height: 180px;
|
||||
padding: 32px 28px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 2px dashed rgba(var(--primary-rgb) / 0.38);
|
||||
border-radius: var(--radius);
|
||||
color: var(--primary);
|
||||
font-family: var(--font-ui);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.addBox:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border-color: rgba(var(--primary-rgb) / 0.58);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--card-hover-shadow);
|
||||
}
|
||||
|
||||
.addBox:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.addIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.addLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cell {
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border-bottom: none !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.messageDashed {
|
||||
white-space: normal;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.rootDashed,
|
||||
.messageDashed,
|
||||
.messageOnly,
|
||||
.addBox {
|
||||
max-width: min(100%, 640px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import styles from './EmptyState.module.css'
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Plain-text fallback for accessibility and tables. */
|
||||
message: string
|
||||
/** Rich message content; used instead of `message` when provided. */
|
||||
messageNode?: ReactNode
|
||||
actionLabel?: string
|
||||
actionTo?: string
|
||||
onAction?: () => void
|
||||
/** Unused visually; kept for call-site compatibility. */
|
||||
icon?: LucideIcon
|
||||
/** Page lists use top padding; tables/modals use inline. */
|
||||
layout?: 'page' | 'inline'
|
||||
}
|
||||
|
||||
export function EmptyStateEmphasis({ children }: { children: ReactNode }) {
|
||||
return <strong className={styles.messageEmphasis}>{children}</strong>
|
||||
}
|
||||
|
||||
export interface ListEmptyMessageProps {
|
||||
part1: string
|
||||
noneLabel: string
|
||||
part2: string
|
||||
actionLabel: string
|
||||
part3: string
|
||||
}
|
||||
|
||||
/** Standard list empty copy: part1 + bold none + part2 + bold action + part3. */
|
||||
export function ListEmptyMessage({
|
||||
part1,
|
||||
noneLabel,
|
||||
part2,
|
||||
actionLabel,
|
||||
part3,
|
||||
}: ListEmptyMessageProps) {
|
||||
return (
|
||||
<>
|
||||
{part1}
|
||||
<EmptyStateEmphasis>{noneLabel}</EmptyStateEmphasis>
|
||||
{part2}
|
||||
<EmptyStateEmphasis>{actionLabel}</EmptyStateEmphasis>
|
||||
{part3}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function buildListEmptyPlainMessage(parts: ListEmptyMessageProps): string {
|
||||
return `${parts.part1}${parts.noneLabel}${parts.part2}${parts.actionLabel}${parts.part3}`
|
||||
}
|
||||
|
||||
function EmptyStateContent({
|
||||
message,
|
||||
messageNode,
|
||||
actionLabel,
|
||||
actionTo,
|
||||
onAction,
|
||||
layout = 'page',
|
||||
}: EmptyStateProps) {
|
||||
const showAction = Boolean(actionLabel && (actionTo || onAction))
|
||||
const messageContent = messageNode ?? message
|
||||
const isInline = layout === 'inline'
|
||||
|
||||
if (!showAction) {
|
||||
return (
|
||||
<div
|
||||
className={[styles.rootDashed, isInline ? styles.rootDashedInline : ''].filter(Boolean).join(' ')}
|
||||
role="status"
|
||||
>
|
||||
<p className={styles.messageOnly}>{messageContent}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const addContent = (
|
||||
<>
|
||||
<span className={styles.addIcon} aria-hidden="true">
|
||||
<Plus size={28} strokeWidth={2} />
|
||||
</span>
|
||||
{actionLabel ? <span className={styles.addLabel}>{actionLabel}</span> : null}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.rootDashed, isInline ? styles.rootDashedInline : ''].filter(Boolean).join(' ')}
|
||||
role="status"
|
||||
>
|
||||
<p className={styles.messageDashed}>{messageContent}</p>
|
||||
{actionTo ? (
|
||||
<Link to={actionTo} className={styles.addBox} onClick={onAction}>
|
||||
{addContent}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className={styles.addBox} onClick={onAction}>
|
||||
{addContent}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState(props: EmptyStateProps) {
|
||||
const content = <EmptyStateContent {...props} />
|
||||
|
||||
if (props.layout === 'inline') {
|
||||
return content
|
||||
}
|
||||
|
||||
return <div className={styles.layoutPage}>{content}</div>
|
||||
}
|
||||
|
||||
export function EmptyTableRow({
|
||||
colSpan,
|
||||
layout: _layout,
|
||||
...props
|
||||
}: EmptyStateProps & { colSpan: number }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className={styles.cell}>
|
||||
<EmptyStateContent {...props} layout="inline" />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
+110
-2
@@ -5,7 +5,7 @@
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -17,11 +17,14 @@
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
@@ -35,11 +38,25 @@
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
.toolbar button:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.toolbar button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dirBtn {
|
||||
width: auto;
|
||||
min-width: 34px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
@@ -47,6 +64,97 @@
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.colorPickerWrap {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.colorBtn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.colorBtn:hover,
|
||||
.colorBtnActive {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.colorSwatch {
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
margin-top: 1px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-primary);
|
||||
}
|
||||
|
||||
.colorPopover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 168px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.colorVisualInput {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hexField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hexLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hexInput {
|
||||
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.9);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hexInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.editorShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -0,0 +1,530 @@
|
||||
import { useRef, useEffect, useState, useCallback, useContext } from 'react'
|
||||
import {
|
||||
Bold,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
Italic,
|
||||
Underline,
|
||||
List,
|
||||
ListOrdered,
|
||||
ImagePlus,
|
||||
Palette,
|
||||
} from 'lucide-react'
|
||||
import type { DashboardLocale } from '@meshkee/dashboard-core'
|
||||
import { LocaleContext } from '../context/LocaleContext'
|
||||
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'])
|
||||
const DEFAULT_TEXT_COLOR = '#0f172a'
|
||||
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
|
||||
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
|
||||
|
||||
function normalizeHexColor(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
|
||||
if (!HEX_COLOR_PATTERN.test(withHash)) return null
|
||||
|
||||
if (withHash.length === 4) {
|
||||
const [, r, g, b] = withHash
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
|
||||
}
|
||||
|
||||
return withHash.toLowerCase()
|
||||
}
|
||||
|
||||
function toHexInputValue(color: string): string {
|
||||
return normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
|
||||
}
|
||||
|
||||
export interface RichTextEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
allowImages?: boolean
|
||||
/** Resolve uploaded image file to a public URL. Required when `allowImages` is true. */
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
editorMinHeight?: number
|
||||
locale?: DashboardLocale
|
||||
dir?: 'ltr' | 'rtl'
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowImages = false,
|
||||
onImageUpload,
|
||||
editorMinHeight = 140,
|
||||
locale: localeProp,
|
||||
dir: dirProp,
|
||||
}: RichTextEditorProps) {
|
||||
const localeContext = useContext(LocaleContext)
|
||||
const locale = localeProp ?? localeContext?.locale ?? 'en'
|
||||
const dir = dirProp ?? localeContext?.dir ?? 'ltr'
|
||||
|
||||
const editorShellRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const colorPickerRef = useRef<HTMLDivElement>(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 [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR)
|
||||
const [colorOpen, setColorOpen] = useState(false)
|
||||
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorOpen) return
|
||||
|
||||
function onDocumentClick(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (colorPickerRef.current?.contains(target)) return
|
||||
setColorOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onDocumentClick)
|
||||
return () => document.removeEventListener('mousedown', onDocumentClick)
|
||||
}, [colorOpen])
|
||||
|
||||
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 setBlockDirection(direction: 'rtl' | 'ltr') {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
editor.focus()
|
||||
const block = findIndentBlock(editor)
|
||||
if (block) {
|
||||
block.setAttribute('dir', direction)
|
||||
syncChange()
|
||||
return
|
||||
}
|
||||
|
||||
document.execCommand('dir', false, direction)
|
||||
syncChange()
|
||||
}
|
||||
|
||||
function applyTextColor(color: string) {
|
||||
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
|
||||
setTextColor(normalized)
|
||||
setHexInput(normalized)
|
||||
document.execCommand('styleWithCSS', false, 'true')
|
||||
exec('foreColor', normalized)
|
||||
}
|
||||
|
||||
function toggleColorPicker() {
|
||||
setColorOpen((open) => {
|
||||
const nextOpen = !open
|
||||
if (nextOpen) {
|
||||
setHexInput(toHexInputValue(textColor))
|
||||
}
|
||||
return nextOpen
|
||||
})
|
||||
}
|
||||
|
||||
function commitHexInput(rawValue?: string) {
|
||||
const normalized = normalizeHexColor(rawValue ?? hexInput)
|
||||
if (!normalized) {
|
||||
setHexInput(toHexInputValue(textColor))
|
||||
return
|
||||
}
|
||||
applyTextColor(normalized)
|
||||
}
|
||||
|
||||
function onHexInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitHexInput()
|
||||
editorRef.current?.focus()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setHexInput(toHexInputValue(textColor))
|
||||
setColorOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 || !onImageUpload) return
|
||||
|
||||
setUploadingImage(true)
|
||||
setUploadError('')
|
||||
|
||||
try {
|
||||
const url = await onImageUpload(file)
|
||||
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} />
|
||||
<div ref={colorPickerRef} className={styles.colorPickerWrap}>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
|
||||
onClick={toggleColorPicker}
|
||||
title="Text color"
|
||||
aria-label="Text color"
|
||||
aria-expanded={colorOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Palette size={16} />
|
||||
<span className={styles.colorSwatch} style={{ background: textColor }} aria-hidden="true" />
|
||||
</button>
|
||||
{colorOpen && (
|
||||
<div className={styles.colorPopover} role="dialog" aria-label="Text color">
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorVisualInput}
|
||||
value={textColor}
|
||||
onChange={(e) => applyTextColor(e.target.value)}
|
||||
aria-label="Pick color"
|
||||
/>
|
||||
<label className={styles.hexField}>
|
||||
<span className={styles.hexLabel}>HEX</span>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.hexInput}
|
||||
value={hexInput}
|
||||
onChange={(e) => setHexInput(e.target.value)}
|
||||
onBlur={() => commitHexInput()}
|
||||
onKeyDown={onHexInputKeyDown}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
aria-label="Hex color"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dirBtn}
|
||||
onClick={() => setBlockDirection('rtl')}
|
||||
title="Right to left"
|
||||
aria-label="Right to left"
|
||||
>
|
||||
RTL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dirBtn}
|
||||
onClick={() => setBlockDirection('ltr')}
|
||||
title="Left to right"
|
||||
aria-label="Left to right"
|
||||
>
|
||||
LTR
|
||||
</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 && onImageUpload && (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -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: var(--elevated-surface);
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './Toast.module.css'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{createPortal(
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,23 @@ export {
|
||||
type CityOption,
|
||||
} from './components/AddressListEditor'
|
||||
export { Breadcrumbs, type BreadcrumbItem } from './components/Breadcrumbs'
|
||||
export {
|
||||
EmptyState,
|
||||
EmptyStateEmphasis,
|
||||
EmptyTableRow,
|
||||
ListEmptyMessage,
|
||||
buildListEmptyPlainMessage,
|
||||
type EmptyStateProps,
|
||||
type ListEmptyMessageProps,
|
||||
} from './components/EmptyState'
|
||||
export { Pagination } from './components/Pagination'
|
||||
export type { PaginationProps } from './components/Pagination'
|
||||
export {
|
||||
RichTextEditor,
|
||||
isEmptyRichText,
|
||||
richTextToPlain,
|
||||
type RichTextEditorProps,
|
||||
} from './components/RichTextEditor'
|
||||
export { SectionCard } from './components/SectionCard'
|
||||
export { RouteLoader } from './components/RouteLoader'
|
||||
export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard'
|
||||
|
||||
Reference in New Issue
Block a user