Add business workshop management UI with Jalali scheduling and shared dashboard polish.

Includes workshop list/edit/details, registrations modal, category tree, shared EmptyState/RichTextEditor/toasts, and fixes for the Jalali date picker and thumbnail field layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-22 12:22:27 +03:30
co-authored by Cursor
parent 89269dff04
commit d5aca8bea7
76 changed files with 4719 additions and 974 deletions
+1
View File
@@ -15,6 +15,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-easy-crop": "^6.2.0", "react-easy-crop": "^6.2.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^7.18.1" "react-router-dom": "^7.18.1"
}, },
"devDependencies": { "devDependencies": {
+11
View File
@@ -60,6 +60,11 @@ import { AddNewInstructionPage } from './pages/AddNewInstructionPage'
import { InstructionCategoriesPage } from './pages/InstructionCategoriesPage' import { InstructionCategoriesPage } from './pages/InstructionCategoriesPage'
import { InstructionSettingsPage } from './pages/InstructionSettingsPage' import { InstructionSettingsPage } from './pages/InstructionSettingsPage'
import { InstructionDetailsPage } from './pages/InstructionDetailsPage' 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 { ContentPage } from './pages/ContentPage'
import { WebsitePage } from './pages/WebsitePage' import { WebsitePage } from './pages/WebsitePage'
import { WebsiteContactPage } from './pages/WebsiteContactPage' import { WebsiteContactPage } from './pages/WebsiteContactPage'
@@ -146,6 +151,12 @@ function App() {
<Route path="instructions/edit/:id" element={<AddNewInstructionPage />} /> <Route path="instructions/edit/:id" element={<AddNewInstructionPage />} />
<Route path="instructions/categories" element={<InstructionCategoriesPage />} /> <Route path="instructions/categories" element={<InstructionCategoriesPage />} />
<Route path="instructions/settings" element={<InstructionSettingsPage />} /> <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" element={<WebsitePage />} />
<Route path="website/static-images" element={<WebsiteStaticImagesPage />} /> <Route path="website/static-images" element={<WebsiteStaticImagesPage />} />
<Route path="website/special-items" element={<StoreSpecialsPage />} /> <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 disabled?: boolean
placeholder?: string placeholder?: string
id?: string id?: string
excludeIds?: string[]
onPick?: (customer: BusinessCustomer) => void
} }
function customerLabel(customer: BusinessCustomer, withPhone = false) { function customerLabel(customer: BusinessCustomer, withPhone = false) {
@@ -30,6 +32,8 @@ export function CustomerSearchSelect({
disabled = false, disabled = false,
placeholder, placeholder,
id, id,
excludeIds = [],
onPick,
}: CustomerSearchSelectProps) { }: CustomerSearchSelectProps) {
const t = useT() const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
@@ -94,11 +98,20 @@ export function CustomerSearchSelect({
}, [query, open]) }, [query, open])
function selectOption(customer: BusinessCustomer) { function selectOption(customer: BusinessCustomer) {
if (excludeIds.includes(customer.id)) return
if (onPick) {
onPick(customer)
setOpen(false)
setQuery('')
return
}
onChange(customer) onChange(customer)
setOpen(false) setOpen(false)
setQuery('') setQuery('')
} }
const visibleResults = results.filter((customer) => !excludeIds.includes(customer.id))
return ( return (
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}> <div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
<div <div
@@ -153,10 +166,10 @@ export function CustomerSearchSelect({
<li className={styles.noResults}>{t('issueInvoice.pickCustomerHint')}</li> <li className={styles.noResults}>{t('issueInvoice.pickCustomerHint')}</li>
) : loading ? ( ) : loading ? (
<li className={styles.noResults}>{t('issueInvoice.loading')}</li> <li className={styles.noResults}>{t('issueInvoice.loading')}</li>
) : results.length === 0 ? ( ) : visibleResults.length === 0 ? (
<li className={styles.noResults}>{t('issueInvoice.pickCustomerNoResults')}</li> <li className={styles.noResults}>{t('issueInvoice.pickCustomerNoResults')}</li>
) : ( ) : (
results.map((customer) => ( visibleResults.map((customer) => (
<li key={customer.id}> <li key={customer.id}>
<button <button
type="button" type="button"
@@ -1,53 +0,0 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
padding: 48px 24px;
text-align: center;
border-block: 1px solid color-mix(in srgb, var(--text-muted) 42%, transparent);
}
.icon {
flex-shrink: 0;
color: var(--text-primary);
}
.message {
margin: 0;
max-width: 36rem;
font-family: var(--font-ui);
font-size: 14px;
font-weight: 500;
line-height: 1.7;
color: var(--text-secondary);
}
.action {
appearance: none;
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
font-family: var(--font-ui);
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.action:hover {
text-decoration: underline;
}
.cell {
padding: 0 !important;
background: transparent !important;
border-bottom: none !important;
vertical-align: middle;
}
+9 -51
View File
@@ -1,52 +1,10 @@
import type { LucideIcon } from 'lucide-react' export {
import { Link } from 'react-router-dom' EmptyState,
import styles from './EmptyState.module.css' EmptyStateEmphasis,
EmptyTableRow,
ListEmptyMessage,
buildListEmptyPlainMessage,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
export interface EmptyStateProps { export { ListEmptyState } from './ListEmptyState'
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>
)
}
@@ -11,8 +11,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
width: 100%; box-sizing: border-box;
min-height: 120px; min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4); border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.4);
@@ -35,8 +35,7 @@
.preview { .preview {
position: relative; position: relative;
width: 100%; min-height: 0;
min-height: 120px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
@@ -83,6 +82,10 @@
} }
.cropPanel { .cropPanel {
width: fit-content;
max-width: 100%;
margin-inline: auto;
align-self: center;
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden; overflow: hidden;
@@ -91,15 +94,8 @@
.cropArea { .cropArea {
position: relative; position: relative;
width: 100%;
min-height: 160px;
max-height: 420px;
background: #1e293b;
}
.cropAreaPortrait {
max-height: min(360px, 42vh);
min-height: 0; min-height: 0;
background: #1e293b;
} }
.cropControls { .cropControls {
@@ -162,7 +158,7 @@
.changeBtn { .changeBtn {
display: inline-flex; display: inline-flex;
align-self: flex-start; align-self: center;
padding: 8px 14px; padding: 8px 14px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
+11 -14
View File
@@ -80,19 +80,16 @@ export function ImageCropper({
} }
const isPortrait = aspect < 1 const isPortrait = aspect < 1
const frameStyle: React.CSSProperties = isPortrait const maxHeight = isPortrait ? 'min(360px, 42vh)' : 'min(280px, 36vh)'
? { const frameStyle: React.CSSProperties = {
aspectRatio: `${aspect}`, aspectRatio: String(aspect),
height: 'min(360px, 42vh)', width: `min(100%, calc(${maxHeight} * ${aspect}))`,
width: 'auto', height: 'auto',
maxWidth: '100%', maxHeight,
marginInline: 'auto', minHeight: 0,
} marginInline: 'auto',
: { alignSelf: 'center',
aspectRatio: `${aspect}`, }
width: '100%',
maxHeight: 'min(280px, 36vh)',
}
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
@@ -121,7 +118,7 @@ export function ImageCropper({
{imageSrc && ( {imageSrc && (
<div className={styles.cropPanel}> <div className={styles.cropPanel}>
<div className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`} style={frameStyle}> <div className={styles.cropArea} style={frameStyle}>
<Cropper <Cropper
key={String(aspect)} key={String(aspect)}
image={imageSrc} image={imageSrc}
@@ -39,8 +39,8 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.grid}> <div className={styles.grid}>
{images.map((src, index) => ( {images.filter(Boolean).map((src, index) => (
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}> <div key={`${String(src).slice(0, 32)}-${index}`} className={styles.item}>
<img src={src} alt={t('products.form.images')} /> <img src={src} alt={t('products.form.images')} />
<button <button
type="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; 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 { .gridFour {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -142,14 +154,16 @@
} }
@media (max-width: 1400px) { @media (max-width: 1400px) {
.gridCols4 { .gridCols4,
.gridCols5 {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.grid, .grid,
.gridCols4 { .gridCols4,
.gridCols5 {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -190,7 +204,8 @@
.grid, .grid,
.gridHome, .gridHome,
.gridFour, .gridFour,
.gridCols4 { .gridCols4,
.gridCols5 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 16px;
} }
@@ -4,7 +4,9 @@ import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
import { listAllStoreItems } from '../services/storeItemService' import { listAllStoreItems } from '../services/storeItemService'
import type { SpecialProductsSource } from '../services/settingsService'
import { groupStoreItemsByProduct } from '../utils/storeProductGroups' import { groupStoreItemsByProduct } from '../utils/storeProductGroups'
import { import {
StoreItemMultiSearchSelect, StoreItemMultiSearchSelect,
@@ -16,9 +18,11 @@ import styles from './PickStoreSpecialItemsModal.module.css'
interface PickStoreSpecialItemsModalProps { interface PickStoreSpecialItemsModalProps {
open: boolean open: boolean
specialTitle: string specialTitle: string
existingStoreItemIds: string[] /** Store item ids when source is store_item; product ids when source is product. */
existingIds: string[]
source: SpecialProductsSource
onClose: () => void onClose: () => void
onConfirm: (storeItemIds: string[]) => void onConfirm: (ids: string[]) => void
isSubmitting?: boolean isSubmitting?: boolean
} }
@@ -27,7 +31,8 @@ const ANIMATION_MS = 220
export function PickStoreSpecialItemsModal({ export function PickStoreSpecialItemsModal({
open, open,
specialTitle, specialTitle,
existingStoreItemIds, existingIds,
source,
onClose, onClose,
onConfirm, onConfirm,
isSubmitting = false, isSubmitting = false,
@@ -35,6 +40,7 @@ export function PickStoreSpecialItemsModal({
const t = useT() const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
const isFa = locale === 'fa' const isFa = locale === 'fa'
const fromProducts = source === 'product'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([]) const [options, setOptions] = useState<StoreItemSearchOption[]>([])
@@ -43,7 +49,7 @@ export function PickStoreSpecialItemsModal({
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIds, setSelectedIds] = useState<string[]>([])
const [selectOpen, setSelectOpen] = useState(false) const [selectOpen, setSelectOpen] = useState(false)
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds]) const existingSet = useMemo(() => new Set(existingIds), [existingIds])
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -67,7 +73,7 @@ export function PickStoreSpecialItemsModal({
const controller = new AbortController() const controller = new AbortController()
void loadOptions(controller.signal) void loadOptions(controller.signal)
return () => controller.abort() return () => controller.abort()
}, [open, existingStoreItemIds]) }, [open, existingIds, source])
useEffect(() => { useEffect(() => {
if (!mounted || closing) return if (!mounted || closing) return
@@ -83,51 +89,68 @@ export function PickStoreSpecialItemsModal({
setError('') setError('')
try { try {
const items = await listAllStoreItems(signal) const nextOptions = fromProducts
const listings = groupStoreItemsByProduct(items) ? await loadProductOptions(signal)
const nextOptions = listings : await loadStoreItemOptions(signal)
.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 setOptions(nextOptions.filter((option) => !existingSet.has(option.id)))
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)
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError(t('website.specialItems.pickErrorLoad')) setError(
fromProducts
? t('website.specialItems.pickErrorLoadProducts')
: t('website.specialItems.pickErrorLoad'),
)
} }
} finally { } finally {
setIsLoading(false) 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) => setSelectedIds((prev) =>
prev.includes(storeItemId) prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id],
? prev.filter((id) => id !== storeItemId)
: [...prev, storeItemId],
) )
} }
@@ -167,7 +190,11 @@ export function PickStoreSpecialItemsModal({
<h2 id="pick-special-items-title" className={modalStyles.title}> <h2 id="pick-special-items-title" className={modalStyles.title}>
{t('website.pick.addTo', { title: specialTitle })} {t('website.pick.addTo', { title: specialTitle })}
</h2> </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> </div>
<button <button
type="button" type="button"
@@ -183,15 +210,27 @@ export function PickStoreSpecialItemsModal({
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {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 ? ( ) : 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 <StoreItemMultiSearchSelect
options={options} options={options}
selectedIds={selectedIds} selectedIds={selectedIds}
onToggle={toggleSelection} onToggle={toggleSelection}
placeholder={t('website.specialItems.pickSearch')} placeholder={
fromProducts
? t('website.specialItems.pickSearchProducts')
: t('website.specialItems.pickSearch')
}
disabled={isSubmitting} disabled={isSubmitting}
onOpenChange={setSelectOpen} onOpenChange={setSelectOpen}
/> />
@@ -212,8 +251,7 @@ export function PickStoreSpecialItemsModal({
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
, document.body,
document.body,
) )
} }
+25 -360
View File
@@ -1,369 +1,34 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import { import {
Bold, RichTextEditor as BaseRichTextEditor,
IndentDecrease, isEmptyRichText,
IndentIncrease, richTextToPlain,
Italic, type RichTextEditorProps,
Underline, } from '@meshkee/dashboard-ui'
List,
ListOrdered,
ImagePlus,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { uploadMediaFiles } from '../services/mediaService' import { uploadMediaFiles } from '../services/mediaService'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24 export { isEmptyRichText, richTextToPlain }
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4']) export type { RichTextEditorProps }
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
allowImages?: boolean
editorMinHeight?: number
}
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
export function RichTextEditor({ export function RichTextEditor({
value, allowImages,
onChange, onImageUpload,
placeholder, ...props
allowImages = false,
editorMinHeight = 140,
}: RichTextEditorProps) { }: 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 ( return (
<div className={styles.wrapper} dir={dir} lang={locale}> <BaseRichTextEditor
<div className={styles.toolbar} dir="ltr"> {...props}
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold"> allowImages={allowImages}
<Bold size={16} /> onImageUpload={
</button> allowImages
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic"> ? onImageUpload ??
<Italic size={16} /> (async (file) => {
</button> const uploaded = await uploadMediaFiles([file])
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline"> const url = uploaded[0]?.publicUrl
<Underline size={16} /> if (!url) throw new Error('Image upload failed.')
</button> return url
<span className={styles.divider} /> })
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list"> : undefined
<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>
) )
} }
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}
+10 -2
View File
@@ -68,7 +68,12 @@ function isGroupActive(group: NavGroup, pathname: string) {
return true return true
} }
if (group.id === 'content') { 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 return true
} }
} }
@@ -183,6 +188,9 @@ export function Sidebar({ open = false }: SidebarProps) {
...(hasBusinessModule(enabledModules, 'instructions') ...(hasBusinessModule(enabledModules, 'instructions')
? [{ labelKey: 'nav.content.instructions' as BusinessMessageKey, to: '/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) => { return items.filter((item) => {
if (item.type === 'group' && item.id === 'content') { 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) return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
}) })
@@ -2,7 +2,7 @@
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;
left: 24px; left: 24px;
z-index: 300; z-index: var(--toast-z-index, 11000);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); 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)); backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass)); -webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); 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 { .toastIn {
@@ -20,13 +20,23 @@
.cell { .cell {
flex: 0 0 auto; flex: 0 0 auto;
display: flex;
scroll-snap-align: start; scroll-snap-align: start;
min-width: 0; min-width: 0;
} }
.cellCompact {
align-self: stretch;
}
.cell > span { .cell > span {
display: block; display: flex;
width: 100%; width: 100%;
min-height: 0;
}
.cellCompact > span {
height: 100%;
} }
.cell > span > button { .cell > span > button {
@@ -154,6 +164,11 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
.addCardCompact {
height: 100%;
min-height: 0;
}
.addIcon { .addIcon {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -163,3 +178,8 @@
border-radius: 50%; border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1); 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), (a, b) => a.sortOrder - b.sortOrder || Number(a.id) - Number(b.id),
) )
const fixedCount = itemCount != null && itemCount > 0 ? itemCount : null const fixedCount = itemCount != null && itemCount > 0 ? itemCount : null
const cellCount = fixedCount ?? sorted.length + 1
const assigned = new Map<number, WebsiteStaticImageItem>() const assigned = new Map<number, WebsiteStaticImageItem>()
for (const image of sorted) { for (const image of sorted) {
@@ -59,13 +58,23 @@ export function WebsiteStaticImageGallery({
} }
} }
const cells = Array.from({ length: cellCount }, (_, index) => { const imageCells = fixedCount
const isExtraAdd = !fixedCount && index === cellCount - 1 ? Array.from({ length: fixedCount }, (_, index) => ({
const image = isExtraAdd index,
? undefined image: assigned.get(index),
: (assigned.get(index) ?? (fixedCount ? undefined : sorted[index])) isExtraAdd: false,
return { index, image, isExtraAdd } }))
}) : 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 ( return (
<div className={styles.track}> <div className={styles.track}>
@@ -85,11 +94,13 @@ export function WebsiteStaticImageGallery({
subtext ? { key: t('website.staticImages.subtext'), value: subtext } : null, subtext ? { key: t('website.staticImages.subtext'), value: subtext } : null,
].filter((row): row is { key: string; value: string } => row != null) ].filter((row): row is { key: string; value: string } => row != null)
const compact = isExtraAdd && compactAdd
return ( return (
<div <div
key={`${index}-${image?.id ?? 'empty'}`} key={`${index}-${image?.id ?? 'empty'}`}
className={styles.cell} className={`${styles.cell} ${compact ? styles.cellCompact : ''}`}
style={{ width: size.width }} style={{ width: compact ? compactAddWidth : size.width }}
> >
{image ? ( {image ? (
<article className={styles.card}> <article className={styles.card}>
@@ -183,13 +194,17 @@ export function WebsiteStaticImageGallery({
<Tooltip label={label}> <Tooltip label={label}>
<button <button
type="button" type="button"
className={styles.addCard} className={`${styles.addCard} ${compact ? styles.addCardCompact : ''}`}
onClick={() => onAddAt(index)} onClick={() => onAddAt(index)}
aria-label={label} aria-label={label}
style={{ aspectRatio: cssAspect, minHeight: size.height }} style={
compact
? { minHeight: size.height }
: { aspectRatio: cssAspect, minHeight: size.height }
}
> >
<span className={styles.addIcon}> <span className={`${styles.addIcon} ${compact ? styles.addIconCompact : ''}`}>
<Plus size={28} /> <Plus size={compact ? 20 : 28} />
</span> </span>
</button> </button>
</Tooltip> </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,
)
}
+15 -11
View File
@@ -8,6 +8,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { createPortal } from 'react-dom'
import styles from '../components/Toast.module.css' import styles from '../components/Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info' export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div className={styles.container} aria-live="polite" aria-atomic="true"> {createPortal(
{toast && ( <div className={styles.container} aria-live="polite" aria-atomic="true">
<div {toast && (
key={toast.id} <div
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`} key={toast.id}
role="status" className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
> role="status"
{toast.message} >
</div> {toast.message}
)} </div>
</div> )}
</div>,
document.body,
)}
</ToastContext.Provider> </ToastContext.Provider>
) )
} }
+297 -10
View File
@@ -6,6 +6,10 @@ const en = {
'app.poweredBy': 'powered by Meshkee.app', 'app.poweredBy': 'powered by Meshkee.app',
'app.userFallback': 'User', '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.superAdmin': 'Super Admin',
'role.owner': 'Business Owner', 'role.owner': 'Business Owner',
'role.admin': 'Admin', 'role.admin': 'Admin',
@@ -67,6 +71,11 @@ const en = {
'nav.instructions.new': 'Add New Instruction', 'nav.instructions.new': 'Add New Instruction',
'nav.instructions.categories': 'Categories', 'nav.instructions.categories': 'Categories',
'nav.instructions.settings': 'Settings', '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': 'Website',
'nav.website.overview': 'Overview', 'nav.website.overview': 'Overview',
'nav.website.staticImages': 'Static Images', 'nav.website.staticImages': 'Static Images',
@@ -208,6 +217,8 @@ const en = {
'content.card.videos.link': 'Open videos', 'content.card.videos.link': 'Open videos',
'content.card.instructions.desc': 'Tutorials with rich text and optional embedded video.', 'content.card.instructions.desc': 'Tutorials with rich text and optional embedded video.',
'content.card.instructions.link': 'Open instructions', '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.title': 'Blog',
'home.card.blog.desc': 'Create and manage blog posts and categories.', 'home.card.blog.desc': 'Create and manage blog posts and categories.',
'home.card.blog.link': 'View blog', 'home.card.blog.link': 'View blog',
@@ -283,6 +294,8 @@ const en = {
'products.list.clearFilters': 'Clear filters', 'products.list.clearFilters': 'Clear filters',
'products.list.loading': 'Loading products...', 'products.list.loading': 'Loading products...',
'products.list.empty': 'You have not added any products yet.', '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.emptyCta': 'Add product',
'products.list.emptyFiltered': 'No products match your filters.', 'products.list.emptyFiltered': 'No products match your filters.',
'products.list.errorLoad': 'Unable to load products.', '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.subtitle': '{count} posts · View, edit and manage your blog content.',
'blog.list.loading': 'Loading blog posts...', 'blog.list.loading': 'Loading blog posts...',
'blog.list.empty': 'No blog posts yet.', '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.emptyHint': 'Share news, stories, and updates with your audience.',
'blog.list.emptyCta': 'Add blog', 'blog.list.emptyCta': 'Add blog',
'blog.list.errorLoad': 'Unable to load blog posts.', '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.subtitle': '{count} videos · View, edit and manage your video content.',
'video.list.loading': 'Loading videos...', 'video.list.loading': 'Loading videos...',
'video.list.empty': 'No videos yet.', '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.emptyHint': 'Share embedded videos from YouTube or Aparat with your audience.',
'video.list.emptyCta': 'Add video', 'video.list.emptyCta': 'Add video',
'video.list.errorLoad': 'Unable to load videos.', '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.subtitle': '{count} instructions · View, edit and manage your tutorials.',
'instruction.list.loading': 'Loading instructions...', 'instruction.list.loading': 'Loading instructions...',
'instruction.list.empty': 'No instructions yet.', '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.emptyHint': 'Publish tutorials with rich text and optional video for your audience.',
'instruction.list.emptyCta': 'Add instruction', 'instruction.list.emptyCta': 'Add instruction',
'instruction.list.errorLoad': 'Unable to load instructions.', 'instruction.list.errorLoad': 'Unable to load instructions.',
@@ -1232,6 +1251,113 @@ const en = {
'instruction.details.noDescription': 'No description yet.', 'instruction.details.noDescription': 'No description yet.',
'instruction.details.author': 'Author', '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.page.subtitle': 'Manage your portfolio items and showcase projects.',
'portfolio.card.list.desc': 'View, edit and manage all your portfolio items.', 'portfolio.card.list.desc': 'View, edit and manage all your portfolio items.',
'portfolio.card.list.link': 'View portfolios', '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.subtitle': '{count} items · View, edit and manage your portfolio content.',
'portfolio.list.loading': 'Loading portfolios...', 'portfolio.list.loading': 'Loading portfolios...',
'portfolio.list.empty': 'You have not added any portfolio items yet.', '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.emptyCta': 'Add portfolio',
'portfolio.list.errorLoad': 'Unable to load portfolios.', 'portfolio.list.errorLoad': 'Unable to load portfolios.',
'portfolio.list.errorDelete': 'Unable to delete portfolio.', 'portfolio.list.errorDelete': 'Unable to delete portfolio.',
@@ -1562,23 +1690,28 @@ const en = {
'website.specialItems.addItemsTo': 'Add store items to {title}', 'website.specialItems.addItemsTo': 'Add store items to {title}',
'website.specialItems.removeFromSpecial': 'Remove from special', 'website.specialItems.removeFromSpecial': 'Remove from special',
'website.specialItems.pickSubtitle': 'Select store items to feature in this category.', '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.pickLoading': 'Loading store items...',
'website.specialItems.pickLoadingProducts': 'Loading products...',
'website.specialItems.pickEmpty': 'All store items are already in this category.', '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.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.pickErrorLoad': 'Unable to load store items.',
'website.specialItems.pickErrorLoadProducts': 'Unable to load products.',
'website.settings.title': 'Website settings', 'website.settings.title': 'Website settings',
'website.settings.subtitle': 'Configure how website content is sourced and displayed.', 'website.settings.subtitle': 'Configure how website content is sourced and displayed.',
'website.settings.section': 'Special products',
'website.settings.loading': 'Loading settings...', 'website.settings.loading': 'Loading settings...',
'website.settings.errorLoad': 'Unable to load settings.', 'website.settings.errorLoad': 'Unable to load settings.',
'website.settings.errorSave': 'Unable to save settings.', 'website.settings.errorSave': 'Unable to save settings.',
'website.settings.saved': 'Settings saved.', 'website.settings.saved': 'Settings saved.',
'website.settings.specialSource': 'Read special products from', 'website.settings.specialSource': 'Special products source',
'website.settings.specialSourceDesc': '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.', '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': 'Product', 'website.settings.source.product': 'Products',
'website.settings.source.storeItem': 'Store item', 'website.settings.source.storeItem': 'Store',
'website.pick.addOneItem': 'Add 1 item', 'website.pick.addOneItem': 'Add 1 item',
'website.pick.addItems': 'Add {count} items', 'website.pick.addItems': 'Add {count} items',
@@ -1692,6 +1825,11 @@ const en = {
'title.addInstruction': 'Add New Instruction', 'title.addInstruction': 'Add New Instruction',
'title.editInstruction': 'Edit Instruction', 'title.editInstruction': 'Edit Instruction',
'title.instructionDetails': 'Instruction Details', '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.myVideos': 'My Videos',
'title.addVideo': 'Add New Video', 'title.addVideo': 'Add New Video',
'title.editVideo': 'Edit Video', 'title.editVideo': 'Edit Video',
@@ -2021,6 +2159,10 @@ const fa: Record<MessageKey, string> = {
'app.poweredBy': 'قدرت‌گرفته از Meshkee.app', 'app.poweredBy': 'قدرت‌گرفته از Meshkee.app',
'app.userFallback': 'کاربر', 'app.userFallback': 'کاربر',
'empty.list.part1': 'شما در حال حاضر ',
'empty.list.part2': ' ندارید. برای شروع ',
'empty.list.part3': ' می‌توانید از دکمهٔ زیر استفاده نمایید.',
'role.superAdmin': 'سوپرادمین', 'role.superAdmin': 'سوپرادمین',
'role.owner': 'صاحب کسب‌وکار', 'role.owner': 'صاحب کسب‌وکار',
'role.admin': 'ادمین', 'role.admin': 'ادمین',
@@ -2082,6 +2224,11 @@ const fa: Record<MessageKey, string> = {
'nav.instructions.new': 'افزودن آموزش', 'nav.instructions.new': 'افزودن آموزش',
'nav.instructions.categories': 'دسته‌بندی‌ها', 'nav.instructions.categories': 'دسته‌بندی‌ها',
'nav.instructions.settings': 'تنظیمات', 'nav.instructions.settings': 'تنظیمات',
'nav.workshops': 'کارگاه‌ها',
'nav.workshops.overview': 'نمای کلی',
'nav.workshops.list': 'کارگاه‌های من',
'nav.workshops.new': 'افزودن کارگاه',
'nav.workshops.categories': 'دسته‌بندی‌ها',
'nav.website': 'وب‌سایت', 'nav.website': 'وب‌سایت',
'nav.website.overview': 'نمای کلی', 'nav.website.overview': 'نمای کلی',
'nav.website.staticImages': 'تصاویر ثابت', 'nav.website.staticImages': 'تصاویر ثابت',
@@ -2223,6 +2370,8 @@ const fa: Record<MessageKey, string> = {
'content.card.videos.link': 'ورود به ویدیوها', 'content.card.videos.link': 'ورود به ویدیوها',
'content.card.instructions.desc': 'آموزش‌ها با متن غنی و ویدیوی جاسازی‌شده اختیاری.', 'content.card.instructions.desc': 'آموزش‌ها با متن غنی و ویدیوی جاسازی‌شده اختیاری.',
'content.card.instructions.link': 'ورود به آموزش‌ها', 'content.card.instructions.link': 'ورود به آموزش‌ها',
'content.card.workshops.desc': 'کارگاه‌ها با متن غنی، گالری تصویر و ثبت‌نام مشتری.',
'content.card.workshops.link': 'ورود به کارگاه‌ها',
'home.card.blog.title': 'بلاگ', 'home.card.blog.title': 'بلاگ',
'home.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.', 'home.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.',
'home.card.blog.link': 'مشاهده بلاگ', 'home.card.blog.link': 'مشاهده بلاگ',
@@ -2298,6 +2447,8 @@ const fa: Record<MessageKey, string> = {
'products.list.clearFilters': 'پاک کردن فیلترها', 'products.list.clearFilters': 'پاک کردن فیلترها',
'products.list.loading': 'در حال بارگذاری محصولات...', 'products.list.loading': 'در حال بارگذاری محصولات...',
'products.list.empty': 'هنوز محصولی ثبت نکرده‌اید.', 'products.list.empty': 'هنوز محصولی ثبت نکرده‌اید.',
'products.list.emptyNone': 'هیچ محصولی',
'products.list.emptyAction': 'ثبت محصول',
'products.list.emptyCta': 'افزودن محصول', 'products.list.emptyCta': 'افزودن محصول',
'products.list.emptyFiltered': 'هیچ محصولی با فیلترهای شما مطابقت ندارد.', 'products.list.emptyFiltered': 'هیچ محصولی با فیلترهای شما مطابقت ندارد.',
'products.list.errorLoad': 'بارگذاری محصولات ممکن نشد.', 'products.list.errorLoad': 'بارگذاری محصولات ممکن نشد.',
@@ -3019,6 +3170,8 @@ const fa: Record<MessageKey, string> = {
'blog.list.subtitle': '{count} مطلب · مشاهده، ویرایش و مدیریت محتوای بلاگ.', 'blog.list.subtitle': '{count} مطلب · مشاهده، ویرایش و مدیریت محتوای بلاگ.',
'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...', 'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...',
'blog.list.empty': 'هنوز مطلب بلاگی ندارید.', 'blog.list.empty': 'هنوز مطلب بلاگی ندارید.',
'blog.list.emptyNone': 'هیچ مطلب بلاگی',
'blog.list.emptyAction': 'ثبت بلاگ',
'blog.list.emptyHint': 'اخبار، داستان‌ها و به‌روزرسانی‌ها را با مخاطبان خود به اشتراک بگذارید.', 'blog.list.emptyHint': 'اخبار، داستان‌ها و به‌روزرسانی‌ها را با مخاطبان خود به اشتراک بگذارید.',
'blog.list.emptyCta': 'افزودن مطلب', 'blog.list.emptyCta': 'افزودن مطلب',
'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.', 'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.',
@@ -3103,6 +3256,8 @@ const fa: Record<MessageKey, string> = {
'video.list.subtitle': '{count} ویدیو · مشاهده، ویرایش و مدیریت محتوای ویدیو.', 'video.list.subtitle': '{count} ویدیو · مشاهده، ویرایش و مدیریت محتوای ویدیو.',
'video.list.loading': 'در حال بارگذاری ویدیوها...', 'video.list.loading': 'در حال بارگذاری ویدیوها...',
'video.list.empty': 'هنوز ویدیویی نیست.', 'video.list.empty': 'هنوز ویدیویی نیست.',
'video.list.emptyNone': 'هیچ ویدیویی',
'video.list.emptyAction': 'ثبت ویدیو',
'video.list.emptyHint': 'ویدیوهای جاسازی‌شده از یوتیوب یا آپارات را با مخاطبان به اشتراک بگذارید.', 'video.list.emptyHint': 'ویدیوهای جاسازی‌شده از یوتیوب یا آپارات را با مخاطبان به اشتراک بگذارید.',
'video.list.emptyCta': 'افزودن ویدیو', 'video.list.emptyCta': 'افزودن ویدیو',
'video.list.errorLoad': 'بارگذاری ویدیوها ممکن نشد.', 'video.list.errorLoad': 'بارگذاری ویدیوها ممکن نشد.',
@@ -3181,6 +3336,8 @@ const fa: Record<MessageKey, string> = {
'instruction.list.subtitle': '{count} آموزش · مشاهده، ویرایش و مدیریت محتوای آموزشی.', 'instruction.list.subtitle': '{count} آموزش · مشاهده، ویرایش و مدیریت محتوای آموزشی.',
'instruction.list.loading': 'در حال بارگذاری آموزش‌ها...', 'instruction.list.loading': 'در حال بارگذاری آموزش‌ها...',
'instruction.list.empty': 'هنوز آموزشی نیست.', 'instruction.list.empty': 'هنوز آموزشی نیست.',
'instruction.list.emptyNone': 'هیچ آموزشی',
'instruction.list.emptyAction': 'ثبت آموزش',
'instruction.list.emptyHint': 'آموزش‌ها را با متن غنی و ویدیوی اختیاری برای مخاطبان منتشر کنید.', 'instruction.list.emptyHint': 'آموزش‌ها را با متن غنی و ویدیوی اختیاری برای مخاطبان منتشر کنید.',
'instruction.list.emptyCta': 'افزودن آموزش', 'instruction.list.emptyCta': 'افزودن آموزش',
'instruction.list.errorLoad': 'بارگذاری آموزش‌ها ممکن نشد.', 'instruction.list.errorLoad': 'بارگذاری آموزش‌ها ممکن نشد.',
@@ -3245,6 +3402,113 @@ const fa: Record<MessageKey, string> = {
'instruction.details.noDescription': 'هنوز توضیحاتی نیست.', 'instruction.details.noDescription': 'هنوز توضیحاتی نیست.',
'instruction.details.author': 'نویسنده', '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.page.subtitle': 'نمونه کارها را مدیریت کنید و پروژه‌ها را به نمایش بگذارید.',
'portfolio.card.list.desc': 'همه نمونه کارها را مشاهده، ویرایش و مدیریت کنید.', 'portfolio.card.list.desc': 'همه نمونه کارها را مشاهده، ویرایش و مدیریت کنید.',
'portfolio.card.list.link': 'مشاهده نمونه کارها', 'portfolio.card.list.link': 'مشاهده نمونه کارها',
@@ -3260,6 +3524,8 @@ const fa: Record<MessageKey, string> = {
'portfolio.list.subtitle': '{count} مورد · مشاهده، ویرایش و مدیریت محتوای نمونه کار.', 'portfolio.list.subtitle': '{count} مورد · مشاهده، ویرایش و مدیریت محتوای نمونه کار.',
'portfolio.list.loading': 'در حال بارگذاری نمونه کارها...', 'portfolio.list.loading': 'در حال بارگذاری نمونه کارها...',
'portfolio.list.empty': 'هنوز نمونه کاری ثبت نکرده‌اید.', 'portfolio.list.empty': 'هنوز نمونه کاری ثبت نکرده‌اید.',
'portfolio.list.emptyNone': 'هیچ نمونه کاری',
'portfolio.list.emptyAction': 'ثبت نمونه کار',
'portfolio.list.emptyCta': 'افزودن نمونه کار', 'portfolio.list.emptyCta': 'افزودن نمونه کار',
'portfolio.list.errorLoad': 'بارگذاری نمونه کارها ممکن نشد.', 'portfolio.list.errorLoad': 'بارگذاری نمونه کارها ممکن نشد.',
'portfolio.list.errorDelete': 'حذف نمونه کار ممکن نشد.', 'portfolio.list.errorDelete': 'حذف نمونه کار ممکن نشد.',
@@ -3574,23 +3840,28 @@ const fa: Record<MessageKey, string> = {
'website.specialItems.addItemsTo': 'افزودن محصولات فروشگاه به {title}', 'website.specialItems.addItemsTo': 'افزودن محصولات فروشگاه به {title}',
'website.specialItems.removeFromSpecial': 'حذف از ویژه', 'website.specialItems.removeFromSpecial': 'حذف از ویژه',
'website.specialItems.pickSubtitle': 'محصولات فروشگاه را برای نمایش در این دسته انتخاب کنید.', 'website.specialItems.pickSubtitle': 'محصولات فروشگاه را برای نمایش در این دسته انتخاب کنید.',
'website.specialItems.pickSubtitleProducts':
'محصولات را برای نمایش در این دسته انتخاب کنید.',
'website.specialItems.pickLoading': 'در حال بارگذاری محصولات فروشگاه...', 'website.specialItems.pickLoading': 'در حال بارگذاری محصولات فروشگاه...',
'website.specialItems.pickLoadingProducts': 'در حال بارگذاری محصولات...',
'website.specialItems.pickEmpty': 'همه محصولات فروشگاه قبلاً در این دسته هستند.', 'website.specialItems.pickEmpty': 'همه محصولات فروشگاه قبلاً در این دسته هستند.',
'website.specialItems.pickEmptyProducts': 'همه محصولات قبلاً در این دسته هستند.',
'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی محصولات فروشگاه تایپ کنید', 'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی محصولات فروشگاه تایپ کنید',
'website.specialItems.pickSearchProducts': 'حداقل ۳ حرف برای جستجوی محصولات تایپ کنید',
'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.', 'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
'website.specialItems.pickErrorLoadProducts': 'بارگذاری محصولات ممکن نشد.',
'website.settings.title': 'تنظیمات وب‌سایت', 'website.settings.title': 'تنظیمات وب‌سایت',
'website.settings.subtitle': 'نحوه منبع‌گیری و نمایش محتوای وب‌سایت را پیکربندی کنید.', 'website.settings.subtitle': 'نحوه منبع‌گیری و نمایش محتوای وب‌سایت را پیکربندی کنید.',
'website.settings.section': 'محصولات ویژه',
'website.settings.loading': 'در حال بارگذاری تنظیمات...', 'website.settings.loading': 'در حال بارگذاری تنظیمات...',
'website.settings.errorLoad': 'بارگذاری تنظیمات ممکن نشد.', 'website.settings.errorLoad': 'بارگذاری تنظیمات ممکن نشد.',
'website.settings.errorSave': 'ذخیره تنظیمات ممکن نشد.', 'website.settings.errorSave': 'ذخیره تنظیمات ممکن نشد.',
'website.settings.saved': 'تنظیمات ذخیره شد.', 'website.settings.saved': 'تنظیمات ذخیره شد.',
'website.settings.specialSource': 'خواندن محصولات ویژه از', 'website.settings.specialSource': 'سورس محصولات ویژه',
'website.settings.specialSourceDesc': 'website.settings.specialSourceDesc':
'انتخاب کنید محصولات ویژه وب‌سایت از کاتالوگ محصول خوانده شوند یا از محصولات فروشگاه. اگر ماژول فروشگاه فعال باشد، پیش‌فرض محصول فروشگاه است.', 'مشخص کنید محصولات ویژه ی شما از کدام بخش خوانده شود: محصولاتی که برای فروش به فروشگاه اضافه شده است و یا خود محصولات (بدون قیمت و موجودی)',
'website.settings.source.product': 'محصول', 'website.settings.source.product': 'محصولات',
'website.settings.source.storeItem': 'محصول فروشگاه', 'website.settings.source.storeItem': 'فروشگاه',
'website.pick.addOneItem': 'افزودن ۱ قلم', 'website.pick.addOneItem': 'افزودن ۱ قلم',
'website.pick.addItems': 'افزودن {count} قلم', 'website.pick.addItems': 'افزودن {count} قلم',
@@ -3706,6 +3977,11 @@ const fa: Record<MessageKey, string> = {
'title.addInstruction': 'افزودن آموزش', 'title.addInstruction': 'افزودن آموزش',
'title.editInstruction': 'ویرایش آموزش', 'title.editInstruction': 'ویرایش آموزش',
'title.instructionDetails': 'جزئیات آموزش', 'title.instructionDetails': 'جزئیات آموزش',
'title.workshops': 'کارگاه‌ها',
'title.myWorkshops': 'کارگاه‌های من',
'title.addWorkshop': 'افزودن کارگاه',
'title.editWorkshop': 'ویرایش کارگاه',
'title.workshopDetails': 'جزئیات کارگاه',
'title.myVideos': 'ویدیوهای من', 'title.myVideos': 'ویدیوهای من',
'title.addVideo': 'افزودن ویدیو', 'title.addVideo': 'افزودن ویدیو',
'title.editVideo': 'ویرایش ویدیو', 'title.editVideo': 'ویرایش ویدیو',
@@ -4077,6 +4353,11 @@ const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
'Add New Video': 'nav.videos.new', 'Add New Video': 'nav.videos.new',
'Edit Video': 'bc.editVideo', 'Edit Video': 'bc.editVideo',
'Video Details': 'bc.videoDetails', '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', Website: 'nav.website',
'Static Images': 'nav.website.staticImages', 'Static Images': 'nav.website.staticImages',
Sliders: 'nav.website.sliders', 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/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/settings', labels: [t('title.content'), t('title.instructions'), t('title.settings')] },
{ match: '/instructions', labels: [t('title.content'), t('title.instructions')] }, { 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/list', labels: [t('title.portfolios'), t('title.myPortfolios')] },
{ {
match: /^\/portfolios\/detail\/[^/]+$/, match: /^\/portfolios\/detail\/[^/]+$/,
+3
View File
@@ -38,6 +38,9 @@
--blur-glass: 28px; --blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4); --modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px; --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-primary: #1e293b;
--text-secondary: #64748b; --text-secondary: #64748b;
--text-muted: #94a3b8; --text-muted: #94a3b8;
+10 -10
View File
@@ -319,17 +319,17 @@ export function AddNewPortfolioPage() {
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
</div>
<div className={styles.field}> <div className={`${styles.field} ${styles.col12}`}>
<label>{t('portfolio.form.mainText')}</label> <label>{t('portfolio.form.mainText')}</label>
<RichTextEditor <RichTextEditor
value={mainTextHtml} value={mainTextHtml}
onChange={setMainTextHtml} onChange={setMainTextHtml}
placeholder={t('portfolio.form.mainTextPlaceholder')} placeholder={t('portfolio.form.mainTextPlaceholder')}
allowImages allowImages
editorMinHeight={280} editorMinHeight={280}
/> />
</div>
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
@@ -41,8 +41,21 @@
min-width: 0; min-width: 0;
} }
.scheduleRow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 16px;
min-width: 0;
}
.thumbnailField { .thumbnailField {
align-self: start; align-self: start;
align-items: center;
}
.thumbnailField label {
width: 100%;
text-align: center;
} }
.topFields { .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>
)
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { FileText, Plus } from 'lucide-react' import { FileText, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState' import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
import { BlogCard } from '../components/BlogCard' import { BlogCard } from '../components/BlogCard'
import { BlogCommentsModal } from '../components/BlogCommentsModal' import { BlogCommentsModal } from '../components/BlogCommentsModal'
@@ -182,9 +182,9 @@ export function BlogListPage() {
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>{t('blog.list.loading')}</p> <p className={styles.empty}>{t('blog.list.loading')}</p>
) : blogs.length === 0 ? ( ) : blogs.length === 0 ? (
<EmptyState <ListEmptyState
entity="blog"
icon={FileText} icon={FileText}
message={t('blog.list.empty')}
actionLabel={t('blog.list.emptyCta')} actionLabel={t('blog.list.emptyCta')}
actionTo="/blog/new" actionTo="/blog/new"
/> />
+10 -2
View File
@@ -1,4 +1,4 @@
import { FileText, GraduationCap } from 'lucide-react' import { FileText, GraduationCap, Presentation } from 'lucide-react'
import { SectionCard } from '../components/SectionCard' import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
@@ -14,7 +14,7 @@ const contentSections: {
descKey: BusinessMessageKey descKey: BusinessMessageKey
linkKey: BusinessMessageKey linkKey: BusinessMessageKey
href: string href: string
moduleId: 'blog' | 'videos' | 'instructions' moduleId: 'blog' | 'videos' | 'instructions' | 'workshops'
}[] = [ }[] = [
{ {
icon: FileText, icon: FileText,
@@ -32,6 +32,14 @@ const contentSections: {
href: '/instructions', href: '/instructions',
moduleId: '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() { export function ContentPage() {
+9 -4
View File
@@ -21,6 +21,7 @@ import { listBlogs } from '../services/blogService'
import { listPortfolios } from '../services/portfolioService' import { listPortfolios } from '../services/portfolioService'
import { listVideos } from '../services/videoService' import { listVideos } from '../services/videoService'
import { listInstructions } from '../services/instructionService' import { listInstructions } from '../services/instructionService'
import { listWorkshops } from '../services/workshopService'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { import {
hasBusinessModule, hasBusinessModule,
@@ -93,7 +94,7 @@ const sections: {
countLabelKey: 'home.card.content.count', countLabelKey: 'home.card.content.count',
href: '/content', href: '/content',
countKey: 'content', countKey: 'content',
anyModuleIds: ['blog', 'videos', 'instructions'], anyModuleIds: ['blog', 'videos', 'instructions', 'workshops'],
}, },
{ {
icon: Briefcase, icon: Briefcase,
@@ -110,7 +111,8 @@ const sections: {
type SectionCounts = Partial<Record<CountKey, number>> type SectionCounts = Partial<Record<CountKey, number>>
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> { 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), listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
listStoreItems(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), 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), listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
listVideos(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), listInstructions(1, 1, signal).then((r) => r.total).catch(() => null),
listWorkshops(1, 1, signal).then((r) => r.total).catch(() => null),
]) ])
const counts: SectionCounts = {} const counts: SectionCounts = {}
@@ -125,8 +128,10 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
if (store !== null) counts.store = store if (store !== null) counts.store = store
if (customers !== null) counts.customers = customers if (customers !== null) counts.customers = customers
if (portfolios !== null) counts.portfolios = portfolios if (portfolios !== null) counts.portfolios = portfolios
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0) const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0) + (workshops ?? 0)
if (blog !== null || videos !== null || instructions !== null) counts.content = contentTotal if (blog !== null || videos !== null || instructions !== null || workshops !== null) {
counts.content = contentTotal
}
return counts return counts
} }
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Play, Plus } from 'lucide-react' import { Play, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState' import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
import { InstructionCard } from '../components/InstructionCard' import { InstructionCard } from '../components/InstructionCard'
import { InstructionCommentsModal } from '../components/InstructionCommentsModal' import { InstructionCommentsModal } from '../components/InstructionCommentsModal'
@@ -159,9 +159,9 @@ export function InstructionListPage() {
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>{t('instruction.list.loading')}</p> <p className={styles.empty}>{t('instruction.list.loading')}</p>
) : videos.length === 0 ? ( ) : videos.length === 0 ? (
<EmptyState <ListEmptyState
entity="instruction"
icon={Play} icon={Play}
message={t('instruction.list.empty')}
actionLabel={t('instruction.list.emptyCta')} actionLabel={t('instruction.list.emptyCta')}
actionTo="/instructions/new" actionTo="/instructions/new"
/> />
+2 -2
View File
@@ -71,10 +71,10 @@
} }
.businessName { .businessName {
font-size: 20px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.25; line-height: 1.3;
} }
.appName { .appName {
+11 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react' 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 { ProductCard } from '../components/ProductCard'
import { AddProductByAiModal } from '../components/AddProductByAiModal' import { AddProductByAiModal } from '../components/AddProductByAiModal'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
@@ -258,12 +258,16 @@ export function MyProductsPage() {
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>{t('products.list.loading')}</p> <p className={styles.empty}>{t('products.list.loading')}</p>
) : products.length === 0 ? ( ) : products.length === 0 ? (
<EmptyState appliedName ? (
icon={Package} <EmptyState message={t('products.list.emptyFiltered')} />
message={appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')} ) : (
actionLabel={appliedName ? undefined : t('products.list.emptyCta')} <ListEmptyState
actionTo={appliedName ? undefined : '/products/new'} entity="products"
/> icon={Package}
actionLabel={t('products.list.emptyCta')}
actionTo="/products/new"
/>
)
) : ( ) : (
<> <>
<div className={styles.grid}> <div className={styles.grid}>
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Briefcase, Plus } from 'lucide-react' import { Briefcase, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState' import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
import { PortfolioCard } from '../components/PortfolioCard' import { PortfolioCard } from '../components/PortfolioCard'
import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal' import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
@@ -190,9 +190,9 @@ export function PortfolioListPage() {
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>{t('portfolio.list.loading')}</p> <p className={styles.empty}>{t('portfolio.list.loading')}</p>
) : portfolios.length === 0 ? ( ) : portfolios.length === 0 ? (
<EmptyState <ListEmptyState
entity="portfolio"
icon={Briefcase} icon={Briefcase}
message={t('portfolio.list.empty')}
actionLabel={t('portfolio.list.emptyCta')} actionLabel={t('portfolio.list.emptyCta')}
actionTo="/portfolios/new" actionTo="/portfolios/new"
/> />
+60 -8
View File
@@ -22,6 +22,11 @@ import {
listStoreSpecials, listStoreSpecials,
updateStoreSpecial, updateStoreSpecial,
} from '../services/storeSpecialService' } 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 { StoreItem } from '../services/storeItemService'
import type { StoreSpecial } from '../types/storeSpecial' import type { StoreSpecial } from '../types/storeSpecial'
import type { StoreProductListing } from '../utils/storeProductGroups' import type { StoreProductListing } from '../utils/storeProductGroups'
@@ -29,11 +34,23 @@ import pageStyles from '../components/PageContent.module.css'
import fabStyles from './StoreItemsPage.module.css' import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.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() { export function StoreSpecialsPage() {
const t = useT() const t = useT()
const { itemCount, hasItems, addVariant } = useDraftCart() const { itemCount, hasItems, addVariant } = useDraftCart()
const { showToast } = useToast() const { showToast } = useToast()
const [specials, setSpecials] = useState<StoreSpecial[]>([]) const [specials, setSpecials] = useState<StoreSpecial[]>([])
const [specialSource, setSpecialSource] = useState<SpecialProductsSource>('store_item')
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -61,10 +78,36 @@ export function StoreSpecialsPage() {
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
void loadSpecials(controller.signal) void loadPage(controller.signal)
return () => controller.abort() 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) { async function loadSpecials(signal?: AbortSignal) {
setIsLoading(true) setIsLoading(true)
setError('') setError('')
@@ -212,19 +255,23 @@ export function StoreSpecialsPage() {
} }
} }
async function handleAddItems(storeItemIds: string[]) { async function handleAddItems(ids: string[]) {
if (!pickItemsTarget) return if (!pickItemsTarget) return
const existingIds = pickItemsTarget.items.map((item) => item.id) const fromProducts = specialSource === 'product'
const nextIds = [...existingIds, ...storeItemIds] const existingIds = pickItemsTarget.items.map((item) =>
fromProducts ? item.productId : item.id,
)
const nextIds = [...existingIds, ...ids]
setIsSaving(true) setIsSaving(true)
setError('') setError('')
try { try {
const result = await updateStoreSpecial(pickItemsTarget.id, { const result = await updateStoreSpecial(
storeItemIds: nextIds, pickItemsTarget.id,
}) fromProducts ? { productIds: nextIds } : { storeItemIds: nextIds },
)
replaceSpecial(result.special) replaceSpecial(result.special)
setPickItemsTarget(null) setPickItemsTarget(null)
showToast(t('website.specialItems.toastAdded'), 'success') showToast(t('website.specialItems.toastAdded'), 'success')
@@ -402,7 +449,12 @@ export function StoreSpecialsPage() {
<PickStoreSpecialItemsModal <PickStoreSpecialItemsModal
open={!!pickItemsTarget} open={!!pickItemsTarget}
specialTitle={pickItemsTarget?.title ?? ''} 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)} onClose={() => !isSaving && setPickItemsTarget(null)}
onConfirm={handleAddItems} onConfirm={handleAddItems}
isSubmitting={isSaving} isSubmitting={isSaving}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Play, Plus } from 'lucide-react' import { Play, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState' import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
import { VideoCard } from '../components/VideoCard' import { VideoCard } from '../components/VideoCard'
import { VideoCommentsModal } from '../components/VideoCommentsModal' import { VideoCommentsModal } from '../components/VideoCommentsModal'
@@ -159,9 +159,9 @@ export function VideoListPage() {
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>{t('video.list.loading')}</p> <p className={styles.empty}>{t('video.list.loading')}</p>
) : videos.length === 0 ? ( ) : videos.length === 0 ? (
<EmptyState <ListEmptyState
entity="video"
icon={Play} icon={Play}
message={t('video.list.empty')}
actionLabel={t('video.list.emptyCta')} actionLabel={t('video.list.emptyCta')}
actionTo="/videos/new" actionTo="/videos/new"
/> />
@@ -14,7 +14,7 @@ import pageStyles from '../components/PageContent.module.css'
import sharedStyles from './ProductSettingsPage.module.css' import sharedStyles from './ProductSettingsPage.module.css'
import styles from './WebsiteSettingsPage.module.css' import styles from './WebsiteSettingsPage.module.css'
const SOURCE_OPTIONS: SpecialProductsSource[] = ['product', 'store_item'] const SOURCE_OPTIONS: SpecialProductsSource[] = ['store_item', 'product']
function defaultSpecialProductsSource( function defaultSpecialProductsSource(
enabledModules: string[] | undefined, enabledModules: string[] | undefined,
@@ -113,8 +113,6 @@ export function WebsiteSettingsPage() {
)} )}
<section className={sharedStyles.panel}> <section className={sharedStyles.panel}>
<h3 className={sharedStyles.sectionTitle}>{t('website.settings.section')}</h3>
{isLoading ? ( {isLoading ? (
<p className={sharedStyles.status}>{t('website.settings.loading')}</p> <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>
)
}
+75
View File
@@ -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 key: string
title: string title: string
storeItemIds?: string[] storeItemIds?: string[]
productIds?: string[]
sortOrder?: number sortOrder?: number
isActive?: boolean isActive?: boolean
} }
@@ -21,6 +22,7 @@ export interface UpdateStoreSpecialPayload {
key?: string key?: string
title?: string title?: string
storeItemIds?: string[] storeItemIds?: string[]
productIds?: string[]
sortOrder?: number sortOrder?: number
isActive?: boolean isActive?: boolean
} }
@@ -0,0 +1,76 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'workshop' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listWorkshopCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createWorkshopCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updateWorkshopCategory(categoryId: string, payload: UpdateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteWorkshopCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export function toWorkshopCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapWorkshopCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
@@ -0,0 +1,413 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { extractFirstImageUrl } from '../utils/htmlImages'
import type {
Workshop,
WorkshopAuthor,
WorkshopDetail,
WorkshopGalleryItem,
WorkshopStatus,
} from '../types/workshop'
export const WORKSHOPS_PER_PAGE = 12
export interface WorkshopApi {
id: string
businessId: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
descriptionHtml: string
status: WorkshopStatus
categoryId: string | null
categoryName: string
tags: string[]
coverImageUrl: string | null
featuredMediaId: string | null
gallery: WorkshopGalleryItem[]
galleryMediaIds: string[]
registrationCount: number
authorId?: string | null
author?: WorkshopAuthor | null
publishedAt: string | null
createdAt: string
updatedAt: string
eventDate: string | null
eventTime: string | null
duration: string | null
}
export interface WorkshopsListResponse {
items: WorkshopApi[]
total: number
page: number
pageSize: number
}
export interface ListWorkshopsParams {
page?: number
pageSize?: number
status?: WorkshopStatus
categoryId?: string
title?: string
}
export interface WorkshopRegistrationUser {
id: string
firstName: string | null
lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
email: string | null
cellNumber: string | null
}
export type WorkshopRegistrationStatus = 'pending' | 'approved' | 'rejected'
export interface WorkshopRegistration {
id: string
workshopId: string
userId: string
status: WorkshopRegistrationStatus
createdAt: string
user: WorkshopRegistrationUser
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/workshops${suffix}`
}
export interface CreateWorkshopPayload {
titleFa: string
titleEn?: string
abstract?: string
descriptionHtml?: string
categoryId?: string
status?: WorkshopStatus
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
eventDate?: string | null
eventTime?: string | null
duration?: string | null
}
export interface UpdateWorkshopPayload {
titleFa?: string
titleEn?: string | null
abstract?: string | null
descriptionHtml?: string | null
categoryId?: string | null
status?: WorkshopStatus
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
eventDate?: string | null
eventTime?: string | null
duration?: string | null
}
export function resolveWorkshopTitle(
workshop: Pick<Workshop, 'titleFa' | 'titleEn' | 'title'>,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
return workshop.titleFa || workshop.title
}
return workshop.titleEn?.trim() || workshop.titleFa || workshop.title
}
export function resolveWorkshopCoverImageUrl(workshop: WorkshopApi): string | null {
const featured = workshop.coverImageUrl?.trim()
if (featured) return featured
const galleryUrl = workshop.gallery?.map((item) => item.url?.trim()).find(Boolean)
if (galleryUrl) return galleryUrl
return extractFirstImageUrl(workshop.descriptionHtml) || null
}
export function mapWorkshopApiToUi(workshop: WorkshopApi): Workshop {
return {
id: workshop.id,
title: workshop.titleFa || workshop.title,
titleFa: workshop.titleFa || workshop.title,
titleEn: workshop.titleEn ?? null,
slug: workshop.slug,
abstract: workshop.abstract,
status: workshop.status,
categoryId: workshop.categoryId,
categoryName: workshop.categoryName,
tags: workshop.tags,
coverImageUrl: resolveWorkshopCoverImageUrl(workshop),
registrationCount: workshop.registrationCount ?? 0,
author: workshop.author ?? null,
publishedAt: workshop.publishedAt,
createdAt: workshop.createdAt,
updatedAt: workshop.updatedAt,
eventDate: workshop.eventDate ?? null,
eventTime: workshop.eventTime ?? null,
duration: workshop.duration ?? null,
}
}
export function mapWorkshopDetailFromApi(workshop: WorkshopApi): WorkshopDetail {
return {
...mapWorkshopApiToUi(workshop),
descriptionHtml: workshop.descriptionHtml,
gallery: workshop.gallery ?? [],
}
}
export function formatWorkshopAuthor(author: WorkshopAuthor | null): string {
if (!author) return 'Unknown author'
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
return name || author.email || 'Unknown author'
}
export function formatWorkshopRegistrationName(
user: WorkshopRegistrationUser,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
const faName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
if (faName) return faName
}
const enName = [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ').trim()
if (enName) return enName
const fallback = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return fallback || user.email || '—'
}
export function formatWorkshopDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatWorkshopCardDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function formatWorkshopCardMetaDate(
workshop: Pick<Workshop, 'eventDate' | 'publishedAt' | 'createdAt'>,
locale: 'en' | 'fa' = 'en',
): string {
if (workshop.eventDate) {
return formatWorkshopCardDate(workshop.eventDate, locale)
}
return formatWorkshopCardDate(workshop.publishedAt ?? workshop.createdAt, locale)
}
export function isWorkshopPublished(workshop: Pick<Workshop, 'status'>): boolean {
return workshop.status === 'published'
}
export async function setWorkshopPublished(workshopId: string, published: boolean) {
return updateWorkshop(workshopId, { status: published ? 'published' : 'draft' })
}
export function formatWorkshopDuration(
duration: string | null | undefined,
): string {
const trimmed = duration?.trim()
return trimmed || '—'
}
function normalizeGregorianDate(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''
}
function normalizeTimeInput(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{2}:\d{2})/)?.[1] ?? ''
}
function normalizeWorkshopDuration(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return ''
}
function normalizeRichTextHtml(html: string | null | undefined): string {
const trimmed = html?.trim() ?? ''
if (!trimmed) return ''
if (/<[a-z][\s\S]*>/i.test(trimmed)) return trimmed
return `<p>${trimmed
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</p>`
}
export function mapWorkshopApiToFormState(workshop: WorkshopApi) {
const legacyDuration = (workshop as WorkshopApi & { durationMinutes?: number | null })
.durationMinutes
const gallery = workshop.gallery ?? []
const images: string[] = []
const galleryMediaIds: string[] = []
for (const item of gallery) {
const url = item.url?.trim()
if (!url) continue
images.push(url)
if (item.mediaId) galleryMediaIds.push(item.mediaId)
}
if (galleryMediaIds.length === 0 && workshop.galleryMediaIds?.length) {
galleryMediaIds.push(
...workshop.galleryMediaIds.filter((id) => typeof id === 'string' && id.trim()),
)
}
return {
titleFa: workshop.titleFa || workshop.title,
titleEn: workshop.titleEn ?? '',
abstract: workshop.abstract ?? '',
descriptionHtml: normalizeRichTextHtml(workshop.descriptionHtml),
categoryId: workshop.categoryId ?? '',
tags: Array.isArray(workshop.tags) ? workshop.tags : [],
eventDate: normalizeGregorianDate(workshop.eventDate),
eventTime: normalizeTimeInput(workshop.eventTime),
duration:
normalizeWorkshopDuration(workshop.duration) ||
normalizeWorkshopDuration(legacyDuration),
coverImage: resolveWorkshopCoverImageUrl(workshop),
featuredMediaId: workshop.featuredMediaId,
images,
galleryMediaIds,
}
}
export async function listWorkshops(
page = 1,
pageSize = WORKSHOPS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListWorkshopsParams, 'page' | 'pageSize'>,
) {
const q = new URLSearchParams()
q.set('page', String(page))
q.set('pageSize', String(pageSize))
if (filters?.status) q.set('status', filters.status)
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<WorkshopsListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapWorkshopApiToUi),
}
}
export async function getWorkshop(workshopId: string, signal?: AbortSignal) {
const data = await apiRequest<{ workshop: WorkshopApi }>(businessPath(`/${workshopId}`), {
auth: true,
signal,
})
return data.workshop
}
export async function getWorkshopDetail(workshopId: string, signal?: AbortSignal) {
const workshop = await getWorkshop(workshopId, signal)
return mapWorkshopDetailFromApi(workshop)
}
export async function listWorkshopRegistrations(workshopId: string, signal?: AbortSignal) {
return apiRequest<{ items: WorkshopRegistration[] }>(
businessPath(`/${workshopId}/registrations`),
{ auth: true, signal },
)
}
export async function addWorkshopRegistration(workshopId: string, userId: string) {
return apiRequest<{ message: string; registration: WorkshopRegistration }>(
businessPath(`/${workshopId}/registrations`),
{
method: 'POST',
auth: true,
body: { userId },
},
)
}
export async function updateWorkshopRegistrationStatus(
workshopId: string,
registrationId: string,
status: WorkshopRegistrationStatus,
) {
return apiRequest<{ message: string; registration: WorkshopRegistration }>(
businessPath(`/${workshopId}/registrations/${registrationId}`),
{
method: 'PATCH',
auth: true,
body: { status },
},
)
}
export async function removeWorkshopRegistration(
workshopId: string,
registrationId: string,
) {
return apiRequest<{ message: string }>(
businessPath(`/${workshopId}/registrations/${registrationId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export async function createWorkshop(payload: CreateWorkshopPayload) {
return apiRequest<{ message: string; workshop: WorkshopApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateWorkshop(workshopId: string, payload: UpdateWorkshopPayload) {
return apiRequest<{ message: string; workshop: WorkshopApi }>(
businessPath(`/${workshopId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteWorkshop(workshopId: string) {
return apiRequest<{ message: string }>(businessPath(`/${workshopId}`), {
method: 'DELETE',
auth: true,
})
}
+40
View File
@@ -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[]
}
+11 -4
View File
@@ -7,6 +7,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'warehouse', 'warehouse',
'videos', 'videos',
'instructions', 'instructions',
'workshops',
'finance', 'finance',
] as const ] as const
@@ -35,13 +36,19 @@ export const HOME_CHART_IDS = [
export type HomeChartId = (typeof HOME_CHART_IDS)[number] 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 * 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[] = [ export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
...BUSINESS_DASHBOARD_MODULE_IDS, BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
]
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [ export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d', 'orders_30d',
+3
View File
@@ -7,6 +7,9 @@ export default defineConfig({
resolve: { resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom'], dedupe: ['react', 'react-dom', 'react-router-dom'],
}, },
optimizeDeps: {
include: ['react-multi-date-picker', 'react-date-object', 'react-element-popper'],
},
server: { server: {
host: true, host: true,
port: 5173, 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 { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { EmptyState } from './EmptyState'
import type { Order } from '../services/orderService' import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
@@ -106,7 +107,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div> </div>
{order.items.length === 0 ? ( {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.tableBlock}>
<div className={styles.tableHead}> <div className={styles.tableHead}>
@@ -2,7 +2,7 @@
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;
left: 24px; left: 24px;
z-index: 300; z-index: var(--toast-z-index, 11000);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); color: var(--text-primary);
background: var(--elevated-surface); background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass)); backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass)); -webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); 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 { .toastIn {
+2
View File
@@ -27,6 +27,7 @@
--surface: rgba(255, 255, 255, 0.82); --surface: rgba(255, 255, 255, 0.82);
--card-media-bg: #ffffff; --card-media-bg: #ffffff;
--elevated-surface: rgba(255, 255, 255, 0.96); --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: color-mix(in srgb, var(--primary) 14%, #ffffff);
--icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9); --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); --surface: rgba(55, 59, 68, 0.95);
--card-media-bg: #32363f; --card-media-bg: #32363f;
--elevated-surface: #3a3e48; --elevated-surface: #3a3e48;
--toast-bg: rgba(58, 62, 72, 0.96);
--card-hover-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); --card-hover-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
+7 -4
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
import { MapPin, Pencil, Plus, Trash2 } from 'lucide-react' import { MapPin, Pencil, Plus, Trash2 } from 'lucide-react'
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui' import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
import { AddressFormModal } from '../components/AddressFormModal' import { AddressFormModal } from '../components/AddressFormModal'
import { EmptyState } from '../components/EmptyState'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -125,10 +126,12 @@ export function AddressesPage() {
{loading && <p className={styles.status}>{t('addresses.loading')}</p>} {loading && <p className={styles.status}>{t('addresses.loading')}</p>}
{!loading && addresses.length === 0 && ( {!loading && addresses.length === 0 && (
<div className={styles.empty}> <EmptyState
<MapPin size={28} /> icon={MapPin}
<p>{t('addresses.empty')}</p> message={t('addresses.empty')}
</div> actionLabel={t('addresses.add')}
onAction={openCreateModal}
/>
)} )}
{!loading && addresses.length > 0 && ( {!loading && addresses.length > 0 && (
+2 -4
View File
@@ -2,6 +2,7 @@ import { PageTitle } from '../components/PageTitle'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react' import { Heart } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui' import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { EmptyState } from '../components/EmptyState'
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard' import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -97,10 +98,7 @@ export function FavoritesPage() {
{loading && <p className={styles.status}>{t('favorites.loading')}</p>} {loading && <p className={styles.status}>{t('favorites.loading')}</p>}
{!loading && data?.items.length === 0 && ( {!loading && data?.items.length === 0 && (
<div className={styles.empty}> <EmptyState icon={Heart} message={t('favorites.empty')} layout="page" />
<Heart size={32} />
<p>{t('favorites.empty')}</p>
</div>
)} )}
{!!data?.items.length && ( {!!data?.items.length && (
+2 -2
View File
@@ -71,10 +71,10 @@
} }
.domain { .domain {
font-size: 20px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.25; line-height: 1.3;
} }
.appName { .appName {
+7 -7
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Package, Plus } from 'lucide-react' import { Package, Plus } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui' import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { EmptyState } from '../components/EmptyState'
import { UserProductCard } from '../components/UserProductCard' import { UserProductCard } from '../components/UserProductCard'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -141,13 +142,12 @@ export function MyProductsPage() {
{loading ? <p className={styles.status}>{t('myProducts.loading')}</p> : null} {loading ? <p className={styles.status}>{t('myProducts.loading')}</p> : null}
{!loading && !error && products.length === 0 ? ( {!loading && !error && products.length === 0 ? (
<div className={styles.empty}> <EmptyState
<Package size={32} /> icon={Package}
<p>{t('myProducts.empty')}</p> message={t('myProducts.empty')}
<Link to="/my-products/new" className={styles.emptyLink}> actionLabel={t('myProducts.add')}
{t('myProducts.add')} actionTo="/my-products/new"
</Link> />
</div>
) : null} ) : null}
{!loading && products.length > 0 ? ( {!loading && products.length > 0 ? (
+2 -5
View File
@@ -1,6 +1,7 @@
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui' import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui'
import { EmptyTableRow } from '../components/EmptyState'
import { OrderItemsModal } from '../components/OrderItemsModal' import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow' import { OrderRow } from '../components/OrderRow'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -131,11 +132,7 @@ export function OrdersPage() {
)} )}
{!loading && data?.items.length === 0 && ( {!loading && data?.items.length === 0 && (
<tr> <EmptyTableRow colSpan={COLUMN_COUNT} message={t('orders.empty')} />
<td className={styles.td} colSpan={COLUMN_COUNT}>
{t('orders.empty')}
</td>
</tr>
)} )}
{!loading && {!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' export {
import { RichTextEditor,
Bold, isEmptyRichText,
IndentDecrease, richTextToPlain,
IndentIncrease, type RichTextEditorProps,
Italic, } from '@meshkee/dashboard-ui'
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(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
@@ -1,12 +1,11 @@
.container { .container {
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;
right: 24px; left: 24px;
/* Above Modal overlay (z-index 300) so success/error feedback is visible while dialogs are open */ z-index: var(--toast-z-index, 11000);
z-index: 400;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-start;
gap: 8px; gap: 8px;
pointer-events: none; pointer-events: none;
} }
@@ -20,12 +19,12 @@
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); 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)); backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass)); -webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); 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 { .toastIn {
+15 -11
View File
@@ -8,6 +8,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { createPortal } from 'react-dom'
import styles from '../components/Toast.module.css' import styles from '../components/Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info' export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div className={styles.container} aria-live="polite" aria-atomic="true"> {createPortal(
{toast && ( <div className={styles.container} aria-live="polite" aria-atomic="true">
<div {toast && (
key={toast.id} <div
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`} key={toast.id}
role="status" className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
> role="status"
{toast.message} >
</div> {toast.message}
)} </div>
</div> )}
</div>,
document.body,
)}
</ToastContext.Provider> </ToastContext.Provider>
) )
} }
+3
View File
@@ -36,6 +36,9 @@
--blur-glass: 28px; --blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4); --modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px; --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-size: 16px;
--select-arrow-offset: 12px; --select-arrow-offset: 12px;
--select-padding-end: 2.5rem; --select-padding-end: 2.5rem;
+15 -16
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react' import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EmptyTableRow } from '../components/EmptyState'
import { Modal } from '../components/Modal' import { Modal } from '../components/Modal'
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor' import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@@ -236,14 +237,13 @@ export function SettingsPage() {
</thead> </thead>
<tbody> <tbody>
{!loading && invoiceTemplates.length === 0 ? ( {!loading && invoiceTemplates.length === 0 ? (
<tr> <EmptyTableRow
<td className={tableStyles.td} colSpan={5}> colSpan={5}
<div className={styles.emptyState}> icon={SettingsIcon}
<SettingsIcon size={20} /> message="No invoice templates yet. Add one to speed up issuing invoices."
<span>No invoice templates yet. Add one to speed up issuing invoices.</span> actionLabel="Add template"
</div> actionTo="/settings/invoice-templates/new"
</td> />
</tr>
) : null} ) : null}
{invoiceTemplates.map((template) => ( {invoiceTemplates.map((template) => (
<tr key={template.id}> <tr key={template.id}>
@@ -330,14 +330,13 @@ export function SettingsPage() {
</thead> </thead>
<tbody> <tbody>
{!loading && itemTemplates.length === 0 ? ( {!loading && itemTemplates.length === 0 ? (
<tr> <EmptyTableRow
<td className={tableStyles.td} colSpan={6}> colSpan={6}
<div className={styles.emptyState}> icon={SettingsIcon}
<SettingsIcon size={20} /> message="No predefined items yet. Add one to reuse when building invoices."
<span>No predefined items yet.</span> actionLabel="Add item"
</div> onAction={openCreateItem}
</td> />
</tr>
) : null} ) : null}
{itemTemplates.map((template) => ( {itemTemplates.map((template) => (
<tr key={template.id}> <tr key={template.id}>
@@ -7,6 +7,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'warehouse', 'warehouse',
'videos', 'videos',
'instructions', 'instructions',
'workshops',
'finance', 'finance',
] as const ] as const
@@ -35,6 +36,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
warehouse: 'Warehouse', warehouse: 'Warehouse',
videos: 'Videos', videos: 'Videos',
instructions: 'Instructions', instructions: 'Instructions',
workshops: 'Workshops',
finance: 'Finance', finance: 'Finance',
} }
@@ -68,11 +70,10 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
/** /**
* Existing tenants without saved modules keep every business-dashboard module * 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[] = [ export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
...BUSINESS_DASHBOARD_MODULE_IDS, BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
]
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [ export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d', 'orders_30d',
+31
View File
@@ -19,6 +19,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-easy-crop": "^6.2.0", "react-easy-crop": "^6.2.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^7.18.1" "react-router-dom": "^7.18.1"
}, },
"devDependencies": { "devDependencies": {
@@ -1275,6 +1276,12 @@
"node": ">=0.10.0" "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": { "node_modules/react-dom": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
@@ -1300,6 +1307,30 @@
"react-dom": ">=16.4.0" "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": { "node_modules/react-router": {
"version": "7.18.1", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
@@ -38,6 +38,9 @@
--blur-glass: 28px; --blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4); --modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px; --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-primary: #1e293b;
--text-secondary: #64748b; --text-secondary: #64748b;
--text-muted: #94a3b8; --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>
)
}
@@ -5,7 +5,7 @@
border: 1px solid rgba(148, 163, 184, 0.35); border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
overflow: hidden; overflow: visible;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -17,11 +17,14 @@
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
padding: 4px 6px; padding: 4px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2); border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(248, 250, 252, 0.8); background: rgba(248, 250, 252, 0.8);
position: relative;
z-index: 2;
} }
.toolbar button { .toolbar button {
@@ -35,11 +38,25 @@
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.toolbar button:hover { .toolbar button:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1); background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary); 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 { .divider {
width: 1px; width: 1px;
height: 20px; height: 20px;
@@ -47,6 +64,97 @@
margin: 0 4px; 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 { .editorShell {
position: relative; position: relative;
width: 100%; 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(/&nbsp;/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; position: fixed;
bottom: 24px; bottom: 24px;
left: 24px; left: 24px;
z-index: 300; z-index: var(--toast-z-index, 11000);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); color: var(--text-primary);
background: var(--elevated-surface); background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass)); backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass)); -webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); 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 { .toastIn {
@@ -8,6 +8,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { createPortal } from 'react-dom'
import styles from './Toast.module.css' import styles from './Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info' export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div className={styles.container} aria-live="polite" aria-atomic="true"> {createPortal(
{toast && ( <div className={styles.container} aria-live="polite" aria-atomic="true">
<div {toast && (
key={toast.id} <div
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`} key={toast.id}
role="status" className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
> role="status"
{toast.message} >
</div> {toast.message}
)} </div>
</div> )}
</div>,
document.body,
)}
</ToastContext.Provider> </ToastContext.Provider>
) )
} }
+15
View File
@@ -10,8 +10,23 @@ export {
type CityOption, type CityOption,
} from './components/AddressListEditor' } from './components/AddressListEditor'
export { Breadcrumbs, type BreadcrumbItem } from './components/Breadcrumbs' 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 { Pagination } from './components/Pagination'
export type { PaginationProps } from './components/Pagination' export type { PaginationProps } from './components/Pagination'
export {
RichTextEditor,
isEmptyRichText,
richTextToPlain,
type RichTextEditorProps,
} from './components/RichTextEditor'
export { SectionCard } from './components/SectionCard' export { SectionCard } from './components/SectionCard'
export { RouteLoader } from './components/RouteLoader' export { RouteLoader } from './components/RouteLoader'
export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard' export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard'