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-dom": "^19.2.7",
"react-easy-crop": "^6.2.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
+11
View File
@@ -60,6 +60,11 @@ import { AddNewInstructionPage } from './pages/AddNewInstructionPage'
import { InstructionCategoriesPage } from './pages/InstructionCategoriesPage'
import { InstructionSettingsPage } from './pages/InstructionSettingsPage'
import { InstructionDetailsPage } from './pages/InstructionDetailsPage'
import { WorkshopPage } from './pages/WorkshopPage'
import { WorkshopListPage } from './pages/WorkshopListPage'
import { AddNewWorkshopPage } from './pages/AddNewWorkshopPage'
import { WorkshopCategoriesPage } from './pages/WorkshopCategoriesPage'
import { WorkshopDetailsPage } from './pages/WorkshopDetailsPage'
import { ContentPage } from './pages/ContentPage'
import { WebsitePage } from './pages/WebsitePage'
import { WebsiteContactPage } from './pages/WebsiteContactPage'
@@ -146,6 +151,12 @@ function App() {
<Route path="instructions/edit/:id" element={<AddNewInstructionPage />} />
<Route path="instructions/categories" element={<InstructionCategoriesPage />} />
<Route path="instructions/settings" element={<InstructionSettingsPage />} />
<Route path="workshops" element={<WorkshopPage />} />
<Route path="workshops/list" element={<WorkshopListPage />} />
<Route path="workshops/detail/:id" element={<WorkshopDetailsPage />} />
<Route path="workshops/new" element={<AddNewWorkshopPage />} />
<Route path="workshops/edit/:id" element={<AddNewWorkshopPage />} />
<Route path="workshops/categories" element={<WorkshopCategoriesPage />} />
<Route path="website" element={<WebsitePage />} />
<Route path="website/static-images" element={<WebsiteStaticImagesPage />} />
<Route path="website/special-items" element={<StoreSpecialsPage />} />
@@ -0,0 +1,23 @@
.modal {
max-width: 440px;
}
.body {
padding: 16px 24px 24px;
}
.hint {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.error {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.08);
color: #b91c1c;
font-size: 13px;
}
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { BusinessCustomer } from '../services/customerService'
import { useT } from '../i18n/useT'
import { CustomerSearchSelect } from './CustomerSearchSelect'
import modalStyles from './VariationsModal.module.css'
import styles from './AddWorkshopRegistrationModal.module.css'
interface AddWorkshopRegistrationModalProps {
open: boolean
excludeIds: string[]
isAdding: boolean
error?: string
onClose: () => void
onPick: (customer: BusinessCustomer) => void
}
const ANIMATION_MS = 220
export function AddWorkshopRegistrationModal({
open,
excludeIds,
isAdding,
error = '',
onClose,
onPick,
}: AddWorkshopRegistrationModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted) return null
return createPortal(
<div
className={`${modalStyles.overlay} ${modalStyles.overlayNested} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="add-workshop-registration-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={modalStyles.header}>
<h3 id="add-workshop-registration-title" className={modalStyles.title}>
{t('workshop.registrations.add')}
</h3>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<div className={modalStyles.field}>
<label htmlFor="workshop-registration-customer-search">
{t('workshop.registrations.searchLabel')}
</label>
<CustomerSearchSelect
id="workshop-registration-customer-search"
value={null}
onChange={() => undefined}
onPick={onPick}
excludeIds={excludeIds}
disabled={isAdding}
placeholder={t('workshop.registrations.searchPlaceholder')}
/>
</div>
<p className={styles.hint}>{t('workshop.registrations.searchHint')}</p>
</div>
</div>
</div>,
document.body,
)
}
@@ -13,6 +13,8 @@ interface CustomerSearchSelectProps {
disabled?: boolean
placeholder?: string
id?: string
excludeIds?: string[]
onPick?: (customer: BusinessCustomer) => void
}
function customerLabel(customer: BusinessCustomer, withPhone = false) {
@@ -30,6 +32,8 @@ export function CustomerSearchSelect({
disabled = false,
placeholder,
id,
excludeIds = [],
onPick,
}: CustomerSearchSelectProps) {
const t = useT()
const { locale } = useLocale()
@@ -94,11 +98,20 @@ export function CustomerSearchSelect({
}, [query, open])
function selectOption(customer: BusinessCustomer) {
if (excludeIds.includes(customer.id)) return
if (onPick) {
onPick(customer)
setOpen(false)
setQuery('')
return
}
onChange(customer)
setOpen(false)
setQuery('')
}
const visibleResults = results.filter((customer) => !excludeIds.includes(customer.id))
return (
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
<div
@@ -153,10 +166,10 @@ export function CustomerSearchSelect({
<li className={styles.noResults}>{t('issueInvoice.pickCustomerHint')}</li>
) : loading ? (
<li className={styles.noResults}>{t('issueInvoice.loading')}</li>
) : results.length === 0 ? (
) : visibleResults.length === 0 ? (
<li className={styles.noResults}>{t('issueInvoice.pickCustomerNoResults')}</li>
) : (
results.map((customer) => (
visibleResults.map((customer) => (
<li key={customer.id}>
<button
type="button"
@@ -1,53 +0,0 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
padding: 48px 24px;
text-align: center;
border-block: 1px solid color-mix(in srgb, var(--text-muted) 42%, transparent);
}
.icon {
flex-shrink: 0;
color: var(--text-primary);
}
.message {
margin: 0;
max-width: 36rem;
font-family: var(--font-ui);
font-size: 14px;
font-weight: 500;
line-height: 1.7;
color: var(--text-secondary);
}
.action {
appearance: none;
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
font-family: var(--font-ui);
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.action:hover {
text-decoration: underline;
}
.cell {
padding: 0 !important;
background: transparent !important;
border-bottom: none !important;
vertical-align: middle;
}
+9 -51
View File
@@ -1,52 +1,10 @@
import type { LucideIcon } from 'lucide-react'
import { Link } from 'react-router-dom'
import styles from './EmptyState.module.css'
export {
EmptyState,
EmptyStateEmphasis,
EmptyTableRow,
ListEmptyMessage,
buildListEmptyPlainMessage,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
export interface EmptyStateProps {
icon: LucideIcon
message: string
actionLabel?: string
actionTo?: string
onAction?: () => void
}
export function EmptyState({
icon: Icon,
message,
actionLabel,
actionTo,
onAction,
}: EmptyStateProps) {
const showAction = Boolean(actionLabel && (actionTo || onAction))
return (
<div className={styles.root} role="status">
<Icon className={styles.icon} size={36} strokeWidth={1.5} aria-hidden />
<p className={styles.message}>{message}</p>
{showAction && actionLabel ? (
actionTo ? (
<Link to={actionTo} className={styles.action} onClick={onAction}>
{actionLabel}
</Link>
) : (
<button type="button" className={styles.action} onClick={onAction}>
{actionLabel}
</button>
)
) : null}
</div>
)
}
export function EmptyTableRow({
colSpan,
...props
}: EmptyStateProps & { colSpan: number }) {
return (
<tr>
<td colSpan={colSpan} className={styles.cell}>
<EmptyState {...props} />
</td>
</tr>
)
}
export { ListEmptyState } from './ListEmptyState'
@@ -11,8 +11,8 @@
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 120px;
box-sizing: border-box;
min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.4);
@@ -35,8 +35,7 @@
.preview {
position: relative;
width: 100%;
min-height: 120px;
min-height: 0;
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
@@ -83,6 +82,10 @@
}
.cropPanel {
width: fit-content;
max-width: 100%;
margin-inline: auto;
align-self: center;
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
@@ -91,15 +94,8 @@
.cropArea {
position: relative;
width: 100%;
min-height: 160px;
max-height: 420px;
background: #1e293b;
}
.cropAreaPortrait {
max-height: min(360px, 42vh);
min-height: 0;
background: #1e293b;
}
.cropControls {
@@ -162,7 +158,7 @@
.changeBtn {
display: inline-flex;
align-self: flex-start;
align-self: center;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
+11 -14
View File
@@ -80,19 +80,16 @@ export function ImageCropper({
}
const isPortrait = aspect < 1
const frameStyle: React.CSSProperties = isPortrait
? {
aspectRatio: `${aspect}`,
height: 'min(360px, 42vh)',
width: 'auto',
maxWidth: '100%',
marginInline: 'auto',
}
: {
aspectRatio: `${aspect}`,
width: '100%',
maxHeight: 'min(280px, 36vh)',
}
const maxHeight = isPortrait ? 'min(360px, 42vh)' : 'min(280px, 36vh)'
const frameStyle: React.CSSProperties = {
aspectRatio: String(aspect),
width: `min(100%, calc(${maxHeight} * ${aspect}))`,
height: 'auto',
maxHeight,
minHeight: 0,
marginInline: 'auto',
alignSelf: 'center',
}
return (
<div className={styles.wrapper}>
@@ -121,7 +118,7 @@ export function ImageCropper({
{imageSrc && (
<div className={styles.cropPanel}>
<div className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`} style={frameStyle}>
<div className={styles.cropArea} style={frameStyle}>
<Cropper
key={String(aspect)}
image={imageSrc}
@@ -39,8 +39,8 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
return (
<div className={styles.wrapper}>
<div className={styles.grid}>
{images.map((src, index) => (
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}>
{images.filter(Boolean).map((src, index) => (
<div key={`${String(src).slice(0, 32)}-${index}`} className={styles.item}>
<img src={src} alt={t('products.form.images')} />
<button
type="button"
@@ -0,0 +1,98 @@
.wrap {
width: 100%;
}
.input {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.85);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.calendar {
--rmdp-primary: var(--primary);
--rmdp-secondary: rgba(var(--primary-rgb) / 0.12);
--rmdp-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
--rmdp-today: rgba(var(--primary-rgb) / 0.14);
--rmdp-hover: rgba(var(--primary-rgb) / 0.1);
--rmdp-dashed-color: rgba(var(--primary-rgb) / 0.35);
font-family: var(--font-ui);
}
.calendar :global(.rmdp-wrapper) {
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.calendar :global(.rmdp-day) {
position: relative;
width: 34px;
height: 34px;
}
.calendar :global(.rmdp-day span) {
position: absolute;
inset: 3px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 14px;
line-height: 1;
text-align: center;
}
.calendar :global(.rmdp-week-day) {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
line-height: 1;
color: var(--text-secondary);
font-weight: 600;
}
.calendar :global(.rmdp-day.rmdp-selected span:not(.highlight)) {
background-color: var(--primary) !important;
color: #fff !important;
box-shadow: 0 2px 8px rgba(var(--primary-rgb) / 0.35);
}
.calendar :global(.rmdp-day.rmdp-today:not(.rmdp-selected) span) {
background-color: rgba(var(--primary-rgb) / 0.14);
color: var(--primary-dark, var(--primary));
}
.calendar :global(.rmdp-arrow) {
border-color: var(--primary);
}
.calendar :global(.rmdp-arrow-container:hover) {
background-color: rgba(var(--primary-rgb) / 0.1);
box-shadow: none;
}
.calendar :global(.rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden) span:hover) {
background-color: rgba(var(--primary-rgb) / 0.12);
color: var(--text-primary);
}
@@ -0,0 +1,118 @@
import { useEffect, useState, type ComponentType } from 'react'
import MultiDatePickerModule from 'react-multi-date-picker'
import DateObjectModule from 'react-date-object'
import type DateObjectType from 'react-date-object'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
import gregorian from 'react-date-object/calendars/gregorian'
import gregorian_en from 'react-date-object/locales/gregorian_en'
import { useLocale } from '@meshkee/dashboard-ui'
import styles from './JalaliDatePicker.module.css'
function resolveDefaultExport<T>(mod: T): T {
if (
mod &&
typeof mod === 'object' &&
'default' in mod &&
(mod as { default?: unknown }).default
) {
return (mod as { default: T }).default
}
return mod
}
const DatePicker = resolveDefaultExport(MultiDatePickerModule) as ComponentType<
Record<string, unknown>
>
const DateObject = resolveDefaultExport(DateObjectModule) as typeof DateObjectType
interface JalaliDatePickerProps {
id?: string
value: string
onChange: (isoDate: string) => void
disabled?: boolean
placeholder?: string
}
function normalizeGregorianDate(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''
}
function toPickerValue(isoDate: string): DateObjectType | undefined {
const normalized = normalizeGregorianDate(isoDate)
if (!normalized) return undefined
try {
return new DateObject({ date: normalized, format: 'YYYY-MM-DD' }).convert(
persian,
persian_fa,
)
} catch {
return undefined
}
}
function toIsoDate(date: DateObjectType | DateObjectType[] | null): string {
if (!date || Array.isArray(date)) return ''
try {
return date.convert(gregorian, gregorian_en).format('YYYY-MM-DD')
} catch {
return ''
}
}
function JalaliDatePickerInner({
id,
value,
onChange,
disabled = false,
placeholder,
}: JalaliDatePickerProps) {
const { locale } = useLocale()
const isFa = locale === 'fa'
return (
<DatePicker
id={id}
value={toPickerValue(value)}
onChange={(date: DateObjectType | DateObjectType[] | null) => onChange(toIsoDate(date))}
calendar={persian}
locale={persian_fa}
format="YYYY/MM/DD"
disabled={disabled}
placeholder={placeholder}
containerClassName={styles.wrap}
inputClass={styles.input}
className={styles.calendar}
calendarPosition={isFa ? 'bottom-right' : 'bottom-left'}
arrow={false}
editable={false}
/>
)
}
export function JalaliDatePicker(props: JalaliDatePickerProps) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<input
id={props.id}
type="text"
className={styles.input}
value={props.value}
readOnly
disabled={props.disabled}
placeholder={props.placeholder}
aria-hidden="true"
/>
)
}
return <JalaliDatePickerInner {...props} />
}
@@ -0,0 +1,64 @@
import type { LucideIcon } from 'lucide-react'
import {
EmptyState,
ListEmptyMessage,
buildListEmptyPlainMessage,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
type ListEmptyEntity =
| 'workshop'
| 'instruction'
| 'video'
| 'blog'
| 'portfolio'
| 'products'
type ListEmptyStateProps = {
entity: ListEmptyEntity
actionLabel: string
icon?: LucideIcon
} & Pick<EmptyStateProps, 'actionTo' | 'onAction' | 'layout'>
function entityKeys(entity: ListEmptyEntity): {
none: BusinessMessageKey
action: BusinessMessageKey
} {
return {
none: `${entity}.list.emptyNone` as BusinessMessageKey,
action: `${entity}.list.emptyAction` as BusinessMessageKey,
}
}
export function ListEmptyState({
entity,
actionLabel,
actionTo,
onAction,
icon,
layout,
}: ListEmptyStateProps) {
const t = useT()
const keys = entityKeys(entity)
const parts = {
part1: t('empty.list.part1'),
noneLabel: t(keys.none),
part2: t('empty.list.part2'),
actionLabel: t(keys.action),
part3: t('empty.list.part3'),
}
return (
<EmptyState
icon={icon}
message={buildListEmptyPlainMessage(parts)}
messageNode={<ListEmptyMessage {...parts} />}
actionLabel={actionLabel}
actionTo={actionTo}
onAction={onAction}
layout={layout}
/>
)
}
@@ -101,6 +101,18 @@
gap: 20px;
}
.gridCols5 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
@media (min-width: 1600px) {
.gridCols5 {
grid-template-columns: repeat(5, 1fr);
}
}
.gridFour {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -142,14 +154,16 @@
}
@media (max-width: 1400px) {
.gridCols4 {
.gridCols4,
.gridCols5 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1100px) {
.grid,
.gridCols4 {
.gridCols4,
.gridCols5 {
grid-template-columns: repeat(2, 1fr);
}
@@ -190,7 +204,8 @@
.grid,
.gridHome,
.gridFour,
.gridCols4 {
.gridCols4,
.gridCols5 {
grid-template-columns: 1fr;
gap: 16px;
}
@@ -4,7 +4,9 @@ import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
import { listAllStoreItems } from '../services/storeItemService'
import type { SpecialProductsSource } from '../services/settingsService'
import { groupStoreItemsByProduct } from '../utils/storeProductGroups'
import {
StoreItemMultiSearchSelect,
@@ -16,9 +18,11 @@ import styles from './PickStoreSpecialItemsModal.module.css'
interface PickStoreSpecialItemsModalProps {
open: boolean
specialTitle: string
existingStoreItemIds: string[]
/** Store item ids when source is store_item; product ids when source is product. */
existingIds: string[]
source: SpecialProductsSource
onClose: () => void
onConfirm: (storeItemIds: string[]) => void
onConfirm: (ids: string[]) => void
isSubmitting?: boolean
}
@@ -27,7 +31,8 @@ const ANIMATION_MS = 220
export function PickStoreSpecialItemsModal({
open,
specialTitle,
existingStoreItemIds,
existingIds,
source,
onClose,
onConfirm,
isSubmitting = false,
@@ -35,6 +40,7 @@ export function PickStoreSpecialItemsModal({
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const fromProducts = source === 'product'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
@@ -43,7 +49,7 @@ export function PickStoreSpecialItemsModal({
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [selectOpen, setSelectOpen] = useState(false)
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds])
const existingSet = useMemo(() => new Set(existingIds), [existingIds])
useEffect(() => {
if (open) {
@@ -67,7 +73,7 @@ export function PickStoreSpecialItemsModal({
const controller = new AbortController()
void loadOptions(controller.signal)
return () => controller.abort()
}, [open, existingStoreItemIds])
}, [open, existingIds, source])
useEffect(() => {
if (!mounted || closing) return
@@ -83,51 +89,68 @@ export function PickStoreSpecialItemsModal({
setError('')
try {
const items = await listAllStoreItems(signal)
const listings = groupStoreItemsByProduct(items)
const nextOptions = listings
.map(
(listing): StoreItemSearchOption | null => {
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
const firstPricedVariant = listing.variants.find((v) => v.price !== null) ?? null
const price = firstPricedVariant?.price ?? null
const hasPrice = listing.variants.some((v) => v.price !== null)
const hasInStock = listing.variants.some(
(v) => v.stockQuantity === null || v.stockQuantity > 0,
)
const nextOptions = fromProducts
? await loadProductOptions(signal)
: await loadStoreItemOptions(signal)
if (!hasPrice && !hasInStock) return null
return {
id: storeItemId,
title: listing.productTitle,
nameFa: listing.productNameFa,
image: listing.productImage,
price,
}
},
)
.filter((option): option is StoreItemSearchOption => option !== null)
.filter((option) => !existingSet.has(option.id))
setOptions(nextOptions)
setOptions(nextOptions.filter((option) => !existingSet.has(option.id)))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.specialItems.pickErrorLoad'))
setError(
fromProducts
? t('website.specialItems.pickErrorLoadProducts')
: t('website.specialItems.pickErrorLoad'),
)
}
} finally {
setIsLoading(false)
}
}
function toggleSelection(storeItemId: string) {
async function loadStoreItemOptions(signal?: AbortSignal) {
const items = await listAllStoreItems(signal)
const listings = groupStoreItemsByProduct(items)
return listings
.map((listing): StoreItemSearchOption | null => {
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
const firstPricedVariant = listing.variants.find((v) => v.price !== null) ?? null
const price = firstPricedVariant?.price ?? null
const hasPrice = listing.variants.some((v) => v.price !== null)
const hasInStock = listing.variants.some(
(v) => v.stockQuantity === null || v.stockQuantity > 0,
)
if (!hasPrice && !hasInStock) return null
return {
id: storeItemId,
title: listing.productTitle,
nameFa: listing.productNameFa,
image: listing.productImage,
price,
}
})
.filter((option): option is StoreItemSearchOption => option !== null)
}
async function loadProductOptions(signal?: AbortSignal) {
const products = await listAllProducts(signal)
return products.map(
(product): StoreItemSearchOption => ({
id: product.id,
title: product.title,
nameFa: product.nameFa,
image: product.thumbnailUrl || product.thumbnail || product.image || null,
}),
)
}
function toggleSelection(id: string) {
setSelectedIds((prev) =>
prev.includes(storeItemId)
? prev.filter((id) => id !== storeItemId)
: [...prev, storeItemId],
prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id],
)
}
@@ -167,7 +190,11 @@ export function PickStoreSpecialItemsModal({
<h2 id="pick-special-items-title" className={modalStyles.title}>
{t('website.pick.addTo', { title: specialTitle })}
</h2>
<p className={modalStyles.subtitle}>{t('website.specialItems.pickSubtitle')}</p>
<p className={modalStyles.subtitle}>
{fromProducts
? t('website.specialItems.pickSubtitleProducts')
: t('website.specialItems.pickSubtitle')}
</p>
</div>
<button
type="button"
@@ -183,15 +210,27 @@ export function PickStoreSpecialItemsModal({
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>{t('website.specialItems.pickLoading')}</p>
<p className={styles.empty}>
{fromProducts
? t('website.specialItems.pickLoadingProducts')
: t('website.specialItems.pickLoading')}
</p>
) : options.length === 0 ? (
<p className={styles.empty}>{t('website.specialItems.pickEmpty')}</p>
<p className={styles.empty}>
{fromProducts
? t('website.specialItems.pickEmptyProducts')
: t('website.specialItems.pickEmpty')}
</p>
) : (
<StoreItemMultiSearchSelect
options={options}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder={t('website.specialItems.pickSearch')}
placeholder={
fromProducts
? t('website.specialItems.pickSearchProducts')
: t('website.specialItems.pickSearch')
}
disabled={isSubmitting}
onOpenChange={setSelectOpen}
/>
@@ -212,8 +251,7 @@ export function PickStoreSpecialItemsModal({
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
+25 -360
View File
@@ -1,369 +1,34 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
Underline,
List,
ListOrdered,
ImagePlus,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
RichTextEditor as BaseRichTextEditor,
isEmptyRichText,
richTextToPlain,
type RichTextEditorProps,
} from '@meshkee/dashboard-ui'
import { uploadMediaFiles } from '../services/mediaService'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
allowImages?: boolean
editorMinHeight?: number
}
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
export { isEmptyRichText, richTextToPlain }
export type { RichTextEditorProps }
export function RichTextEditor({
value,
onChange,
placeholder,
allowImages = false,
editorMinHeight = 140,
allowImages,
onImageUpload,
...props
}: RichTextEditorProps) {
const { locale, dir } = useLocale()
const editorShellRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragRef = useRef<{ startX: number; startWidth: number; img: HTMLImageElement } | null>(
null,
)
const [uploadingImage, setUploadingImage] = useState(false)
const [uploadError, setUploadError] = useState('')
const [selectedImageEl, setSelectedImageEl] = useState<HTMLImageElement | null>(null)
const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(null)
const syncChange = useCallback(() => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML)
}
}, [onChange])
const updateHandlePosition = useCallback((img: HTMLImageElement | null) => {
if (!img || !editorShellRef.current) {
setHandlePos(null)
return
}
const shellRect = editorShellRef.current.getBoundingClientRect()
const imgRect = img.getBoundingClientRect()
setHandlePos({
top: imgRect.bottom - shellRect.top - 6,
left: imgRect.right - shellRect.left - 6,
})
}, [])
const clearImageSelection = useCallback(() => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((img) => {
img.classList.remove(SELECTED_IMAGE_CLASS)
})
setSelectedImageEl(null)
setHandlePos(null)
}, [])
const selectImage = useCallback(
(img: HTMLImageElement) => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
node.classList.remove(SELECTED_IMAGE_CLASS)
})
img.classList.add(SELECTED_IMAGE_CLASS)
setSelectedImageEl(img)
updateHandlePosition(img)
},
[updateHandlePosition],
)
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value
clearImageSelection()
}
}, [value, clearImageSelection])
useEffect(() => {
if (!allowImages) return
const editor = editorRef.current
if (!editor) return
function onEditorClick(e: MouseEvent) {
const target = e.target
if (!(target instanceof HTMLImageElement) || !editor!.contains(target)) return
e.preventDefault()
selectImage(target)
}
function onDocumentClick(e: MouseEvent) {
const target = e.target as Node
if (editor!.contains(target)) return
if (target instanceof Element && target.closest(`.${styles.resizeHandle}`)) return
clearImageSelection()
}
editor.addEventListener('click', onEditorClick)
document.addEventListener('click', onDocumentClick)
return () => {
editor.removeEventListener('click', onEditorClick)
document.removeEventListener('click', onDocumentClick)
}
}, [allowImages, selectImage, clearImageSelection])
useEffect(() => {
if (!selectedImageEl) return
function refreshHandle() {
if (selectedImageEl?.isConnected) {
updateHandlePosition(selectedImageEl)
}
}
window.addEventListener('resize', refreshHandle)
editorRef.current?.addEventListener('scroll', refreshHandle)
return () => {
window.removeEventListener('resize', refreshHandle)
editorRef.current?.removeEventListener('scroll', refreshHandle)
}
}, [selectedImageEl, updateHandlePosition])
function exec(cmd: string, arg?: string) {
document.execCommand(cmd, false, arg)
editorRef.current?.focus()
syncChange()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
let node: Node | null = selection.anchorNode
while (node && node !== editor) {
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
return node
}
node = node.parentNode
}
return null
}
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (!block) {
document.execCommand(direction > 0 ? 'indent' : 'outdent')
syncChange()
return
}
const current =
Number.parseFloat(block.style.paddingInlineStart) ||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
0
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
if (next === 0) {
block.style.paddingInlineStart = ''
} else {
block.style.paddingInlineStart = `${next}px`
}
syncChange()
}
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
e.preventDefault()
changeIndent(e.shiftKey ? -1 : 1)
}
function styleInsertedImage(img: HTMLImageElement) {
img.style.width = '100%'
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.draggable = false
}
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !editorRef.current) return
setUploadingImage(true)
setUploadError('')
try {
const uploaded = await uploadMediaFiles([file])
const url = uploaded[0]?.publicUrl
if (!url) throw new Error('Image upload failed.')
editorRef.current.focus()
document.execCommand('insertImage', false, url)
const imgs = editorRef.current.querySelectorAll('img')
const lastImg = imgs[imgs.length - 1]
if (lastImg instanceof HTMLImageElement) {
styleInsertedImage(lastImg)
selectImage(lastImg)
}
syncChange()
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Unable to upload image.')
} finally {
setUploadingImage(false)
}
}
function onResizeStart(e: React.MouseEvent) {
if (!selectedImageEl || !editorRef.current) return
e.preventDefault()
e.stopPropagation()
dragRef.current = {
startX: e.clientX,
startWidth: selectedImageEl.offsetWidth,
img: selectedImageEl,
}
function onMove(ev: MouseEvent) {
if (!dragRef.current || !editorRef.current) return
const delta = ev.clientX - dragRef.current.startX
const maxWidth = editorRef.current.clientWidth - 16
const nextWidth = Math.max(80, Math.min(maxWidth, dragRef.current.startWidth + delta))
dragRef.current.img.style.width = `${nextWidth}px`
dragRef.current.img.style.height = 'auto'
dragRef.current.img.style.maxWidth = '100%'
updateHandlePosition(dragRef.current.img)
}
function onUp() {
dragRef.current = null
syncChange()
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
return (
<div className={styles.wrapper} dir={dir} lang={locale}>
<div className={styles.toolbar} dir="ltr">
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<Bold size={16} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<Italic size={16} />
</button>
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
<Underline size={16} />
</button>
<span className={styles.divider} />
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<List size={16} />
</button>
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<ListOrdered size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={16} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={16} />
</button>
{allowImages && (
<>
<span className={styles.divider} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
title="Insert image"
aria-label="Insert image"
>
<ImagePlus size={16} />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleImageSelect}
/>
</>
)}
</div>
{uploadError && (
<p className={styles.uploadError} role="alert">
{uploadError}
</p>
)}
<div ref={editorShellRef} className={styles.editorShell}>
<div
ref={editorRef}
className={styles.editor}
style={{ minHeight: editorMinHeight }}
contentEditable
role="textbox"
aria-multiline="true"
dir={dir}
lang={locale}
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning
/>
{allowImages && selectedImageEl && handlePos && (
<button
type="button"
className={styles.resizeHandle}
style={{ top: handlePos.top, left: handlePos.left }}
onMouseDown={onResizeStart}
aria-label="Resize image"
/>
)}
</div>
</div>
<BaseRichTextEditor
{...props}
allowImages={allowImages}
onImageUpload={
allowImages
? onImageUpload ??
(async (file) => {
const uploaded = await uploadMediaFiles([file])
const url = uploaded[0]?.publicUrl
if (!url) throw new Error('Image upload failed.')
return url
})
: undefined
}
/>
)
}
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}
+10 -2
View File
@@ -68,7 +68,12 @@ function isGroupActive(group: NavGroup, pathname: string) {
return true
}
if (group.id === 'content') {
if (pathname.startsWith('/blog') || pathname.startsWith('/videos') || pathname.startsWith('/instructions')) {
if (
pathname.startsWith('/blog') ||
pathname.startsWith('/videos') ||
pathname.startsWith('/instructions') ||
pathname.startsWith('/workshops')
) {
return true
}
}
@@ -183,6 +188,9 @@ export function Sidebar({ open = false }: SidebarProps) {
...(hasBusinessModule(enabledModules, 'instructions')
? [{ labelKey: 'nav.content.instructions' as BusinessMessageKey, to: '/instructions' }]
: []),
...(hasBusinessModule(enabledModules, 'workshops')
? [{ labelKey: 'nav.workshops' as BusinessMessageKey, to: '/workshops' }]
: []),
],
},
{
@@ -222,7 +230,7 @@ export function Sidebar({ open = false }: SidebarProps) {
return items.filter((item) => {
if (item.type === 'group' && item.id === 'content') {
return hasAnyBusinessModule(enabledModules, ['blog', 'videos', 'instructions'])
return hasAnyBusinessModule(enabledModules, ['blog', 'videos', 'instructions', 'workshops'])
}
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
})
@@ -2,7 +2,7 @@
position: fixed;
bottom: 24px;
left: 24px;
z-index: 300;
z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
@@ -20,13 +20,23 @@
.cell {
flex: 0 0 auto;
display: flex;
scroll-snap-align: start;
min-width: 0;
}
.cellCompact {
align-self: stretch;
}
.cell > span {
display: block;
display: flex;
width: 100%;
min-height: 0;
}
.cellCompact > span {
height: 100%;
}
.cell > span > button {
@@ -154,6 +164,11 @@
transform: translateY(-2px);
}
.addCardCompact {
height: 100%;
min-height: 0;
}
.addIcon {
width: 44px;
height: 44px;
@@ -163,3 +178,8 @@
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1);
}
.addIconCompact {
width: 32px;
height: 32px;
}
@@ -36,7 +36,6 @@ export function WebsiteStaticImageGallery({
(a, b) => a.sortOrder - b.sortOrder || Number(a.id) - Number(b.id),
)
const fixedCount = itemCount != null && itemCount > 0 ? itemCount : null
const cellCount = fixedCount ?? sorted.length + 1
const assigned = new Map<number, WebsiteStaticImageItem>()
for (const image of sorted) {
@@ -59,13 +58,23 @@ export function WebsiteStaticImageGallery({
}
}
const cells = Array.from({ length: cellCount }, (_, index) => {
const isExtraAdd = !fixedCount && index === cellCount - 1
const image = isExtraAdd
? undefined
: (assigned.get(index) ?? (fixedCount ? undefined : sorted[index]))
return { index, image, isExtraAdd }
})
const imageCells = fixedCount
? Array.from({ length: fixedCount }, (_, index) => ({
index,
image: assigned.get(index),
isExtraAdd: false,
}))
: sorted.map((image, index) => ({
index,
image,
isExtraAdd: false,
}))
const addCell = !fixedCount
? { index: sorted.length, image: undefined, isExtraAdd: true }
: null
const compactAdd = Boolean(addCell && sorted.length > 0)
const cells = addCell ? [addCell, ...imageCells] : imageCells
const compactAddWidth = 56
return (
<div className={styles.track}>
@@ -85,11 +94,13 @@ export function WebsiteStaticImageGallery({
subtext ? { key: t('website.staticImages.subtext'), value: subtext } : null,
].filter((row): row is { key: string; value: string } => row != null)
const compact = isExtraAdd && compactAdd
return (
<div
key={`${index}-${image?.id ?? 'empty'}`}
className={styles.cell}
style={{ width: size.width }}
className={`${styles.cell} ${compact ? styles.cellCompact : ''}`}
style={{ width: compact ? compactAddWidth : size.width }}
>
{image ? (
<article className={styles.card}>
@@ -183,13 +194,17 @@ export function WebsiteStaticImageGallery({
<Tooltip label={label}>
<button
type="button"
className={styles.addCard}
className={`${styles.addCard} ${compact ? styles.addCardCompact : ''}`}
onClick={() => onAddAt(index)}
aria-label={label}
style={{ aspectRatio: cssAspect, minHeight: size.height }}
style={
compact
? { minHeight: size.height }
: { aspectRatio: cssAspect, minHeight: size.height }
}
>
<span className={styles.addIcon}>
<Plus size={28} />
<span className={`${styles.addIcon} ${compact ? styles.addIconCompact : ''}`}>
<Plus size={compact ? 20 : 28} />
</span>
</button>
</Tooltip>
@@ -0,0 +1,164 @@
import { Link } from 'react-router-dom'
import { Pencil, Users, Trash2, BadgeCheck, Clock } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Workshop } from '../types/workshop'
import {
formatWorkshopAuthor,
formatWorkshopCardMetaDate,
isWorkshopPublished,
} from '../services/workshopService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
interface WorkshopCardProps {
workshop: Workshop
registrationCount: number
onEdit: (id: string) => void
onRegistrations: (id: string) => void
onTogglePublished: (id: string) => void
onRemove: (id: string) => void
isTogglingStatus?: boolean
}
export function WorkshopCard({
workshop,
registrationCount,
onEdit,
onRegistrations,
onTogglePublished,
onRemove,
isTogglingStatus = false,
}: WorkshopCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const published = isWorkshopPublished(workshop)
const dateLocale = isFa ? 'fa' : 'en'
const metaDate = formatWorkshopCardMetaDate(workshop, dateLocale)
const eventTime = workshop.eventTime?.trim() || null
const titleLocale = textLocaleAttrs(workshop.title)
const abstractLocale = textLocaleAttrs(workshop.abstract)
const hasAbstract = Boolean(workshop.abstract?.trim())
const statusLabel = published ? t('workshop.card.unpublish') : t('workshop.card.publish')
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
return (
<article className={cardStyles.card}>
<Link to={`/workshops/detail/${workshop.id}`} className={cardStyles.clickable}>
<div className={cardStyles.imageWrap}>
{workshop.coverImageUrl ? (
<img
src={workshop.coverImageUrl}
alt={workshop.title}
className={cardStyles.image}
loading="lazy"
/>
) : (
<div className={cardStyles.imagePlaceholder} aria-hidden="true" />
)}
{!published && (
<span className={`${cardStyles.statusBadge} ${cardStyles.waitingBadge}`}>
<Clock size={11} aria-hidden="true" />
{t('workshop.card.draft')}
</span>
)}
{published && (
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
{t('workshop.card.published')}
</span>
)}
</div>
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
lang={titleLocale.lang}
dir={titleDir}
>
{workshop.title}
</h3>
{hasAbstract ? (
<p
className={[cardStyles.abstract, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractDir}
>
{workshop.abstract}
</p>
) : (
<p
className={cardStyles.abstract}
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{t('workshop.card.noSummary')}
</p>
)}
<p className={cardStyles.meta}>
<span>{formatWorkshopAuthor(workshop.author)}</span>
<span className={cardStyles.metaDot}>·</span>
<span>{metaDate}</span>
{eventTime ? (
<>
<span className={cardStyles.metaDot}>·</span>
<span dir="ltr">{eventTime}</span>
</>
) : null}
</p>
</div>
</Link>
<div className={controlStyles.controls}>
<Tooltip label={t('workshop.card.edit')}>
<button
type="button"
onClick={() => onEdit(workshop.id)}
aria-label={t('workshop.card.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={statusLabel}>
<button
type="button"
className={published ? cardStyles.verifyActive : cardStyles.verifyWaiting}
disabled={isTogglingStatus}
onClick={() => onTogglePublished(workshop.id)}
aria-label={statusLabel}
>
{published ? <BadgeCheck size={16} /> : <Clock size={16} />}
</button>
</Tooltip>
<Tooltip label={t('workshop.card.registrations')}>
<button
type="button"
className={controlStyles.iconBtn}
onClick={() => onRegistrations(workshop.id)}
aria-label={`${t('workshop.card.registrations')} (${registrationCount})`}
>
<Users size={16} />
{registrationCount > 0 && (
<span className={controlStyles.commentBadge}>{registrationCount}</span>
)}
</button>
</Tooltip>
<Tooltip label={t('workshop.card.remove')}>
<button
type="button"
className={controlStyles.danger}
onClick={() => onRemove(workshop.id)}
aria-label={t('workshop.card.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,68 @@
import type { Category } from '../types/category'
import { BlogCategoryRow } from './BlogCategoryRow'
import { getChildren, hasChildren } from '../utils/categories'
import styles from './CategoryTree.module.css'
interface WorkshopCategoryTreeProps {
categories: Category[]
expandedIds: Set<string>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
}
export function WorkshopCategoryTree({
categories,
expandedIds,
onToggle,
onAddSub,
onEdit,
onRemove,
parentId = null,
depth = 0,
}: WorkshopCategoryTreeProps) {
const nodes = getChildren(categories, parentId)
return (
<>
{nodes.map((category) => {
const childCount = hasChildren(categories, category.id)
const expanded = expandedIds.has(category.id)
return (
<div key={category.id}>
<BlogCategoryRow
category={category}
depth={depth}
hasChildren={childCount}
expanded={expanded}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
/>
{childCount && (
<div className={`${styles.childrenWrap} ${expanded ? styles.expanded : ''}`}>
<div className={styles.childrenInner}>
<WorkshopCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={onToggle}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
/>
</div>
</div>
)}
</div>
)
})}
</>
)
}
@@ -0,0 +1,219 @@
.list {
display: flex;
flex-direction: column;
gap: 0;
}
.tableWrap {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th,
.table td {
padding: 10px 12px;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.06);
}
.table td {
color: var(--text-primary);
vertical-align: middle;
}
.actionsCol {
width: 52px;
}
.cellCol {
text-align: right;
white-space: nowrap;
}
.table th.cellCol,
.table td.cellCol {
text-align: right;
}
.statusCol {
width: 120px;
}
.statusCell {
vertical-align: middle;
}
.statusControls {
display: flex;
align-items: center;
gap: 4px;
}
.statusBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 8px;
transition: background 0.2s, color 0.2s, opacity 0.2s;
}
.statusBtn:disabled:not(.statusBtnActive) {
opacity: 0.45;
cursor: not-allowed;
}
.statusBtnActive:disabled {
opacity: 1;
cursor: default;
}
.statusBtnPending {
color: #0f172a;
}
.statusBtnPending:hover:not(:disabled) {
background: rgba(15, 23, 42, 0.08);
color: #0f172a;
}
.statusBtnPending.statusBtnActive {
background: rgba(15, 23, 42, 0.12);
color: #0f172a;
}
.statusBtnApprove {
color: #15803d;
}
.statusBtnApprove:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
}
.statusBtnApprove.statusBtnActive {
background: rgba(34, 197, 94, 0.18);
color: #15803d;
}
.statusBtnReject {
color: #dc2626;
}
.statusBtnReject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.statusBtnReject.statusBtnActive {
background: rgba(239, 68, 68, 0.14);
color: #dc2626;
}
.actionsCell {
text-align: center;
}
.empty {
margin: 0;
padding: 24px 0;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
.error {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.08);
color: #b91c1c;
font-size: 13px;
}
.loading {
margin: 0;
padding: 24px 0;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
.modal {
position: relative;
max-width: 980px;
}
.content {
position: relative;
}
.body {
padding: 16px 18px 88px;
overflow-y: auto;
max-height: min(60vh, 520px);
}
.fabDock {
position: absolute;
inset-inline-end: 18px;
bottom: 18px;
z-index: 3;
}
.addFab {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.addFab:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.addFabActive {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.5);
}
@media (max-width: 768px) {
.body {
padding-bottom: 80px;
}
.fabDock {
inset-inline-end: 14px;
bottom: 14px;
}
.addFab {
width: 52px;
height: 52px;
}
}
@@ -0,0 +1,378 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X, Clock, Check, XCircle } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
addWorkshopRegistration,
formatWorkshopDate,
formatWorkshopRegistrationName,
listWorkshopRegistrations,
removeWorkshopRegistration,
updateWorkshopRegistrationStatus,
type WorkshopRegistration,
type WorkshopRegistrationStatus,
} from '../services/workshopService'
import type { BusinessCustomer } from '../services/customerService'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { AddWorkshopRegistrationModal } from './AddWorkshopRegistrationModal'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
import styles from './WorkshopRegistrationsModal.module.css'
interface WorkshopRegistrationsModalProps {
open: boolean
workshopId: string
workshopTitle: string
onClose: () => void
onRegistrationAdded?: () => void
onRegistrationRemoved?: () => void
}
const ANIMATION_MS = 220
const STATUS_CONTROLS: {
status: WorkshopRegistrationStatus
icon: typeof Clock
labelKey: 'workshop.registrations.status.pending' | 'workshop.registrations.status.approved' | 'workshop.registrations.status.rejected'
className: 'statusBtnPending' | 'statusBtnApprove' | 'statusBtnReject'
}[] = [
{
status: 'pending',
icon: Clock,
labelKey: 'workshop.registrations.status.pending',
className: 'statusBtnPending',
},
{
status: 'approved',
icon: Check,
labelKey: 'workshop.registrations.status.approved',
className: 'statusBtnApprove',
},
{
status: 'rejected',
icon: XCircle,
labelKey: 'workshop.registrations.status.rejected',
className: 'statusBtnReject',
},
]
export function WorkshopRegistrationsModal({
open,
workshopId,
workshopTitle,
onClose,
onRegistrationAdded,
onRegistrationRemoved,
}: WorkshopRegistrationsModalProps) {
const t = useT()
const { showToast } = useToast()
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [registrations, setRegistrations] = useState<WorkshopRegistration[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [addError, setAddError] = useState('')
const [addOpen, setAddOpen] = useState(false)
const [isAdding, setIsAdding] = useState(false)
const [updatingId, setUpdatingId] = useState<string | null>(null)
const [removingId, setRemovingId] = useState<string | null>(null)
const registeredUserIds = useMemo(
() => registrations.map((registration) => registration.userId),
[registrations],
)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
setAddOpen(false)
setAddError('')
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open || !workshopId) return
const controller = new AbortController()
void loadRegistrations(controller.signal)
return () => controller.abort()
}, [open, workshopId])
async function loadRegistrations(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listWorkshopRegistrations(workshopId, signal)
setRegistrations(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.registrations.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function handleAddCustomer(customer: BusinessCustomer) {
setIsAdding(true)
setAddError('')
try {
const result = await addWorkshopRegistration(workshopId, customer.id)
setRegistrations((current) => [result.registration, ...current])
setAddOpen(false)
showToast(t('workshop.registrations.toast.added'), 'success')
onRegistrationAdded?.()
} catch (err) {
if (err instanceof ApiError) {
setAddError(err.message)
} else {
setAddError(t('workshop.registrations.errorAdd'))
}
} finally {
setIsAdding(false)
}
}
function openAddModal() {
setAddError('')
setAddOpen(true)
}
function closeAddModal() {
if (isAdding) return
setAddOpen(false)
setAddError('')
}
async function handleStatusChange(
registrationId: string,
status: WorkshopRegistrationStatus,
) {
setUpdatingId(registrationId)
setError('')
try {
const result = await updateWorkshopRegistrationStatus(
workshopId,
registrationId,
status,
)
setRegistrations((current) =>
current.map((item) =>
item.id === registrationId ? result.registration : item,
),
)
showToast(t('workshop.registrations.toast.statusUpdated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.registrations.errorStatus'))
}
} finally {
setUpdatingId(null)
}
}
async function handleRemove(registrationId: string) {
setRemovingId(registrationId)
setError('')
try {
await removeWorkshopRegistration(workshopId, registrationId)
setRegistrations((current) => current.filter((item) => item.id !== registrationId))
showToast(t('workshop.registrations.toast.removed'), 'success')
onRegistrationRemoved?.()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.registrations.errorRemove'))
}
} finally {
setRemovingId(null)
}
}
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !addOpen) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, addOpen, onClose])
if (!mounted) return null
return createPortal(
<>
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="workshop-registrations-title"
lang={locale === 'fa' ? 'fa' : undefined}
dir={locale === 'fa' ? 'rtl' : undefined}
>
<div className={modalStyles.header}>
<div>
<h2 id="workshop-registrations-title" className={modalStyles.title}>
{t('workshop.registrations.title')}
</h2>
<p className={modalStyles.subtitle}>{workshopTitle}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={styles.content}>
<div className={styles.body}>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.loading}>{t('workshop.registrations.loading')}</p>
) : registrations.length === 0 ? (
<p className={styles.empty}>{t('workshop.registrations.empty')}</p>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('workshop.registrations.col.name')}</th>
<th className={styles.cellCol}>{t('workshop.registrations.col.cell')}</th>
<th>{t('workshop.registrations.col.date')}</th>
<th className={styles.statusCol}>{t('workshop.registrations.col.status')}</th>
<th className={styles.actionsCol} />
</tr>
</thead>
<tbody>
{registrations.map((registration) => (
<tr key={registration.id}>
<td>
{formatWorkshopRegistrationName(registration.user, dateLocale)}
</td>
<td className={styles.cellCol} dir="ltr">
{registration.user.cellNumber
? formatCellForDisplay(registration.user.cellNumber)
: '—'}
</td>
<td>{formatWorkshopDate(registration.createdAt, dateLocale)}</td>
<td className={styles.statusCell}>
<div className={styles.statusControls}>
{STATUS_CONTROLS.map(({ status, icon: Icon, labelKey, className }) => {
const isActive = registration.status === status
const isBusy =
updatingId === registration.id || removingId === registration.id
return (
<Tooltip key={status} label={t(labelKey)}>
<button
type="button"
className={[
styles.statusBtn,
styles[className],
isActive ? styles.statusBtnActive : '',
]
.filter(Boolean)
.join(' ')}
disabled={isBusy || isActive}
onClick={() => handleStatusChange(registration.id, status)}
aria-label={t(labelKey)}
aria-pressed={isActive}
>
<Icon size={16} />
</button>
</Tooltip>
)
})}
</div>
</td>
<td className={styles.actionsCell}>
<Tooltip label={t('workshop.registrations.remove')}>
<button
type="button"
className={modalStyles.removeRowBtn}
onClick={() => handleRemove(registration.id)}
disabled={
removingId === registration.id || updatingId === registration.id
}
aria-label={t('workshop.registrations.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className={styles.fabDock}>
<Tooltip label={t('workshop.registrations.add')}>
<button
type="button"
className={[styles.addFab, addOpen ? styles.addFabActive : '']
.filter(Boolean)
.join(' ')}
onClick={openAddModal}
disabled={isAdding}
aria-label={t('workshop.registrations.add')}
aria-haspopup="dialog"
aria-expanded={addOpen}
>
<Plus size={22} />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
<AddWorkshopRegistrationModal
open={addOpen}
excludeIds={registeredUserIds}
isAdding={isAdding}
error={addError}
onClose={closeAddModal}
onPick={handleAddCustomer}
/>
</>,
document.body,
)
}
+15 -11
View File
@@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import styles from '../components/Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastContext.Provider value={value}>
{children}
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>
{createPortal(
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>,
document.body,
)}
</ToastContext.Provider>
)
}
+297 -10
View File
@@ -6,6 +6,10 @@ const en = {
'app.poweredBy': 'powered by Meshkee.app',
'app.userFallback': 'User',
'empty.list.part1': 'You currently have ',
'empty.list.part2': '. To start ',
'empty.list.part3': ', use the button below.',
'role.superAdmin': 'Super Admin',
'role.owner': 'Business Owner',
'role.admin': 'Admin',
@@ -67,6 +71,11 @@ const en = {
'nav.instructions.new': 'Add New Instruction',
'nav.instructions.categories': 'Categories',
'nav.instructions.settings': 'Settings',
'nav.workshops': 'Workshops',
'nav.workshops.overview': 'Overview',
'nav.workshops.list': 'My Workshops',
'nav.workshops.new': 'Add New Workshop',
'nav.workshops.categories': 'Categories',
'nav.website': 'Website',
'nav.website.overview': 'Overview',
'nav.website.staticImages': 'Static Images',
@@ -208,6 +217,8 @@ const en = {
'content.card.videos.link': 'Open videos',
'content.card.instructions.desc': 'Tutorials with rich text and optional embedded video.',
'content.card.instructions.link': 'Open instructions',
'content.card.workshops.desc': 'Workshops with rich text, image gallery, and customer registration.',
'content.card.workshops.link': 'Open workshops',
'home.card.blog.title': 'Blog',
'home.card.blog.desc': 'Create and manage blog posts and categories.',
'home.card.blog.link': 'View blog',
@@ -283,6 +294,8 @@ const en = {
'products.list.clearFilters': 'Clear filters',
'products.list.loading': 'Loading products...',
'products.list.empty': 'You have not added any products yet.',
'products.list.emptyNone': 'no products',
'products.list.emptyAction': 'adding a product',
'products.list.emptyCta': 'Add product',
'products.list.emptyFiltered': 'No products match your filters.',
'products.list.errorLoad': 'Unable to load products.',
@@ -1006,6 +1019,8 @@ const en = {
'blog.list.subtitle': '{count} posts · View, edit and manage your blog content.',
'blog.list.loading': 'Loading blog posts...',
'blog.list.empty': 'No blog posts yet.',
'blog.list.emptyNone': 'no blog posts',
'blog.list.emptyAction': 'publishing a blog post',
'blog.list.emptyHint': 'Share news, stories, and updates with your audience.',
'blog.list.emptyCta': 'Add blog',
'blog.list.errorLoad': 'Unable to load blog posts.',
@@ -1090,6 +1105,8 @@ const en = {
'video.list.subtitle': '{count} videos · View, edit and manage your video content.',
'video.list.loading': 'Loading videos...',
'video.list.empty': 'No videos yet.',
'video.list.emptyNone': 'no videos',
'video.list.emptyAction': 'adding a video',
'video.list.emptyHint': 'Share embedded videos from YouTube or Aparat with your audience.',
'video.list.emptyCta': 'Add video',
'video.list.errorLoad': 'Unable to load videos.',
@@ -1168,6 +1185,8 @@ const en = {
'instruction.list.subtitle': '{count} instructions · View, edit and manage your tutorials.',
'instruction.list.loading': 'Loading instructions...',
'instruction.list.empty': 'No instructions yet.',
'instruction.list.emptyNone': 'no instructions',
'instruction.list.emptyAction': 'adding an instruction',
'instruction.list.emptyHint': 'Publish tutorials with rich text and optional video for your audience.',
'instruction.list.emptyCta': 'Add instruction',
'instruction.list.errorLoad': 'Unable to load instructions.',
@@ -1232,6 +1251,113 @@ const en = {
'instruction.details.noDescription': 'No description yet.',
'instruction.details.author': 'Author',
'workshop.page.subtitle': 'Create and manage workshops with rich text, gallery images, and registrations.',
'workshop.card.list.desc': 'View, edit and manage all your workshops.',
'workshop.card.list.link': 'View workshops',
'workshop.card.new.desc': 'Add a new workshop with thumbnail, rich text, and image gallery.',
'workshop.card.new.link': 'Add workshop',
'workshop.card.categories.title': 'Workshop Categories',
'workshop.card.categories.desc': 'Organize workshops into categories and subcategories.',
'workshop.card.categories.link': 'View categories',
'workshop.list.subtitle': '{count} workshops · View, edit and manage your workshop content.',
'workshop.list.loading': 'Loading workshops...',
'workshop.list.empty':
"You don't have any workshops yet. To get started, use the button below to add one.",
'workshop.list.emptyNone': 'no workshops',
'workshop.list.emptyAction': 'registering a workshop',
'workshop.list.emptyCta': 'Add workshop',
'workshop.list.errorLoad': 'Unable to load workshops.',
'workshop.list.errorDelete': 'Unable to delete workshop.',
'workshop.list.errorStatus': 'Unable to update workshop status.',
'workshop.list.toast.removed': 'Workshop removed.',
'workshop.list.toast.published': 'Workshop published.',
'workshop.list.toast.unpublished': 'Workshop unpublished.',
'workshop.list.deleteTitle': 'Delete Workshop',
'workshop.list.deleteMessage':
'Are you sure you want to delete "{name}"? This action cannot be undone.',
'workshop.list.addNew': 'Add new workshop',
'workshop.card.edit': 'Edit workshop',
'workshop.card.publish': 'Publish workshop',
'workshop.card.unpublish': 'Unpublish workshop',
'workshop.card.registrations': 'View registrations',
'workshop.card.remove': 'Remove workshop',
'workshop.card.draft': 'Draft',
'workshop.card.published': 'Published',
'workshop.card.noSummary': 'No summary yet.',
'workshop.categories.title': 'Workshop Categories',
'workshop.categories.subtitle': 'Organize your workshops into categories and subcategories.',
'workshop.categories.empty': 'You have not added any categories yet.',
'workshop.categories.errorLoad': 'Unable to load categories.',
'workshop.categories.errorSave': 'Unable to save category.',
'workshop.categories.errorDelete': 'Unable to delete category.',
'workshop.form.add.subtitle': 'Add a workshop with thumbnail, rich text, and image gallery.',
'workshop.form.edit.subtitle': 'Update workshop details and save changes.',
'workshop.form.cancel': 'Cancel',
'workshop.form.saving': 'Saving...',
'workshop.form.save': 'Save Workshop',
'workshop.form.update': 'Update Workshop',
'workshop.form.errorSave': 'Unable to save workshop.',
'workshop.form.errorThumbnailUpload': 'Unable to upload thumbnail. Please try again.',
'workshop.form.toast.created': 'Workshop created.',
'workshop.form.toast.updated': 'Workshop updated.',
'workshop.form.thumbnail': 'Thumbnail (1:1)',
'workshop.form.uploadThumbnail': 'Upload thumbnail',
'workshop.form.changeThumbnail': 'Change thumbnail',
'workshop.form.cropHint': 'Click to select, then crop (1:1)',
'workshop.form.category': 'Category',
'workshop.form.categoryPlaceholder': 'Search and select category...',
'workshop.form.titleFa': 'Title (FA)',
'workshop.form.titleFaPlaceholder': 'عنوان فارسی',
'workshop.form.titleEn': 'Title (EN)',
'workshop.form.titleEnPlaceholder': 'English title',
'workshop.form.abstract': 'Abstract',
'workshop.form.abstractPlaceholder': 'Short summary shown in workshop listings',
'workshop.form.description': 'Description',
'workshop.form.eventDate': 'Event date',
'workshop.form.eventDatePlaceholder': 'Select date',
'workshop.form.eventTime': 'Time',
'workshop.form.duration': 'Duration',
'workshop.form.durationPlaceholder': 'e.g. 2 hours, 3 days, 1 week',
'workshop.form.descriptionPlaceholder': 'Full workshop description with formatting...',
'workshop.form.gallery': 'Gallery',
'workshop.form.tags': 'Tags',
'workshop.details.loading': 'Loading workshop...',
'workshop.details.notFound': 'Workshop not found.',
'workshop.details.errorLoad': 'Unable to load workshop.',
'workshop.details.back': 'Back to My Workshops',
'workshop.details.noContent': 'No description yet.',
'workshop.details.gallery': 'Gallery',
'workshop.details.galleryImage': 'Gallery image {index}',
'workshop.registrations.title': 'Registered Users',
'workshop.registrations.loading': 'Loading registrations...',
'workshop.registrations.empty': 'No registrations yet.',
'workshop.registrations.errorLoad': 'Unable to load registrations.',
'workshop.registrations.add': 'Add customer',
'workshop.registrations.searchLabel': 'Customer',
'workshop.registrations.searchPlaceholder': 'Search by name, phone, or email',
'workshop.registrations.searchHint': 'Only active customers of this business can be added.',
'workshop.registrations.errorAdd': 'Unable to add registration.',
'workshop.registrations.toast.added': 'Customer added to workshop.',
'workshop.registrations.col.name': 'Name',
'workshop.registrations.col.cell': 'Cell',
'workshop.registrations.col.email': 'Email',
'workshop.registrations.col.date': 'Registered',
'workshop.registrations.col.status': 'Status',
'workshop.registrations.status.pending': 'Pending',
'workshop.registrations.status.approved': 'Approved',
'workshop.registrations.status.rejected': 'Rejected',
'workshop.registrations.remove': 'Remove registration',
'workshop.registrations.errorStatus': 'Unable to update registration status.',
'workshop.registrations.errorRemove': 'Unable to remove registration.',
'workshop.registrations.toast.statusUpdated': 'Registration status updated.',
'workshop.registrations.toast.removed': 'Registration removed.',
'portfolio.page.subtitle': 'Manage your portfolio items and showcase projects.',
'portfolio.card.list.desc': 'View, edit and manage all your portfolio items.',
'portfolio.card.list.link': 'View portfolios',
@@ -1246,6 +1372,8 @@ const en = {
'portfolio.list.subtitle': '{count} items · View, edit and manage your portfolio content.',
'portfolio.list.loading': 'Loading portfolios...',
'portfolio.list.empty': 'You have not added any portfolio items yet.',
'portfolio.list.emptyNone': 'no portfolio items',
'portfolio.list.emptyAction': 'adding a portfolio item',
'portfolio.list.emptyCta': 'Add portfolio',
'portfolio.list.errorLoad': 'Unable to load portfolios.',
'portfolio.list.errorDelete': 'Unable to delete portfolio.',
@@ -1562,23 +1690,28 @@ const en = {
'website.specialItems.addItemsTo': 'Add store items to {title}',
'website.specialItems.removeFromSpecial': 'Remove from special',
'website.specialItems.pickSubtitle': 'Select store items to feature in this category.',
'website.specialItems.pickSubtitleProducts':
'Select products to feature in this category.',
'website.specialItems.pickLoading': 'Loading store items...',
'website.specialItems.pickLoadingProducts': 'Loading products...',
'website.specialItems.pickEmpty': 'All store items are already in this category.',
'website.specialItems.pickEmptyProducts': 'All products are already in this category.',
'website.specialItems.pickSearch': 'Type 3+ characters to search store items',
'website.specialItems.pickSearchProducts': 'Type 3+ characters to search products',
'website.specialItems.pickErrorLoad': 'Unable to load store items.',
'website.specialItems.pickErrorLoadProducts': 'Unable to load products.',
'website.settings.title': 'Website settings',
'website.settings.subtitle': 'Configure how website content is sourced and displayed.',
'website.settings.section': 'Special products',
'website.settings.loading': 'Loading settings...',
'website.settings.errorLoad': 'Unable to load settings.',
'website.settings.errorSave': 'Unable to save settings.',
'website.settings.saved': 'Settings saved.',
'website.settings.specialSource': 'Read special products from',
'website.settings.specialSource': 'Special products source',
'website.settings.specialSourceDesc':
'Choose whether featured products on the website come from catalog products or store items. Defaults to store items when the store module is enabled.',
'website.settings.source.product': 'Product',
'website.settings.source.storeItem': 'Store item',
'Choose where your special products come from: items added to the store for sale, or catalog products (without price and stock).',
'website.settings.source.product': 'Products',
'website.settings.source.storeItem': 'Store',
'website.pick.addOneItem': 'Add 1 item',
'website.pick.addItems': 'Add {count} items',
@@ -1692,6 +1825,11 @@ const en = {
'title.addInstruction': 'Add New Instruction',
'title.editInstruction': 'Edit Instruction',
'title.instructionDetails': 'Instruction Details',
'title.workshops': 'Workshops',
'title.myWorkshops': 'My Workshops',
'title.addWorkshop': 'Add New Workshop',
'title.editWorkshop': 'Edit Workshop',
'title.workshopDetails': 'Workshop Details',
'title.myVideos': 'My Videos',
'title.addVideo': 'Add New Video',
'title.editVideo': 'Edit Video',
@@ -2021,6 +2159,10 @@ const fa: Record<MessageKey, string> = {
'app.poweredBy': 'قدرت‌گرفته از Meshkee.app',
'app.userFallback': 'کاربر',
'empty.list.part1': 'شما در حال حاضر ',
'empty.list.part2': ' ندارید. برای شروع ',
'empty.list.part3': ' می‌توانید از دکمهٔ زیر استفاده نمایید.',
'role.superAdmin': 'سوپرادمین',
'role.owner': 'صاحب کسب‌وکار',
'role.admin': 'ادمین',
@@ -2082,6 +2224,11 @@ const fa: Record<MessageKey, string> = {
'nav.instructions.new': 'افزودن آموزش',
'nav.instructions.categories': 'دسته‌بندی‌ها',
'nav.instructions.settings': 'تنظیمات',
'nav.workshops': 'کارگاه‌ها',
'nav.workshops.overview': 'نمای کلی',
'nav.workshops.list': 'کارگاه‌های من',
'nav.workshops.new': 'افزودن کارگاه',
'nav.workshops.categories': 'دسته‌بندی‌ها',
'nav.website': 'وب‌سایت',
'nav.website.overview': 'نمای کلی',
'nav.website.staticImages': 'تصاویر ثابت',
@@ -2223,6 +2370,8 @@ const fa: Record<MessageKey, string> = {
'content.card.videos.link': 'ورود به ویدیوها',
'content.card.instructions.desc': 'آموزش‌ها با متن غنی و ویدیوی جاسازی‌شده اختیاری.',
'content.card.instructions.link': 'ورود به آموزش‌ها',
'content.card.workshops.desc': 'کارگاه‌ها با متن غنی، گالری تصویر و ثبت‌نام مشتری.',
'content.card.workshops.link': 'ورود به کارگاه‌ها',
'home.card.blog.title': 'بلاگ',
'home.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.',
'home.card.blog.link': 'مشاهده بلاگ',
@@ -2298,6 +2447,8 @@ const fa: Record<MessageKey, string> = {
'products.list.clearFilters': 'پاک کردن فیلترها',
'products.list.loading': 'در حال بارگذاری محصولات...',
'products.list.empty': 'هنوز محصولی ثبت نکرده‌اید.',
'products.list.emptyNone': 'هیچ محصولی',
'products.list.emptyAction': 'ثبت محصول',
'products.list.emptyCta': 'افزودن محصول',
'products.list.emptyFiltered': 'هیچ محصولی با فیلترهای شما مطابقت ندارد.',
'products.list.errorLoad': 'بارگذاری محصولات ممکن نشد.',
@@ -3019,6 +3170,8 @@ const fa: Record<MessageKey, string> = {
'blog.list.subtitle': '{count} مطلب · مشاهده، ویرایش و مدیریت محتوای بلاگ.',
'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...',
'blog.list.empty': 'هنوز مطلب بلاگی ندارید.',
'blog.list.emptyNone': 'هیچ مطلب بلاگی',
'blog.list.emptyAction': 'ثبت بلاگ',
'blog.list.emptyHint': 'اخبار، داستان‌ها و به‌روزرسانی‌ها را با مخاطبان خود به اشتراک بگذارید.',
'blog.list.emptyCta': 'افزودن مطلب',
'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.',
@@ -3103,6 +3256,8 @@ const fa: Record<MessageKey, string> = {
'video.list.subtitle': '{count} ویدیو · مشاهده، ویرایش و مدیریت محتوای ویدیو.',
'video.list.loading': 'در حال بارگذاری ویدیوها...',
'video.list.empty': 'هنوز ویدیویی نیست.',
'video.list.emptyNone': 'هیچ ویدیویی',
'video.list.emptyAction': 'ثبت ویدیو',
'video.list.emptyHint': 'ویدیوهای جاسازی‌شده از یوتیوب یا آپارات را با مخاطبان به اشتراک بگذارید.',
'video.list.emptyCta': 'افزودن ویدیو',
'video.list.errorLoad': 'بارگذاری ویدیوها ممکن نشد.',
@@ -3181,6 +3336,8 @@ const fa: Record<MessageKey, string> = {
'instruction.list.subtitle': '{count} آموزش · مشاهده، ویرایش و مدیریت محتوای آموزشی.',
'instruction.list.loading': 'در حال بارگذاری آموزش‌ها...',
'instruction.list.empty': 'هنوز آموزشی نیست.',
'instruction.list.emptyNone': 'هیچ آموزشی',
'instruction.list.emptyAction': 'ثبت آموزش',
'instruction.list.emptyHint': 'آموزش‌ها را با متن غنی و ویدیوی اختیاری برای مخاطبان منتشر کنید.',
'instruction.list.emptyCta': 'افزودن آموزش',
'instruction.list.errorLoad': 'بارگذاری آموزش‌ها ممکن نشد.',
@@ -3245,6 +3402,113 @@ const fa: Record<MessageKey, string> = {
'instruction.details.noDescription': 'هنوز توضیحاتی نیست.',
'instruction.details.author': 'نویسنده',
'workshop.page.subtitle': 'کارگاه‌ها را با متن غنی، گالری تصویر و ثبت‌نام مدیریت کنید.',
'workshop.card.list.desc': 'همه کارگاه‌ها را مشاهده، ویرایش و مدیریت کنید.',
'workshop.card.list.link': 'مشاهده کارگاه‌ها',
'workshop.card.new.desc': 'کارگاه جدید با تصویر شاخص، متن غنی و گالری تصویر اضافه کنید.',
'workshop.card.new.link': 'افزودن کارگاه',
'workshop.card.categories.title': 'دسته‌بندی کارگاه‌ها',
'workshop.card.categories.desc': 'کارگاه‌ها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.',
'workshop.card.categories.link': 'مشاهده دسته‌بندی‌ها',
'workshop.list.subtitle': '{count} کارگاه · مشاهده، ویرایش و مدیریت محتوای کارگاه.',
'workshop.list.loading': 'در حال بارگذاری کارگاه‌ها...',
'workshop.list.empty':
'شما در حال حاضر هیچ کارگاهی ندارید. برای شروع ثبت کارگاه می‌توانید از دکمهٔ زیر استفاده نمایید.',
'workshop.list.emptyNone': 'هیچ کارگاهی',
'workshop.list.emptyAction': 'ثبت کارگاه',
'workshop.list.emptyCta': 'افزودن کارگاه',
'workshop.list.errorLoad': 'بارگذاری کارگاه‌ها ممکن نشد.',
'workshop.list.errorDelete': 'حذف کارگاه ممکن نشد.',
'workshop.list.errorStatus': 'به‌روزرسانی وضعیت کارگاه ممکن نشد.',
'workshop.list.toast.removed': 'کارگاه حذف شد.',
'workshop.list.toast.published': 'کارگاه منتشر شد.',
'workshop.list.toast.unpublished': 'انتشار کارگاه لغو شد.',
'workshop.list.deleteTitle': 'حذف کارگاه',
'workshop.list.deleteMessage':
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
'workshop.list.addNew': 'افزودن کارگاه جدید',
'workshop.card.edit': 'ویرایش کارگاه',
'workshop.card.publish': 'انتشار کارگاه',
'workshop.card.unpublish': 'لغو انتشار کارگاه',
'workshop.card.registrations': 'مشاهده ثبت‌نام‌ها',
'workshop.card.remove': 'حذف کارگاه',
'workshop.card.draft': 'پیش‌نویس',
'workshop.card.published': 'منتشرشده',
'workshop.card.noSummary': 'هنوز خلاصه‌ای نیست.',
'workshop.categories.title': 'دسته‌بندی کارگاه‌ها',
'workshop.categories.subtitle': 'کارگاه‌ها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.',
'workshop.categories.empty': 'هنوز دسته‌بندی اضافه نکرده‌اید.',
'workshop.categories.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.',
'workshop.categories.errorSave': 'ذخیره دسته‌بندی ممکن نشد.',
'workshop.categories.errorDelete': 'حذف دسته‌بندی ممکن نشد.',
'workshop.form.add.subtitle': 'کارگاه با تصویر شاخص، متن غنی و گالری تصویر اضافه کنید.',
'workshop.form.edit.subtitle': 'جزئیات کارگاه را به‌روزرسانی و ذخیره کنید.',
'workshop.form.cancel': 'انصراف',
'workshop.form.saving': 'در حال ذخیره...',
'workshop.form.save': 'ذخیره کارگاه',
'workshop.form.update': 'به‌روزرسانی کارگاه',
'workshop.form.errorSave': 'ذخیره کارگاه ممکن نشد.',
'workshop.form.errorThumbnailUpload': 'بارگذاری تصویر شاخص ممکن نشد. دوباره تلاش کنید.',
'workshop.form.toast.created': 'کارگاه ایجاد شد.',
'workshop.form.toast.updated': 'کارگاه به‌روزرسانی شد.',
'workshop.form.thumbnail': 'تصویر شاخص (۱:۱)',
'workshop.form.uploadThumbnail': 'بارگذاری تصویر شاخص',
'workshop.form.changeThumbnail': 'تغییر تصویر شاخص',
'workshop.form.cropHint': 'برای انتخاب و برش (۱:۱) کلیک کنید',
'workshop.form.category': 'دسته‌بندی',
'workshop.form.categoryPlaceholder': 'جستجو و انتخاب دسته‌بندی...',
'workshop.form.titleFa': 'عنوان (FA)',
'workshop.form.titleFaPlaceholder': 'عنوان فارسی',
'workshop.form.titleEn': 'عنوان (EN)',
'workshop.form.titleEnPlaceholder': 'English title',
'workshop.form.abstract': 'چکیده',
'workshop.form.abstractPlaceholder': 'خلاصه کوتاه در فهرست کارگاه‌ها',
'workshop.form.description': 'توضیحات',
'workshop.form.eventDate': 'زمان برگزاری',
'workshop.form.eventDatePlaceholder': 'انتخاب تاریخ',
'workshop.form.eventTime': 'ساعت',
'workshop.form.duration': 'مدت',
'workshop.form.durationPlaceholder': 'مثلاً ۲ ساعت، ۳ روز، ۱ هفته',
'workshop.form.descriptionPlaceholder': 'توضیحات کامل کارگاه با قالب‌بندی...',
'workshop.form.gallery': 'گالری',
'workshop.form.tags': 'برچسب‌ها',
'workshop.details.loading': 'در حال بارگذاری کارگاه...',
'workshop.details.notFound': 'کارگاه یافت نشد.',
'workshop.details.errorLoad': 'بارگذاری کارگاه ممکن نشد.',
'workshop.details.back': 'بازگشت به کارگاه‌های من',
'workshop.details.noContent': 'هنوز توضیحی ثبت نشده است.',
'workshop.details.gallery': 'گالری',
'workshop.details.galleryImage': 'تصویر گالری {index}',
'workshop.registrations.title': 'کاربران ثبت‌نام‌شده',
'workshop.registrations.loading': 'در حال بارگذاری ثبت‌نام‌ها...',
'workshop.registrations.empty': 'هنوز ثبت‌نامی وجود ندارد.',
'workshop.registrations.errorLoad': 'بارگذاری ثبت‌نام‌ها ممکن نشد.',
'workshop.registrations.add': 'افزودن مشتری',
'workshop.registrations.searchLabel': 'مشتری',
'workshop.registrations.searchPlaceholder': 'جستجو با نام، موبایل یا ایمیل',
'workshop.registrations.searchHint': 'فقط مشتریان فعال این کسب‌وکار قابل افزودن هستند.',
'workshop.registrations.errorAdd': 'افزودن ثبت‌نام ممکن نشد.',
'workshop.registrations.toast.added': 'مشتری به کارگاه اضافه شد.',
'workshop.registrations.col.name': 'نام',
'workshop.registrations.col.cell': 'موبایل',
'workshop.registrations.col.email': 'ایمیل',
'workshop.registrations.col.date': 'تاریخ ثبت‌نام',
'workshop.registrations.col.status': 'وضعیت',
'workshop.registrations.status.pending': 'در انتظار',
'workshop.registrations.status.approved': 'تأیید شده',
'workshop.registrations.status.rejected': 'رد شده',
'workshop.registrations.remove': 'حذف ثبت‌نام',
'workshop.registrations.errorStatus': 'به‌روزرسانی وضعیت ثبت‌نام ممکن نشد.',
'workshop.registrations.errorRemove': 'حذف ثبت‌نام ممکن نشد.',
'workshop.registrations.toast.statusUpdated': 'وضعیت ثبت‌نام به‌روزرسانی شد.',
'workshop.registrations.toast.removed': 'ثبت‌نام حذف شد.',
'portfolio.page.subtitle': 'نمونه کارها را مدیریت کنید و پروژه‌ها را به نمایش بگذارید.',
'portfolio.card.list.desc': 'همه نمونه کارها را مشاهده، ویرایش و مدیریت کنید.',
'portfolio.card.list.link': 'مشاهده نمونه کارها',
@@ -3260,6 +3524,8 @@ const fa: Record<MessageKey, string> = {
'portfolio.list.subtitle': '{count} مورد · مشاهده، ویرایش و مدیریت محتوای نمونه کار.',
'portfolio.list.loading': 'در حال بارگذاری نمونه کارها...',
'portfolio.list.empty': 'هنوز نمونه کاری ثبت نکرده‌اید.',
'portfolio.list.emptyNone': 'هیچ نمونه کاری',
'portfolio.list.emptyAction': 'ثبت نمونه کار',
'portfolio.list.emptyCta': 'افزودن نمونه کار',
'portfolio.list.errorLoad': 'بارگذاری نمونه کارها ممکن نشد.',
'portfolio.list.errorDelete': 'حذف نمونه کار ممکن نشد.',
@@ -3574,23 +3840,28 @@ const fa: Record<MessageKey, string> = {
'website.specialItems.addItemsTo': 'افزودن محصولات فروشگاه به {title}',
'website.specialItems.removeFromSpecial': 'حذف از ویژه',
'website.specialItems.pickSubtitle': 'محصولات فروشگاه را برای نمایش در این دسته انتخاب کنید.',
'website.specialItems.pickSubtitleProducts':
'محصولات را برای نمایش در این دسته انتخاب کنید.',
'website.specialItems.pickLoading': 'در حال بارگذاری محصولات فروشگاه...',
'website.specialItems.pickLoadingProducts': 'در حال بارگذاری محصولات...',
'website.specialItems.pickEmpty': 'همه محصولات فروشگاه قبلاً در این دسته هستند.',
'website.specialItems.pickEmptyProducts': 'همه محصولات قبلاً در این دسته هستند.',
'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی محصولات فروشگاه تایپ کنید',
'website.specialItems.pickSearchProducts': 'حداقل ۳ حرف برای جستجوی محصولات تایپ کنید',
'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
'website.specialItems.pickErrorLoadProducts': 'بارگذاری محصولات ممکن نشد.',
'website.settings.title': 'تنظیمات وب‌سایت',
'website.settings.subtitle': 'نحوه منبع‌گیری و نمایش محتوای وب‌سایت را پیکربندی کنید.',
'website.settings.section': 'محصولات ویژه',
'website.settings.loading': 'در حال بارگذاری تنظیمات...',
'website.settings.errorLoad': 'بارگذاری تنظیمات ممکن نشد.',
'website.settings.errorSave': 'ذخیره تنظیمات ممکن نشد.',
'website.settings.saved': 'تنظیمات ذخیره شد.',
'website.settings.specialSource': 'خواندن محصولات ویژه از',
'website.settings.specialSource': 'سورس محصولات ویژه',
'website.settings.specialSourceDesc':
'انتخاب کنید محصولات ویژه وب‌سایت از کاتالوگ محصول خوانده شوند یا از محصولات فروشگاه. اگر ماژول فروشگاه فعال باشد، پیش‌فرض محصول فروشگاه است.',
'website.settings.source.product': 'محصول',
'website.settings.source.storeItem': 'محصول فروشگاه',
'مشخص کنید محصولات ویژه ی شما از کدام بخش خوانده شود: محصولاتی که برای فروش به فروشگاه اضافه شده است و یا خود محصولات (بدون قیمت و موجودی)',
'website.settings.source.product': 'محصولات',
'website.settings.source.storeItem': 'فروشگاه',
'website.pick.addOneItem': 'افزودن ۱ قلم',
'website.pick.addItems': 'افزودن {count} قلم',
@@ -3706,6 +3977,11 @@ const fa: Record<MessageKey, string> = {
'title.addInstruction': 'افزودن آموزش',
'title.editInstruction': 'ویرایش آموزش',
'title.instructionDetails': 'جزئیات آموزش',
'title.workshops': 'کارگاه‌ها',
'title.myWorkshops': 'کارگاه‌های من',
'title.addWorkshop': 'افزودن کارگاه',
'title.editWorkshop': 'ویرایش کارگاه',
'title.workshopDetails': 'جزئیات کارگاه',
'title.myVideos': 'ویدیوهای من',
'title.addVideo': 'افزودن ویدیو',
'title.editVideo': 'ویرایش ویدیو',
@@ -4077,6 +4353,11 @@ const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
'Add New Video': 'nav.videos.new',
'Edit Video': 'bc.editVideo',
'Video Details': 'bc.videoDetails',
Workshops: 'nav.workshops',
'My Workshops': 'nav.workshops.list',
'Add New Workshop': 'title.addWorkshop',
'Edit Workshop': 'title.editWorkshop',
'Workshop Details': 'title.workshopDetails',
Website: 'nav.website',
'Static Images': 'nav.website.staticImages',
Sliders: 'nav.website.sliders',
@@ -4179,6 +4460,12 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/instructions/categories', labels: [t('title.content'), t('title.instructions'), t('title.categories')] },
{ match: '/instructions/settings', labels: [t('title.content'), t('title.instructions'), t('title.settings')] },
{ match: '/instructions', labels: [t('title.content'), t('title.instructions')] },
{ match: '/workshops/list', labels: [t('title.content'), t('title.workshops'), t('title.myWorkshops')] },
{ match: /^\/workshops\/detail\/[^/]+$/, labels: [t('title.content'), t('title.workshops'), t('title.workshopDetails')] },
{ match: '/workshops/new', labels: [t('title.content'), t('title.workshops'), t('title.addWorkshop')] },
{ match: /^\/workshops\/edit\/[^/]+$/, labels: [t('title.content'), t('title.workshops'), t('title.editWorkshop')] },
{ match: '/workshops/categories', labels: [t('title.content'), t('title.workshops'), t('title.categories')] },
{ match: '/workshops', labels: [t('title.content'), t('title.workshops')] },
{ match: '/portfolios/list', labels: [t('title.portfolios'), t('title.myPortfolios')] },
{
match: /^\/portfolios\/detail\/[^/]+$/,
+3
View File
@@ -38,6 +38,9 @@
--blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px;
--toast-z-index: 11000;
--toast-bg: rgba(255, 255, 255, 0.94);
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
+10 -10
View File
@@ -319,17 +319,17 @@ export function AddNewPortfolioPage() {
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label>{t('portfolio.form.mainText')}</label>
<RichTextEditor
value={mainTextHtml}
onChange={setMainTextHtml}
placeholder={t('portfolio.form.mainTextPlaceholder')}
allowImages
editorMinHeight={280}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('portfolio.form.mainText')}</label>
<RichTextEditor
value={mainTextHtml}
onChange={setMainTextHtml}
placeholder={t('portfolio.form.mainTextPlaceholder')}
allowImages
editorMinHeight={280}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
@@ -41,8 +41,21 @@
min-width: 0;
}
.scheduleRow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 16px;
min-width: 0;
}
.thumbnailField {
align-self: start;
align-items: center;
}
.thumbnailField label {
width: 100%;
text-align: center;
}
.topFields {
@@ -0,0 +1,380 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { JalaliDatePicker } from '../components/JalaliDatePicker'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
listWorkshopCategories,
mapWorkshopCategoryToUi,
} from '../services/workshopCategoryService'
import {
resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds,
resolveHtmlEmbeddedDataUrls,
} from '../services/mediaService'
import {
createWorkshop,
getWorkshop,
mapWorkshopApiToFormState,
updateWorkshop,
} from '../services/workshopService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
export function AddNewWorkshopPage() {
const { id } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [abstract, setAbstract] = useState('')
const [descriptionHtml, setDescriptionHtml] = useState('')
const [coverImage, setCoverImage] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [eventDate, setEventDate] = useState('')
const [eventTime, setEventTime] = useState('')
const [duration, setDuration] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
const [isLoadingWorkshop, setIsLoadingWorkshop] = useState(isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
async function loadCategories() {
try {
const items = await listWorkshopCategories(controller.signal)
setCategories(items.map(mapWorkshopCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.categories.errorLoad'))
}
}
}
void loadCategories()
return () => controller.abort()
}, [])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadWorkshop() {
setIsLoadingWorkshop(true)
setError('')
try {
const workshop = await getWorkshop(id!, controller.signal)
const form = mapWorkshopApiToFormState(workshop)
setCategoryId(form.categoryId)
setTitleFa(form.titleFa)
setTitleEn(form.titleEn)
setAbstract(form.abstract)
setDescriptionHtml(form.descriptionHtml)
setCoverImage(form.coverImage)
setImages(form.images)
setTags(form.tags)
setEventDate(form.eventDate)
setEventTime(form.eventTime)
setDuration(form.duration)
setFeaturedMediaId(form.featuredMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
} catch (err) {
if (isAbortError(err)) return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.details.errorLoad'))
}
} finally {
setIsLoadingWorkshop(false)
}
}
void loadWorkshop()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) {
return (
<main className={pageStyles.content}>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : (
<p className={pageStyles.pageSubtitle}>
{isLoadingWorkshop ? t('workshop.list.loading') : t('workshop.details.errorLoad')}
</p>
)}
</main>
)
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
try {
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
coverImage,
'workshop-thumbnail.jpg',
featuredMediaId,
)
const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml)
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(images, galleryMediaIds)
if (coverImage?.startsWith('data:') && !nextFeaturedMediaId) {
setError(t('workshop.form.errorThumbnailUpload'))
setIsSubmitting(false)
return
}
const payload = {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
abstract: abstract.trim(),
descriptionHtml: resolvedDescriptionHtml,
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
eventDate: eventDate.trim() || null,
eventTime: eventTime.trim() || null,
duration: duration.trim() || null,
}
if (isEdit && id) {
await updateWorkshop(id, {
...payload,
categoryId: categoryId || null,
featuredMediaId: nextFeaturedMediaId,
})
showToast(t('workshop.form.toast.updated'), 'success')
} else {
await createWorkshop({ ...payload, status: 'draft' })
showToast(t('workshop.form.toast.created'), 'success')
}
navigate('/workshops/list')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError(t('workshop.form.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Workshops', href: '/workshops' },
{ label: 'My Workshops', href: '/workshops/list' },
{ label: 'Edit Workshop' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Workshops', href: '/workshops' },
{ label: 'Add New Workshop' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('title.editWorkshop') : t('title.addWorkshop')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit ? t('workshop.form.edit.subtitle') : t('workshop.form.add.subtitle')}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
<label>{t('workshop.form.thumbnail')}</label>
<ImageCropper
value={coverImage}
onChange={setCoverImage}
aspect={1}
uploadLabel={t('workshop.form.uploadThumbnail')}
hint={t('workshop.form.cropHint')}
changeLabel={t('workshop.form.changeThumbnail')}
/>
</div>
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('workshop.form.category')}</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder={t('workshop.form.categoryPlaceholder')}
/>
</div>
<div className={styles.titleRow}>
<div className={styles.field}>
<label htmlFor="workshop-title-fa">{t('workshop.form.titleFa')}</label>
<input
id="workshop-title-fa"
name="titleFa"
type="text"
dir="rtl"
lang="fa"
className="faText"
placeholder={t('workshop.form.titleFaPlaceholder')}
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="workshop-title-en">{t('workshop.form.titleEn')}</label>
<input
id="workshop-title-en"
name="titleEn"
type="text"
dir="ltr"
placeholder={t('workshop.form.titleEnPlaceholder')}
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="workshop-abstract">{t('workshop.form.abstract')}</label>
<textarea
id="workshop-abstract"
name="abstract"
rows={4}
placeholder={t('workshop.form.abstractPlaceholder')}
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.scheduleRow}>
<div className={styles.field}>
<label htmlFor="workshop-event-date">{t('workshop.form.eventDate')}</label>
<JalaliDatePicker
id="workshop-event-date"
value={eventDate}
onChange={setEventDate}
disabled={isSubmitting}
placeholder={t('workshop.form.eventDatePlaceholder')}
/>
</div>
<div className={styles.field}>
<label htmlFor="workshop-event-time">{t('workshop.form.eventTime')}</label>
<input
id="workshop-event-time"
name="eventTime"
type="time"
value={eventTime}
onChange={(e) => setEventTime(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="workshop-duration">{t('workshop.form.duration')}</label>
<input
id="workshop-duration"
name="duration"
type="text"
placeholder={t('workshop.form.durationPlaceholder')}
value={duration}
onChange={(e) => setDuration(e.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('workshop.form.description')}</label>
<RichTextEditor
value={descriptionHtml}
onChange={setDescriptionHtml}
placeholder={t('workshop.form.descriptionPlaceholder')}
allowImages
editorMinHeight={280}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('workshop.form.gallery')}</label>
<ImageUploader images={images} onChange={setImages} />
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('workshop.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
</div>
<div className={styles.actions}>
<Link to="/workshops/list" className={styles.cancelBtn}>
{t('workshop.form.cancel')}
</Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('workshop.form.saving')
: isEdit
? t('workshop.form.update')
: t('workshop.form.save')}
</button>
</div>
</form>
</main>
)
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FileText, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { BlogCard } from '../components/BlogCard'
import { BlogCommentsModal } from '../components/BlogCommentsModal'
@@ -182,9 +182,9 @@ export function BlogListPage() {
{isLoading ? (
<p className={styles.empty}>{t('blog.list.loading')}</p>
) : blogs.length === 0 ? (
<EmptyState
<ListEmptyState
entity="blog"
icon={FileText}
message={t('blog.list.empty')}
actionLabel={t('blog.list.emptyCta')}
actionTo="/blog/new"
/>
+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 { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
@@ -14,7 +14,7 @@ const contentSections: {
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
href: string
moduleId: 'blog' | 'videos' | 'instructions'
moduleId: 'blog' | 'videos' | 'instructions' | 'workshops'
}[] = [
{
icon: FileText,
@@ -32,6 +32,14 @@ const contentSections: {
href: '/instructions',
moduleId: 'instructions',
},
{
icon: Presentation,
titleKey: 'nav.workshops',
descKey: 'content.card.workshops.desc',
linkKey: 'content.card.workshops.link',
href: '/workshops',
moduleId: 'workshops',
},
]
export function ContentPage() {
+9 -4
View File
@@ -21,6 +21,7 @@ import { listBlogs } from '../services/blogService'
import { listPortfolios } from '../services/portfolioService'
import { listVideos } from '../services/videoService'
import { listInstructions } from '../services/instructionService'
import { listWorkshops } from '../services/workshopService'
import { useTenantBranding } from '../context/TenantBrandingContext'
import {
hasBusinessModule,
@@ -93,7 +94,7 @@ const sections: {
countLabelKey: 'home.card.content.count',
href: '/content',
countKey: 'content',
anyModuleIds: ['blog', 'videos', 'instructions'],
anyModuleIds: ['blog', 'videos', 'instructions', 'workshops'],
},
{
icon: Briefcase,
@@ -110,7 +111,8 @@ const sections: {
type SectionCounts = Partial<Record<CountKey, number>>
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
const [products, store, customers, blog, portfolios, videos, instructions] = await Promise.all([
const [products, store, customers, blog, portfolios, videos, instructions, workshops] =
await Promise.all([
listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
listStoreItems(1, 1, signal).then((r) => r.total).catch(() => null),
listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
@@ -118,6 +120,7 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
listVideos(1, 1, signal).then((r) => r.total).catch(() => null),
listInstructions(1, 1, signal).then((r) => r.total).catch(() => null),
listWorkshops(1, 1, signal).then((r) => r.total).catch(() => null),
])
const counts: SectionCounts = {}
@@ -125,8 +128,10 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
if (store !== null) counts.store = store
if (customers !== null) counts.customers = customers
if (portfolios !== null) counts.portfolios = portfolios
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0)
if (blog !== null || videos !== null || instructions !== null) counts.content = contentTotal
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0) + (workshops ?? 0)
if (blog !== null || videos !== null || instructions !== null || workshops !== null) {
counts.content = contentTotal
}
return counts
}
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Play, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { InstructionCard } from '../components/InstructionCard'
import { InstructionCommentsModal } from '../components/InstructionCommentsModal'
@@ -159,9 +159,9 @@ export function InstructionListPage() {
{isLoading ? (
<p className={styles.empty}>{t('instruction.list.loading')}</p>
) : videos.length === 0 ? (
<EmptyState
<ListEmptyState
entity="instruction"
icon={Play}
message={t('instruction.list.empty')}
actionLabel={t('instruction.list.emptyCta')}
actionTo="/instructions/new"
/>
+2 -2
View File
@@ -71,10 +71,10 @@
}
.businessName {
font-size: 20px;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
line-height: 1.3;
}
.appName {
+11 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
import { EmptyState } from '../components/EmptyState'
import { EmptyState, ListEmptyState } from '../components/EmptyState'
import { ProductCard } from '../components/ProductCard'
import { AddProductByAiModal } from '../components/AddProductByAiModal'
import { Pagination } from '../components/Pagination'
@@ -258,12 +258,16 @@ export function MyProductsPage() {
{isLoading ? (
<p className={styles.empty}>{t('products.list.loading')}</p>
) : products.length === 0 ? (
<EmptyState
icon={Package}
message={appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
actionLabel={appliedName ? undefined : t('products.list.emptyCta')}
actionTo={appliedName ? undefined : '/products/new'}
/>
appliedName ? (
<EmptyState message={t('products.list.emptyFiltered')} />
) : (
<ListEmptyState
entity="products"
icon={Package}
actionLabel={t('products.list.emptyCta')}
actionTo="/products/new"
/>
)
) : (
<>
<div className={styles.grid}>
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Briefcase, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { PortfolioCard } from '../components/PortfolioCard'
import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
@@ -190,9 +190,9 @@ export function PortfolioListPage() {
{isLoading ? (
<p className={styles.empty}>{t('portfolio.list.loading')}</p>
) : portfolios.length === 0 ? (
<EmptyState
<ListEmptyState
entity="portfolio"
icon={Briefcase}
message={t('portfolio.list.empty')}
actionLabel={t('portfolio.list.emptyCta')}
actionTo="/portfolios/new"
/>
+60 -8
View File
@@ -22,6 +22,11 @@ import {
listStoreSpecials,
updateStoreSpecial,
} from '../services/storeSpecialService'
import {
getSettings,
type SpecialProductsSource,
} from '../services/settingsService'
import { hasBusinessModule, type BusinessModuleId } from '../utils/businessModules'
import type { StoreItem } from '../services/storeItemService'
import type { StoreSpecial } from '../types/storeSpecial'
import type { StoreProductListing } from '../utils/storeProductGroups'
@@ -29,11 +34,23 @@ import pageStyles from '../components/PageContent.module.css'
import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css'
function defaultSpecialProductsSource(
enabledModules: string[] | undefined,
): SpecialProductsSource {
return hasBusinessModule(
enabledModules as BusinessModuleId[] | undefined,
'store',
)
? 'store_item'
: 'product'
}
export function StoreSpecialsPage() {
const t = useT()
const { itemCount, hasItems, addVariant } = useDraftCart()
const { showToast } = useToast()
const [specials, setSpecials] = useState<StoreSpecial[]>([])
const [specialSource, setSpecialSource] = useState<SpecialProductsSource>('store_item')
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
@@ -61,10 +78,36 @@ export function StoreSpecialsPage() {
useEffect(() => {
const controller = new AbortController()
void loadSpecials(controller.signal)
void loadPage(controller.signal)
return () => controller.abort()
}, [])
async function loadPage(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const [specialsData, settingsData] = await Promise.all([
listStoreSpecials(1, 50, signal),
getSettings(signal),
])
setSpecials(specialsData.items)
setSpecialSource(
settingsData.settings.website?.specialProductsSource ??
defaultSpecialProductsSource(settingsData.settings.modules?.enabled),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.specialItems.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function loadSpecials(signal?: AbortSignal) {
setIsLoading(true)
setError('')
@@ -212,19 +255,23 @@ export function StoreSpecialsPage() {
}
}
async function handleAddItems(storeItemIds: string[]) {
async function handleAddItems(ids: string[]) {
if (!pickItemsTarget) return
const existingIds = pickItemsTarget.items.map((item) => item.id)
const nextIds = [...existingIds, ...storeItemIds]
const fromProducts = specialSource === 'product'
const existingIds = pickItemsTarget.items.map((item) =>
fromProducts ? item.productId : item.id,
)
const nextIds = [...existingIds, ...ids]
setIsSaving(true)
setError('')
try {
const result = await updateStoreSpecial(pickItemsTarget.id, {
storeItemIds: nextIds,
})
const result = await updateStoreSpecial(
pickItemsTarget.id,
fromProducts ? { productIds: nextIds } : { storeItemIds: nextIds },
)
replaceSpecial(result.special)
setPickItemsTarget(null)
showToast(t('website.specialItems.toastAdded'), 'success')
@@ -402,7 +449,12 @@ export function StoreSpecialsPage() {
<PickStoreSpecialItemsModal
open={!!pickItemsTarget}
specialTitle={pickItemsTarget?.title ?? ''}
existingStoreItemIds={pickItemsTarget?.items.map((item) => item.id) ?? []}
source={specialSource}
existingIds={
pickItemsTarget?.items.map((item) =>
specialSource === 'product' ? item.productId : item.id,
) ?? []
}
onClose={() => !isSaving && setPickItemsTarget(null)}
onConfirm={handleAddItems}
isSubmitting={isSaving}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Play, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { VideoCard } from '../components/VideoCard'
import { VideoCommentsModal } from '../components/VideoCommentsModal'
@@ -159,9 +159,9 @@ export function VideoListPage() {
{isLoading ? (
<p className={styles.empty}>{t('video.list.loading')}</p>
) : videos.length === 0 ? (
<EmptyState
<ListEmptyState
entity="video"
icon={Play}
message={t('video.list.empty')}
actionLabel={t('video.list.emptyCta')}
actionTo="/videos/new"
/>
@@ -14,7 +14,7 @@ import pageStyles from '../components/PageContent.module.css'
import sharedStyles from './ProductSettingsPage.module.css'
import styles from './WebsiteSettingsPage.module.css'
const SOURCE_OPTIONS: SpecialProductsSource[] = ['product', 'store_item']
const SOURCE_OPTIONS: SpecialProductsSource[] = ['store_item', 'product']
function defaultSpecialProductsSource(
enabledModules: string[] | undefined,
@@ -113,8 +113,6 @@ export function WebsiteSettingsPage() {
)}
<section className={sharedStyles.panel}>
<h3 className={sharedStyles.sectionTitle}>{t('website.settings.section')}</h3>
{isLoading ? (
<p className={sharedStyles.status}>{t('website.settings.loading')}</p>
) : (
@@ -0,0 +1,245 @@
import { useEffect, useState } from 'react'
import { FolderTree, Plus } from 'lucide-react'
import { CategoryModal } from '../components/CategoryModal'
import { WorkshopCategoryTree } from '../components/WorkshopCategoryTree'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import {
createWorkshopCategory,
deleteWorkshopCategory,
listWorkshopCategories,
mapWorkshopCategoryToUi,
toWorkshopCategoryFormPayload,
updateWorkshopCategory,
} from '../services/workshopCategoryService'
import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css'
export function WorkshopCategoriesPage() {
const t = useT()
const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('')
const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listWorkshopCategories(signal)
setCategories(items.map(mapWorkshopCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.categories.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function openCreateModal(parentId = '', title?: string) {
setEditingCategory(null)
setDefaultParentId(parentId)
setModalTitle(title ?? t('categories.add'))
setModalOpen(true)
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId))
}
}
function openEditModal(category: Category) {
setEditingCategory(category)
setDefaultParentId(category.parentId ?? '')
setModalTitle(t('categories.edit'))
setModalOpen(true)
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleSubmit(data: CategoryFormData) {
setIsSubmitting(true)
setError('')
try {
const payload = toWorkshopCategoryFormPayload(data)
if (editingCategory) {
const result = await updateWorkshopCategory(editingCategory.id, {
...payload,
parentId: data.parentId || null,
})
setCategories((prev) =>
prev.map((category) =>
category.id === editingCategory.id
? mapWorkshopCategoryToUi(result.category)
: category,
),
)
} else {
const result = await createWorkshopCategory(payload)
setCategories((prev) => [...prev, mapWorkshopCategoryToUi(result.category)])
if (data.parentId) {
setExpandedIds((prev) => new Set(prev).add(data.parentId))
}
}
setModalOpen(false)
setEditingCategory(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.categories.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
const result = await deleteWorkshopCategory(deleteTarget.id)
const removed = new Set(result.deletedIds)
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
setExpandedIds((prev) => {
const next = new Set(prev)
removed.forEach((id) => next.delete(id))
return next
})
setDeleteTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.categories.errorDelete'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Workshops', href: '/workshops' },
{ label: 'Categories' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="WORKSHOP CATEGORIES">{t('workshop.categories.title')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('workshop.categories.subtitle')}</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{isLoading ? (
<div className={styles.list}>
<p className={styles.empty}>{t('categories.loading')}</p>
</div>
) : categories.length === 0 ? (
<EmptyState
icon={FolderTree}
message={t('workshop.categories.empty')}
actionLabel={t('categories.add')}
onAction={() => openCreateModal()}
/>
) : (
<div className={styles.list}>
<WorkshopCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
}}
/>
</div>
)}
<CategoryModal
open={modalOpen}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingCategory(null)
}
}}
title={modalTitle}
defaultParentId={defaultParentId}
categories={categories}
editingCategory={editingCategory}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('categories.deleteTitle')}
message={
deleteTarget
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
<div className={styles.fabDock}>
<button
type="button"
className={styles.addFab}
onClick={() => openCreateModal()}
aria-label={t('categories.add')}
>
<Plus size={24} />
</button>
</div>
</main>
)
}
@@ -0,0 +1,211 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { CalendarDays } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { formatWorkshopDate, formatWorkshopDuration, getWorkshopDetail } from '../services/workshopService'
import type { WorkshopDetail } from '../types/workshop'
import pageStyles from '../components/PageContent.module.css'
import styles from './PortfolioDetailsPage.module.css'
export function WorkshopDetailsPage() {
const t = useT()
const { locale } = useLocale()
const { id } = useParams()
const [workshop, setWorkshop] = useState<WorkshopDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function loadWorkshop() {
setIsLoading(true)
setError('')
try {
const data = await getWorkshopDetail(id!, controller.signal)
setWorkshop(data)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.details.errorLoad'))
}
setWorkshop(null)
} finally {
setIsLoading(false)
}
}
void loadWorkshop()
return () => controller.abort()
}, [id])
const galleryImages = useMemo(
() => workshop?.gallery.map((item) => item.url).filter(Boolean) ?? [],
[workshop],
)
function openLightbox(index: number) {
setLightboxIndex(index)
setLightboxOpen(true)
}
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('workshop.details.loading')}</p>
</main>
)
}
if (error || !workshop || !id) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || t('workshop.details.notFound')}</p>
<Link to="/workshops/list" className={styles.backLink}>
{t('workshop.details.back')}
</Link>
</main>
)
}
const displayDate = formatWorkshopDate(
workshop.publishedAt ?? workshop.createdAt,
locale === 'fa' ? 'fa' : 'en',
)
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const hasSchedule = Boolean(workshop.eventDate || workshop.eventTime || workshop.duration)
return (
<>
<div className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Workshops', href: '/workshops' },
{ label: 'My Workshops', href: '/workshops/list' },
{ label: workshop.title },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{workshop.title}</h2>
{workshop.abstract && (
<p className={pageStyles.pageSubtitleContent}>{workshop.abstract}</p>
)}
</div>
</div>
<article className={styles.portfolioDetail}>
<div
className={`${styles.heroImageWrap} ${
workshop.coverImageUrl ? '' : styles.heroImageWrapEmpty
}`}
>
{workshop.coverImageUrl ? (
<img
src={workshop.coverImageUrl}
alt={workshop.title}
className={styles.heroImage}
/>
) : (
<div className={styles.heroPlaceholder} aria-hidden="true" />
)}
</div>
<div className={styles.metaRow}>
{hasSchedule && (
<>
<span className={styles.metaItem}>
<CalendarDays size={15} />
{[
workshop.eventDate
? formatWorkshopDate(workshop.eventDate, dateLocale)
: null,
workshop.eventTime || null,
workshop.duration ? formatWorkshopDuration(workshop.duration) : null,
]
.filter(Boolean)
.join(' · ')}
</span>
<span className={styles.metaDot}>·</span>
</>
)}
<span className={styles.metaItem}>
<CalendarDays size={15} />
{displayDate}
</span>
{(workshop.categoryName || workshop.tags.length > 0) && (
<>
<span className={styles.metaDot}>·</span>
<div className={styles.metaChips}>
{workshop.categoryName && (
<span className={styles.categoryChip}>{workshop.categoryName}</span>
)}
{workshop.tags.map((tag) => (
<span key={tag} className={styles.tagChip}>
{tag}
</span>
))}
</div>
</>
)}
</div>
<div className={styles.contentPanel}>
{workshop.descriptionHtml ? (
<div
className={styles.content}
dangerouslySetInnerHTML={{ __html: workshop.descriptionHtml }}
/>
) : (
<p className={styles.content}>{t('workshop.details.noContent')}</p>
)}
</div>
</article>
</div>
{galleryImages.length > 0 && (
<section className={styles.galleryBand} aria-label={t('workshop.details.gallery')}>
<div className={styles.galleryInner}>
<h3 className={styles.galleryTitle}>{t('workshop.details.gallery')}</h3>
<div className={styles.galleryGrid}>
{galleryImages.map((src, index) => (
<button
key={`${src}-${index}`}
type="button"
className={styles.galleryItem}
onClick={() => openLightbox(index)}
aria-label={t('workshop.details.galleryImage', { index: index + 1 })}
>
<img src={src} alt={`${workshop.title} gallery ${index + 1}`} loading="lazy" />
</button>
))}
</div>
</div>
</section>
)}
{galleryImages.length > 0 && (
<ImageLightbox
open={lightboxOpen}
images={galleryImages}
initialIndex={lightboxIndex}
alt={workshop.title}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
)
}
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Presentation, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ListEmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { WorkshopCard } from '../components/WorkshopCard'
import { WorkshopRegistrationsModal } from '../components/WorkshopRegistrationsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import {
WORKSHOPS_PER_PAGE,
deleteWorkshop,
isWorkshopPublished,
listWorkshops,
mapWorkshopApiToUi,
resolveWorkshopTitle,
setWorkshopPublished,
} from '../services/workshopService'
import type { Workshop } from '../types/workshop'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogPage.module.css'
export function WorkshopListPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const { locale } = useLocale()
const [workshops, setWorkshops] = useState<Workshop[]>([])
const [totalWorkshops, setTotalWorkshops] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [registrationCounts, setRegistrationCounts] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [registrationsTarget, setRegistrationsTarget] = useState<{
id: string
title: string
} | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalWorkshops / WORKSHOPS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadWorkshops(currentPage, controller.signal)
return () => controller.abort()
}, [currentPage])
async function loadWorkshops(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listWorkshops(page, WORKSHOPS_PER_PAGE, signal)
setWorkshops(data.items)
setTotalWorkshops(data.total)
setRegistrationCounts(
Object.fromEntries(
data.items.map((workshop) => [workshop.id, workshop.registrationCount ?? 0]),
),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.list.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function handleEdit(id: string) {
navigate(`/workshops/edit/${id}`)
}
function handleRegistrations(id: string) {
const workshop = workshops.find((item) => item.id === id)
if (workshop) {
setRegistrationsTarget({
id,
title: resolveWorkshopTitle(workshop, locale === 'fa' ? 'fa' : 'en'),
})
}
}
function handleRemoveRequest(id: string) {
const workshop = workshops.find((item) => item.id === id)
if (workshop) {
setDeleteTarget({
id,
title: resolveWorkshopTitle(workshop, locale === 'fa' ? 'fa' : 'en'),
})
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deleteWorkshop(deleteTarget.id)
showToast(t('workshop.list.toast.removed'), 'success')
const nextTotal = totalWorkshops - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / WORKSHOPS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setDeleteTarget(null)
setCurrentPage(nextPage)
await loadWorkshops(nextPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.list.errorDelete'))
}
} finally {
setIsDeleting(false)
}
}
function handlePageChange(page: number) {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function handleTogglePublished(id: string) {
const workshop = workshops.find((item) => item.id === id)
if (!workshop) return
const nextPublished = !isWorkshopPublished(workshop)
setTogglingId(id)
setError('')
try {
const result = await setWorkshopPublished(id, nextPublished)
setWorkshops((prev) =>
prev.map((item) => (item.id === id ? mapWorkshopApiToUi(result.workshop) : item)),
)
showToast(
nextPublished ? t('workshop.list.toast.published') : t('workshop.list.toast.unpublished'),
'success',
)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('workshop.list.errorStatus'))
}
} finally {
setTogglingId(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Workshops', href: '/workshops' },
{ label: 'My Workshops' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="MY WORKSHOPS">{t('title.myWorkshops')}</PageTitle>
<p className={pageStyles.pageSubtitle}>
{t('workshop.list.subtitle', { count: totalWorkshops })}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{isLoading ? (
<p className={styles.empty}>{t('workshop.list.loading')}</p>
) : workshops.length === 0 ? (
<ListEmptyState
entity="workshop"
icon={Presentation}
actionLabel={t('workshop.list.emptyCta')}
actionTo="/workshops/new"
/>
) : (
<>
<div className={pageStyles.gridCols5}>
{workshops.map((workshop) => (
<WorkshopCard
key={workshop.id}
workshop={workshop}
registrationCount={registrationCounts[workshop.id] ?? workshop.registrationCount}
onEdit={handleEdit}
onRegistrations={handleRegistrations}
onTogglePublished={handleTogglePublished}
onRemove={handleRemoveRequest}
isTogglingStatus={togglingId === workshop.id}
/>
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
)}
{!isLoading && workshops.length > 0 && (
<button
type="button"
className={styles.addFab}
onClick={() => navigate('/workshops/new')}
aria-label={t('workshop.list.addNew')}
>
<Plus size={26} strokeWidth={2.5} />
</button>
)}
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('workshop.list.deleteTitle')}
message={
deleteTarget
? t('workshop.list.deleteMessage', { name: deleteTarget.title })
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
{registrationsTarget && (
<WorkshopRegistrationsModal
open={!!registrationsTarget}
workshopId={registrationsTarget.id}
workshopTitle={registrationsTarget.title}
onClose={() => setRegistrationsTarget(null)}
onRegistrationAdded={() => {
setRegistrationCounts((current) => ({
...current,
[registrationsTarget.id]:
(current[registrationsTarget.id] ?? 0) + 1,
}))
}}
onRegistrationRemoved={() => {
setRegistrationCounts((current) => ({
...current,
[registrationsTarget.id]: Math.max(
0,
(current[registrationsTarget.id] ?? 0) - 1,
),
}))
}}
/>
)}
</main>
)
}
+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
title: string
storeItemIds?: string[]
productIds?: string[]
sortOrder?: number
isActive?: boolean
}
@@ -21,6 +22,7 @@ export interface UpdateStoreSpecialPayload {
key?: string
title?: string
storeItemIds?: string[]
productIds?: string[]
sortOrder?: number
isActive?: boolean
}
@@ -0,0 +1,76 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'workshop' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listWorkshopCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createWorkshopCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updateWorkshopCategory(categoryId: string, payload: UpdateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteWorkshopCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export function toWorkshopCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapWorkshopCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
@@ -0,0 +1,413 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { extractFirstImageUrl } from '../utils/htmlImages'
import type {
Workshop,
WorkshopAuthor,
WorkshopDetail,
WorkshopGalleryItem,
WorkshopStatus,
} from '../types/workshop'
export const WORKSHOPS_PER_PAGE = 12
export interface WorkshopApi {
id: string
businessId: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
descriptionHtml: string
status: WorkshopStatus
categoryId: string | null
categoryName: string
tags: string[]
coverImageUrl: string | null
featuredMediaId: string | null
gallery: WorkshopGalleryItem[]
galleryMediaIds: string[]
registrationCount: number
authorId?: string | null
author?: WorkshopAuthor | null
publishedAt: string | null
createdAt: string
updatedAt: string
eventDate: string | null
eventTime: string | null
duration: string | null
}
export interface WorkshopsListResponse {
items: WorkshopApi[]
total: number
page: number
pageSize: number
}
export interface ListWorkshopsParams {
page?: number
pageSize?: number
status?: WorkshopStatus
categoryId?: string
title?: string
}
export interface WorkshopRegistrationUser {
id: string
firstName: string | null
lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
email: string | null
cellNumber: string | null
}
export type WorkshopRegistrationStatus = 'pending' | 'approved' | 'rejected'
export interface WorkshopRegistration {
id: string
workshopId: string
userId: string
status: WorkshopRegistrationStatus
createdAt: string
user: WorkshopRegistrationUser
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/workshops${suffix}`
}
export interface CreateWorkshopPayload {
titleFa: string
titleEn?: string
abstract?: string
descriptionHtml?: string
categoryId?: string
status?: WorkshopStatus
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
eventDate?: string | null
eventTime?: string | null
duration?: string | null
}
export interface UpdateWorkshopPayload {
titleFa?: string
titleEn?: string | null
abstract?: string | null
descriptionHtml?: string | null
categoryId?: string | null
status?: WorkshopStatus
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
eventDate?: string | null
eventTime?: string | null
duration?: string | null
}
export function resolveWorkshopTitle(
workshop: Pick<Workshop, 'titleFa' | 'titleEn' | 'title'>,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
return workshop.titleFa || workshop.title
}
return workshop.titleEn?.trim() || workshop.titleFa || workshop.title
}
export function resolveWorkshopCoverImageUrl(workshop: WorkshopApi): string | null {
const featured = workshop.coverImageUrl?.trim()
if (featured) return featured
const galleryUrl = workshop.gallery?.map((item) => item.url?.trim()).find(Boolean)
if (galleryUrl) return galleryUrl
return extractFirstImageUrl(workshop.descriptionHtml) || null
}
export function mapWorkshopApiToUi(workshop: WorkshopApi): Workshop {
return {
id: workshop.id,
title: workshop.titleFa || workshop.title,
titleFa: workshop.titleFa || workshop.title,
titleEn: workshop.titleEn ?? null,
slug: workshop.slug,
abstract: workshop.abstract,
status: workshop.status,
categoryId: workshop.categoryId,
categoryName: workshop.categoryName,
tags: workshop.tags,
coverImageUrl: resolveWorkshopCoverImageUrl(workshop),
registrationCount: workshop.registrationCount ?? 0,
author: workshop.author ?? null,
publishedAt: workshop.publishedAt,
createdAt: workshop.createdAt,
updatedAt: workshop.updatedAt,
eventDate: workshop.eventDate ?? null,
eventTime: workshop.eventTime ?? null,
duration: workshop.duration ?? null,
}
}
export function mapWorkshopDetailFromApi(workshop: WorkshopApi): WorkshopDetail {
return {
...mapWorkshopApiToUi(workshop),
descriptionHtml: workshop.descriptionHtml,
gallery: workshop.gallery ?? [],
}
}
export function formatWorkshopAuthor(author: WorkshopAuthor | null): string {
if (!author) return 'Unknown author'
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
return name || author.email || 'Unknown author'
}
export function formatWorkshopRegistrationName(
user: WorkshopRegistrationUser,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
const faName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
if (faName) return faName
}
const enName = [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ').trim()
if (enName) return enName
const fallback = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return fallback || user.email || '—'
}
export function formatWorkshopDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatWorkshopCardDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function formatWorkshopCardMetaDate(
workshop: Pick<Workshop, 'eventDate' | 'publishedAt' | 'createdAt'>,
locale: 'en' | 'fa' = 'en',
): string {
if (workshop.eventDate) {
return formatWorkshopCardDate(workshop.eventDate, locale)
}
return formatWorkshopCardDate(workshop.publishedAt ?? workshop.createdAt, locale)
}
export function isWorkshopPublished(workshop: Pick<Workshop, 'status'>): boolean {
return workshop.status === 'published'
}
export async function setWorkshopPublished(workshopId: string, published: boolean) {
return updateWorkshop(workshopId, { status: published ? 'published' : 'draft' })
}
export function formatWorkshopDuration(
duration: string | null | undefined,
): string {
const trimmed = duration?.trim()
return trimmed || '—'
}
function normalizeGregorianDate(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''
}
function normalizeTimeInput(value: string | null | undefined): string {
if (!value?.trim()) return ''
return value.trim().match(/^(\d{2}:\d{2})/)?.[1] ?? ''
}
function normalizeWorkshopDuration(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return ''
}
function normalizeRichTextHtml(html: string | null | undefined): string {
const trimmed = html?.trim() ?? ''
if (!trimmed) return ''
if (/<[a-z][\s\S]*>/i.test(trimmed)) return trimmed
return `<p>${trimmed
.replace(/&/g, '&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',
'videos',
'instructions',
'workshops',
'finance',
] as const
@@ -35,13 +36,19 @@ export const HOME_CHART_IDS = [
export type HomeChartId = (typeof HOME_CHART_IDS)[number]
/** Modules enabled when a new business is created (super-admin). */
export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
'products',
'blog',
'finance',
]
/**
* Existing tenants without saved modules keep every business-dashboard module
* enabled. Customer modules stay opt-in.
* enabled except opt-in modules. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_DASHBOARD_MODULE_IDS,
]
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
+3
View File
@@ -7,6 +7,9 @@ export default defineConfig({
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom'],
},
optimizeDeps: {
include: ['react-multi-date-picker', 'react-date-object', 'react-element-popper'],
},
server: {
host: true,
port: 5173,
@@ -0,0 +1,6 @@
export {
EmptyState,
EmptyStateEmphasis,
EmptyTableRow,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useT } from '../i18n/useT'
import { EmptyState } from './EmptyState'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice'
@@ -106,7 +107,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div>
{order.items.length === 0 ? (
<p className={styles.empty}>{t('orderItems.empty')}</p>
<EmptyState layout="inline" message={t('orderItems.empty')} />
) : (
<div className={styles.tableBlock}>
<div className={styles.tableHead}>
@@ -2,7 +2,7 @@
position: fixed;
bottom: 24px;
left: 24px;
z-index: 300;
z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: var(--elevated-surface);
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
+2
View File
@@ -27,6 +27,7 @@
--surface: rgba(255, 255, 255, 0.82);
--card-media-bg: #ffffff;
--elevated-surface: rgba(255, 255, 255, 0.96);
--toast-bg: rgba(255, 255, 255, 0.94);
--icon-bg: color-mix(in srgb, var(--primary) 14%, #ffffff);
--icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9);
}
@@ -55,6 +56,7 @@ html[data-theme='dark'] {
--surface: rgba(55, 59, 68, 0.95);
--card-media-bg: #32363f;
--elevated-surface: #3a3e48;
--toast-bg: rgba(58, 62, 72, 0.96);
--card-hover-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
+7 -4
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
import { MapPin, Pencil, Plus, Trash2 } from 'lucide-react'
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
import { AddressFormModal } from '../components/AddressFormModal'
import { EmptyState } from '../components/EmptyState'
import { Tooltip } from '../components/Tooltip'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
@@ -125,10 +126,12 @@ export function AddressesPage() {
{loading && <p className={styles.status}>{t('addresses.loading')}</p>}
{!loading && addresses.length === 0 && (
<div className={styles.empty}>
<MapPin size={28} />
<p>{t('addresses.empty')}</p>
</div>
<EmptyState
icon={MapPin}
message={t('addresses.empty')}
actionLabel={t('addresses.add')}
onAction={openCreateModal}
/>
)}
{!loading && addresses.length > 0 && (
+2 -4
View File
@@ -2,6 +2,7 @@ import { PageTitle } from '../components/PageTitle'
import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { EmptyState } from '../components/EmptyState'
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
@@ -97,10 +98,7 @@ export function FavoritesPage() {
{loading && <p className={styles.status}>{t('favorites.loading')}</p>}
{!loading && data?.items.length === 0 && (
<div className={styles.empty}>
<Heart size={32} />
<p>{t('favorites.empty')}</p>
</div>
<EmptyState icon={Heart} message={t('favorites.empty')} layout="page" />
)}
{!!data?.items.length && (
+2 -2
View File
@@ -71,10 +71,10 @@
}
.domain {
font-size: 20px;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
line-height: 1.3;
}
.appName {
+7 -7
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Package, Plus } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { EmptyState } from '../components/EmptyState'
import { UserProductCard } from '../components/UserProductCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
@@ -141,13 +142,12 @@ export function MyProductsPage() {
{loading ? <p className={styles.status}>{t('myProducts.loading')}</p> : null}
{!loading && !error && products.length === 0 ? (
<div className={styles.empty}>
<Package size={32} />
<p>{t('myProducts.empty')}</p>
<Link to="/my-products/new" className={styles.emptyLink}>
{t('myProducts.add')}
</Link>
</div>
<EmptyState
icon={Package}
message={t('myProducts.empty')}
actionLabel={t('myProducts.add')}
actionTo="/my-products/new"
/>
) : null}
{!loading && products.length > 0 ? (
+2 -5
View File
@@ -1,6 +1,7 @@
import { PageTitle } from '../components/PageTitle'
import { useEffect, useMemo, useState } from 'react'
import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui'
import { EmptyTableRow } from '../components/EmptyState'
import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow'
import { useT } from '../i18n/useT'
@@ -131,11 +132,7 @@ export function OrdersPage() {
)}
{!loading && data?.items.length === 0 && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
{t('orders.empty')}
</td>
</tr>
<EmptyTableRow colSpan={COLUMN_COUNT} message={t('orders.empty')} />
)}
{!loading &&
@@ -0,0 +1,6 @@
export {
EmptyState,
EmptyStateEmphasis,
EmptyTableRow,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
@@ -1,83 +0,0 @@
.wrapper {
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.wrapper:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 3px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(248, 250, 252, 0.8);
}
.toolbar button {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.toolbar button:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.divider {
width: 1px;
height: 16px;
background: rgba(148, 163, 184, 0.3);
margin: 0 4px;
}
.editor {
min-height: 88px;
max-height: 160px;
overflow: auto;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.45;
color: var(--text-primary);
outline: none;
}
.editor:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
.editor ul,
.editor ol {
padding-left: 1.4em;
margin: 0.35em 0;
}
.editor p {
margin: 0 0 0.35em;
}
.editor p:last-child {
margin-bottom: 0;
}
.editor blockquote {
margin: 0.35em 0;
margin-inline-start: 1.4em;
margin-inline-end: 0;
padding: 0;
border: 0;
}
@@ -1,177 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
List,
ListOrdered,
Underline,
} from 'lucide-react'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
/** Compact editor height for short fields like invoice top text. */
editorMinHeight?: number
}
export function RichTextEditor({
value,
onChange,
placeholder = '',
editorMinHeight = 88,
}: RichTextEditorProps) {
const editorRef = useRef<HTMLDivElement>(null)
const syncChange = useCallback(() => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML)
}
}, [onChange])
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value
}
}, [value])
function exec(command: string) {
editorRef.current?.focus()
document.execCommand(command)
syncChange()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
let node: Node | null = selection.anchorNode
while (node && node !== editor) {
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
return node
}
node = node.parentNode
}
return null
}
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (!block) {
document.execCommand(direction > 0 ? 'indent' : 'outdent')
syncChange()
return
}
const current =
Number.parseFloat(block.style.paddingInlineStart) ||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
0
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
if (next === 0) {
block.style.paddingInlineStart = ''
} else {
block.style.paddingInlineStart = `${next}px`
}
syncChange()
}
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
e.preventDefault()
changeIndent(e.shiftKey ? -1 : 1)
}
return (
<div className={styles.wrapper}>
<div className={styles.toolbar}>
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<Bold size={14} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<Italic size={14} />
</button>
<button
type="button"
onClick={() => exec('underline')}
title="Underline"
aria-label="Underline"
>
<Underline size={14} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => exec('insertUnorderedList')}
title="Bullet list"
aria-label="Bullet list"
>
<List size={14} />
</button>
<button
type="button"
onClick={() => exec('insertOrderedList')}
title="Numbered list"
aria-label="Numbered list"
>
<ListOrdered size={14} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={14} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={14} />
</button>
</div>
<div
ref={editorRef}
className={styles.editor}
style={{ minHeight: editorMinHeight }}
contentEditable
role="textbox"
aria-multiline="true"
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning
/>
</div>
)
}
/** True when HTML is empty or only whitespace / empty tags. */
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export {
RichTextEditor,
isEmptyRichText,
richTextToPlain,
type RichTextEditorProps,
} from '@meshkee/dashboard-ui'
@@ -1,12 +1,11 @@
.container {
position: fixed;
bottom: 24px;
right: 24px;
/* Above Modal overlay (z-index 300) so success/error feedback is visible while dialogs are open */
z-index: 400;
left: 24px;
z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: flex-start;
gap: 8px;
pointer-events: none;
}
@@ -20,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
+15 -11
View File
@@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import styles from '../components/Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastContext.Provider value={value}>
{children}
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>
{createPortal(
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>,
document.body,
)}
</ToastContext.Provider>
)
}
+3
View File
@@ -36,6 +36,9 @@
--blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px;
--toast-z-index: 11000;
--toast-bg: rgba(255, 255, 255, 0.94);
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
--select-arrow-size: 16px;
--select-arrow-offset: 12px;
--select-padding-end: 2.5rem;
+15 -16
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EmptyTableRow } from '../components/EmptyState'
import { Modal } from '../components/Modal'
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
@@ -236,14 +237,13 @@ export function SettingsPage() {
</thead>
<tbody>
{!loading && invoiceTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={5}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>No invoice templates yet. Add one to speed up issuing invoices.</span>
</div>
</td>
</tr>
<EmptyTableRow
colSpan={5}
icon={SettingsIcon}
message="No invoice templates yet. Add one to speed up issuing invoices."
actionLabel="Add template"
actionTo="/settings/invoice-templates/new"
/>
) : null}
{invoiceTemplates.map((template) => (
<tr key={template.id}>
@@ -330,14 +330,13 @@ export function SettingsPage() {
</thead>
<tbody>
{!loading && itemTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={6}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>No predefined items yet.</span>
</div>
</td>
</tr>
<EmptyTableRow
colSpan={6}
icon={SettingsIcon}
message="No predefined items yet. Add one to reuse when building invoices."
actionLabel="Add item"
onAction={openCreateItem}
/>
) : null}
{itemTemplates.map((template) => (
<tr key={template.id}>
@@ -7,6 +7,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'warehouse',
'videos',
'instructions',
'workshops',
'finance',
] as const
@@ -35,6 +36,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
warehouse: 'Warehouse',
videos: 'Videos',
instructions: 'Instructions',
workshops: 'Workshops',
finance: 'Finance',
}
@@ -68,11 +70,10 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
/**
* Existing tenants without saved modules keep every business-dashboard module
* enabled. Customer modules stay opt-in.
* enabled except opt-in modules. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_DASHBOARD_MODULE_IDS,
]
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
+31
View File
@@ -19,6 +19,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.2.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
@@ -1275,6 +1276,12 @@
"node": ">=0.10.0"
}
},
"node_modules/react-date-object": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT"
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
@@ -1300,6 +1307,30 @@
"react-dom": ">=16.4.0"
}
},
"node_modules/react-element-popper": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz",
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-multi-date-picker": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
"license": "MIT",
"dependencies": {
"react-date-object": "^2.1.8",
"react-element-popper": "^2.1.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
@@ -38,6 +38,9 @@
--blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px;
--toast-z-index: 11000;
--toast-bg: rgba(255, 255, 255, 0.94);
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
@@ -0,0 +1,130 @@
.layoutPage {
display: flex;
justify-content: center;
width: 100%;
padding: 32px 0 48px;
}
.rootDashed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
width: 100%;
max-width: 880px;
margin-inline: auto;
padding: 0 16px;
text-align: center;
}
.rootDashedInline {
max-width: 100%;
padding: 24px 12px;
}
.messageDashed {
margin: 0;
width: 100%;
max-width: 880px;
font-family: var(--font-ui);
font-size: 15px;
font-weight: 500;
line-height: 1.6;
color: var(--text-secondary);
text-align: center;
white-space: nowrap;
}
.messageOnly {
margin: 0;
width: 100%;
max-width: 880px;
font-family: var(--font-ui);
font-size: 15px;
font-weight: 500;
line-height: 1.85;
color: var(--text-secondary);
text-align: center;
}
.messageEmphasis {
font-weight: 700;
color: var(--text-primary);
}
.addBox {
appearance: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
max-width: 880px;
min-height: 180px;
padding: 32px 28px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 2px dashed rgba(var(--primary-rgb) / 0.38);
border-radius: var(--radius);
color: var(--primary);
font-family: var(--font-ui);
text-decoration: none;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.addBox:hover {
background: rgba(var(--primary-rgb) / 0.08);
border-color: rgba(var(--primary-rgb) / 0.58);
transform: translateY(-3px);
box-shadow: var(--card-hover-shadow);
}
.addBox:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
}
.addIcon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.12);
}
.addLabel {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.cell {
padding: 0 !important;
background: transparent !important;
border-bottom: none !important;
vertical-align: middle;
}
@media (max-width: 1100px) {
.messageDashed {
white-space: normal;
line-height: 1.85;
}
.rootDashed,
.messageDashed,
.messageOnly,
.addBox {
max-width: min(100%, 640px);
}
}
@@ -0,0 +1,129 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Plus } from 'lucide-react'
import styles from './EmptyState.module.css'
export interface EmptyStateProps {
/** Plain-text fallback for accessibility and tables. */
message: string
/** Rich message content; used instead of `message` when provided. */
messageNode?: ReactNode
actionLabel?: string
actionTo?: string
onAction?: () => void
/** Unused visually; kept for call-site compatibility. */
icon?: LucideIcon
/** Page lists use top padding; tables/modals use inline. */
layout?: 'page' | 'inline'
}
export function EmptyStateEmphasis({ children }: { children: ReactNode }) {
return <strong className={styles.messageEmphasis}>{children}</strong>
}
export interface ListEmptyMessageProps {
part1: string
noneLabel: string
part2: string
actionLabel: string
part3: string
}
/** Standard list empty copy: part1 + bold none + part2 + bold action + part3. */
export function ListEmptyMessage({
part1,
noneLabel,
part2,
actionLabel,
part3,
}: ListEmptyMessageProps) {
return (
<>
{part1}
<EmptyStateEmphasis>{noneLabel}</EmptyStateEmphasis>
{part2}
<EmptyStateEmphasis>{actionLabel}</EmptyStateEmphasis>
{part3}
</>
)
}
export function buildListEmptyPlainMessage(parts: ListEmptyMessageProps): string {
return `${parts.part1}${parts.noneLabel}${parts.part2}${parts.actionLabel}${parts.part3}`
}
function EmptyStateContent({
message,
messageNode,
actionLabel,
actionTo,
onAction,
layout = 'page',
}: EmptyStateProps) {
const showAction = Boolean(actionLabel && (actionTo || onAction))
const messageContent = messageNode ?? message
const isInline = layout === 'inline'
if (!showAction) {
return (
<div
className={[styles.rootDashed, isInline ? styles.rootDashedInline : ''].filter(Boolean).join(' ')}
role="status"
>
<p className={styles.messageOnly}>{messageContent}</p>
</div>
)
}
const addContent = (
<>
<span className={styles.addIcon} aria-hidden="true">
<Plus size={28} strokeWidth={2} />
</span>
{actionLabel ? <span className={styles.addLabel}>{actionLabel}</span> : null}
</>
)
return (
<div
className={[styles.rootDashed, isInline ? styles.rootDashedInline : ''].filter(Boolean).join(' ')}
role="status"
>
<p className={styles.messageDashed}>{messageContent}</p>
{actionTo ? (
<Link to={actionTo} className={styles.addBox} onClick={onAction}>
{addContent}
</Link>
) : (
<button type="button" className={styles.addBox} onClick={onAction}>
{addContent}
</button>
)}
</div>
)
}
export function EmptyState(props: EmptyStateProps) {
const content = <EmptyStateContent {...props} />
if (props.layout === 'inline') {
return content
}
return <div className={styles.layoutPage}>{content}</div>
}
export function EmptyTableRow({
colSpan,
layout: _layout,
...props
}: EmptyStateProps & { colSpan: number }) {
return (
<tr>
<td colSpan={colSpan} className={styles.cell}>
<EmptyStateContent {...props} layout="inline" />
</td>
</tr>
)
}
@@ -5,7 +5,7 @@
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
overflow: hidden;
overflow: visible;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
@@ -17,11 +17,14 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(248, 250, 252, 0.8);
position: relative;
z-index: 2;
}
.toolbar button {
@@ -35,11 +38,25 @@
transition: background 0.15s, color 0.15s;
}
.toolbar button:hover {
.toolbar button:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.toolbar button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.dirBtn {
width: auto;
min-width: 34px;
padding: 0 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.divider {
width: 1px;
height: 20px;
@@ -47,6 +64,97 @@
margin: 0 4px;
}
.colorPickerWrap {
position: relative;
width: 28px;
height: 28px;
}
.colorBtn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.colorBtn:hover,
.colorBtnActive {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.colorSwatch {
width: 14px;
height: 3px;
margin-top: 1px;
border-radius: 2px;
background: var(--text-primary);
}
.colorPopover {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 168px;
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.colorVisualInput {
width: 100%;
height: 32px;
padding: 0;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 6px;
cursor: pointer;
background: transparent;
}
.hexField {
display: flex;
flex-direction: column;
gap: 4px;
}
.hexLabel {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.hexInput {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.9);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
text-transform: uppercase;
}
.hexInput:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.editorShell {
position: relative;
width: 100%;
@@ -0,0 +1,530 @@
import { useRef, useEffect, useState, useCallback, useContext } from 'react'
import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
Underline,
List,
ListOrdered,
ImagePlus,
Palette,
} from 'lucide-react'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import { LocaleContext } from '../context/LocaleContext'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
const DEFAULT_TEXT_COLOR = '#0f172a'
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
function normalizeHexColor(value: string): string | null {
const trimmed = value.trim()
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
if (!HEX_COLOR_PATTERN.test(withHash)) return null
if (withHash.length === 4) {
const [, r, g, b] = withHash
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
}
return withHash.toLowerCase()
}
function toHexInputValue(color: string): string {
return normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
}
export interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
allowImages?: boolean
/** Resolve uploaded image file to a public URL. Required when `allowImages` is true. */
onImageUpload?: (file: File) => Promise<string>
editorMinHeight?: number
locale?: DashboardLocale
dir?: 'ltr' | 'rtl'
}
export function RichTextEditor({
value,
onChange,
placeholder,
allowImages = false,
onImageUpload,
editorMinHeight = 140,
locale: localeProp,
dir: dirProp,
}: RichTextEditorProps) {
const localeContext = useContext(LocaleContext)
const locale = localeProp ?? localeContext?.locale ?? 'en'
const dir = dirProp ?? localeContext?.dir ?? 'ltr'
const editorShellRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const colorPickerRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{ startX: number; startWidth: number; img: HTMLImageElement } | null>(
null,
)
const [uploadingImage, setUploadingImage] = useState(false)
const [uploadError, setUploadError] = useState('')
const [selectedImageEl, setSelectedImageEl] = useState<HTMLImageElement | null>(null)
const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(null)
const [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR)
const [colorOpen, setColorOpen] = useState(false)
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
const syncChange = useCallback(() => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML)
}
}, [onChange])
const updateHandlePosition = useCallback((img: HTMLImageElement | null) => {
if (!img || !editorShellRef.current) {
setHandlePos(null)
return
}
const shellRect = editorShellRef.current.getBoundingClientRect()
const imgRect = img.getBoundingClientRect()
setHandlePos({
top: imgRect.bottom - shellRect.top - 6,
left: imgRect.right - shellRect.left - 6,
})
}, [])
const clearImageSelection = useCallback(() => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((img) => {
img.classList.remove(SELECTED_IMAGE_CLASS)
})
setSelectedImageEl(null)
setHandlePos(null)
}, [])
const selectImage = useCallback(
(img: HTMLImageElement) => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
node.classList.remove(SELECTED_IMAGE_CLASS)
})
img.classList.add(SELECTED_IMAGE_CLASS)
setSelectedImageEl(img)
updateHandlePosition(img)
},
[updateHandlePosition],
)
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value
clearImageSelection()
}
}, [value, clearImageSelection])
useEffect(() => {
if (!allowImages) return
const editor = editorRef.current
if (!editor) return
function onEditorClick(e: MouseEvent) {
const target = e.target
if (!(target instanceof HTMLImageElement) || !editor!.contains(target)) return
e.preventDefault()
selectImage(target)
}
function onDocumentClick(e: MouseEvent) {
const target = e.target as Node
if (editor!.contains(target)) return
if (target instanceof Element && target.closest(`.${styles.resizeHandle}`)) return
clearImageSelection()
}
editor.addEventListener('click', onEditorClick)
document.addEventListener('click', onDocumentClick)
return () => {
editor.removeEventListener('click', onEditorClick)
document.removeEventListener('click', onDocumentClick)
}
}, [allowImages, selectImage, clearImageSelection])
useEffect(() => {
if (!selectedImageEl) return
function refreshHandle() {
if (selectedImageEl?.isConnected) {
updateHandlePosition(selectedImageEl)
}
}
window.addEventListener('resize', refreshHandle)
editorRef.current?.addEventListener('scroll', refreshHandle)
return () => {
window.removeEventListener('resize', refreshHandle)
editorRef.current?.removeEventListener('scroll', refreshHandle)
}
}, [selectedImageEl, updateHandlePosition])
useEffect(() => {
if (!colorOpen) return
function onDocumentClick(e: MouseEvent) {
const target = e.target as Node
if (colorPickerRef.current?.contains(target)) return
setColorOpen(false)
}
document.addEventListener('mousedown', onDocumentClick)
return () => document.removeEventListener('mousedown', onDocumentClick)
}, [colorOpen])
function exec(cmd: string, arg?: string) {
document.execCommand(cmd, false, arg)
editorRef.current?.focus()
syncChange()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
let node: Node | null = selection.anchorNode
while (node && node !== editor) {
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
return node
}
node = node.parentNode
}
return null
}
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (!block) {
document.execCommand(direction > 0 ? 'indent' : 'outdent')
syncChange()
return
}
const current =
Number.parseFloat(block.style.paddingInlineStart) ||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
0
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
if (next === 0) {
block.style.paddingInlineStart = ''
} else {
block.style.paddingInlineStart = `${next}px`
}
syncChange()
}
function setBlockDirection(direction: 'rtl' | 'ltr') {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (block) {
block.setAttribute('dir', direction)
syncChange()
return
}
document.execCommand('dir', false, direction)
syncChange()
}
function applyTextColor(color: string) {
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
setTextColor(normalized)
setHexInput(normalized)
document.execCommand('styleWithCSS', false, 'true')
exec('foreColor', normalized)
}
function toggleColorPicker() {
setColorOpen((open) => {
const nextOpen = !open
if (nextOpen) {
setHexInput(toHexInputValue(textColor))
}
return nextOpen
})
}
function commitHexInput(rawValue?: string) {
const normalized = normalizeHexColor(rawValue ?? hexInput)
if (!normalized) {
setHexInput(toHexInputValue(textColor))
return
}
applyTextColor(normalized)
}
function onHexInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault()
commitHexInput()
editorRef.current?.focus()
}
if (e.key === 'Escape') {
e.preventDefault()
setHexInput(toHexInputValue(textColor))
setColorOpen(false)
}
}
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
e.preventDefault()
changeIndent(e.shiftKey ? -1 : 1)
}
function styleInsertedImage(img: HTMLImageElement) {
img.style.width = '100%'
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.draggable = false
}
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !editorRef.current || !onImageUpload) return
setUploadingImage(true)
setUploadError('')
try {
const url = await onImageUpload(file)
if (!url) throw new Error('Image upload failed.')
editorRef.current.focus()
document.execCommand('insertImage', false, url)
const imgs = editorRef.current.querySelectorAll('img')
const lastImg = imgs[imgs.length - 1]
if (lastImg instanceof HTMLImageElement) {
styleInsertedImage(lastImg)
selectImage(lastImg)
}
syncChange()
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Unable to upload image.')
} finally {
setUploadingImage(false)
}
}
function onResizeStart(e: React.MouseEvent) {
if (!selectedImageEl || !editorRef.current) return
e.preventDefault()
e.stopPropagation()
dragRef.current = {
startX: e.clientX,
startWidth: selectedImageEl.offsetWidth,
img: selectedImageEl,
}
function onMove(ev: MouseEvent) {
if (!dragRef.current || !editorRef.current) return
const delta = ev.clientX - dragRef.current.startX
const maxWidth = editorRef.current.clientWidth - 16
const nextWidth = Math.max(80, Math.min(maxWidth, dragRef.current.startWidth + delta))
dragRef.current.img.style.width = `${nextWidth}px`
dragRef.current.img.style.height = 'auto'
dragRef.current.img.style.maxWidth = '100%'
updateHandlePosition(dragRef.current.img)
}
function onUp() {
dragRef.current = null
syncChange()
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
return (
<div className={styles.wrapper} dir={dir} lang={locale}>
<div className={styles.toolbar} dir="ltr">
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<Bold size={16} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<Italic size={16} />
</button>
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
<Underline size={16} />
</button>
<span className={styles.divider} />
<div ref={colorPickerRef} className={styles.colorPickerWrap}>
<button
type="button"
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
onClick={toggleColorPicker}
title="Text color"
aria-label="Text color"
aria-expanded={colorOpen}
aria-haspopup="dialog"
>
<Palette size={16} />
<span className={styles.colorSwatch} style={{ background: textColor }} aria-hidden="true" />
</button>
{colorOpen && (
<div className={styles.colorPopover} role="dialog" aria-label="Text color">
<input
type="color"
className={styles.colorVisualInput}
value={textColor}
onChange={(e) => applyTextColor(e.target.value)}
aria-label="Pick color"
/>
<label className={styles.hexField}>
<span className={styles.hexLabel}>HEX</span>
<input
type="text"
className={styles.hexInput}
value={hexInput}
onChange={(e) => setHexInput(e.target.value)}
onBlur={() => commitHexInput()}
onKeyDown={onHexInputKeyDown}
spellCheck={false}
autoComplete="off"
dir="ltr"
aria-label="Hex color"
/>
</label>
</div>
)}
</div>
<span className={styles.divider} />
<button
type="button"
className={styles.dirBtn}
onClick={() => setBlockDirection('rtl')}
title="Right to left"
aria-label="Right to left"
>
RTL
</button>
<button
type="button"
className={styles.dirBtn}
onClick={() => setBlockDirection('ltr')}
title="Left to right"
aria-label="Left to right"
>
LTR
</button>
<span className={styles.divider} />
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<List size={16} />
</button>
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<ListOrdered size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={16} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={16} />
</button>
{allowImages && onImageUpload && (
<>
<span className={styles.divider} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
title="Insert image"
aria-label="Insert image"
>
<ImagePlus size={16} />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleImageSelect}
/>
</>
)}
</div>
{uploadError && (
<p className={styles.uploadError} role="alert">
{uploadError}
</p>
)}
<div ref={editorShellRef} className={styles.editorShell}>
<div
ref={editorRef}
className={styles.editor}
style={{ minHeight: editorMinHeight }}
contentEditable
role="textbox"
aria-multiline="true"
dir={dir}
lang={locale}
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning
/>
{allowImages && selectedImageEl && handlePos && (
<button
type="button"
className={styles.resizeHandle}
style={{ top: handlePos.top, left: handlePos.left }}
onMouseDown={onResizeStart}
aria-label="Resize image"
/>
)}
</div>
</div>
)
}
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&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;
bottom: 24px;
left: 24px;
z-index: 300;
z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: var(--elevated-surface);
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
@@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import styles from './Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastContext.Provider value={value}>
{children}
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>
{createPortal(
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>,
document.body,
)}
</ToastContext.Provider>
)
}
+15
View File
@@ -10,8 +10,23 @@ export {
type CityOption,
} from './components/AddressListEditor'
export { Breadcrumbs, type BreadcrumbItem } from './components/Breadcrumbs'
export {
EmptyState,
EmptyStateEmphasis,
EmptyTableRow,
ListEmptyMessage,
buildListEmptyPlainMessage,
type EmptyStateProps,
type ListEmptyMessageProps,
} from './components/EmptyState'
export { Pagination } from './components/Pagination'
export type { PaginationProps } from './components/Pagination'
export {
RichTextEditor,
isEmptyRichText,
richTextToPlain,
type RichTextEditorProps,
} from './components/RichTextEditor'
export { SectionCard } from './components/SectionCard'
export { RouteLoader } from './components/RouteLoader'
export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard'