From d5aca8bea77e92961840b2a77bd581a10187d046 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 22 Aug 2026 12:22:27 +0330 Subject: [PATCH] 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 --- apps/business/package.json | 1 + apps/business/src/App.tsx | 11 + .../AddWorkshopRegistrationModal.module.css | 23 + .../AddWorkshopRegistrationModal.tsx | 117 ++++ .../src/components/CustomerSearchSelect.tsx | 17 +- .../src/components/EmptyState.module.css | 53 -- apps/business/src/components/EmptyState.tsx | 60 +- .../src/components/ImageCropper.module.css | 22 +- apps/business/src/components/ImageCropper.tsx | 25 +- .../business/src/components/ImageUploader.tsx | 4 +- .../components/JalaliDatePicker.module.css | 98 ++++ .../src/components/JalaliDatePicker.tsx | 118 ++++ .../src/components/ListEmptyState.tsx | 64 +++ .../src/components/PageContent.module.css | 21 +- .../components/PickStoreSpecialItemsModal.tsx | 126 +++-- .../src/components/RichTextEditor.tsx | 385 +------------ apps/business/src/components/Sidebar.tsx | 12 +- apps/business/src/components/Toast.module.css | 6 +- .../WebsiteStaticImageGallery.module.css | 22 +- .../components/WebsiteStaticImageGallery.tsx | 43 +- apps/business/src/components/WorkshopCard.tsx | 164 ++++++ .../src/components/WorkshopCategoryTree.tsx | 68 +++ .../WorkshopRegistrationsModal.module.css | 219 ++++++++ .../components/WorkshopRegistrationsModal.tsx | 378 +++++++++++++ apps/business/src/context/ToastContext.tsx | 26 +- apps/business/src/i18n/messages.ts | 307 +++++++++- apps/business/src/index.css | 3 + .../src/pages/AddNewPortfolioPage.tsx | 20 +- .../src/pages/AddNewProductPage.module.css | 13 + .../business/src/pages/AddNewWorkshopPage.tsx | 380 +++++++++++++ apps/business/src/pages/BlogListPage.tsx | 6 +- apps/business/src/pages/ContentPage.tsx | 12 +- apps/business/src/pages/HomePage.tsx | 13 +- .../src/pages/InstructionListPage.tsx | 6 +- apps/business/src/pages/LoginPage.module.css | 4 +- apps/business/src/pages/MyProductsPage.tsx | 18 +- apps/business/src/pages/PortfolioListPage.tsx | 6 +- apps/business/src/pages/StoreSpecialsPage.tsx | 68 ++- apps/business/src/pages/VideoListPage.tsx | 6 +- .../src/pages/WebsiteSettingsPage.tsx | 4 +- .../src/pages/WorkshopCategoriesPage.tsx | 245 ++++++++ .../src/pages/WorkshopDetailsPage.tsx | 211 +++++++ apps/business/src/pages/WorkshopListPage.tsx | 271 +++++++++ apps/business/src/pages/WorkshopPage.tsx | 75 +++ .../src/services/storeSpecialService.ts | 2 + .../src/services/workshopCategoryService.ts | 76 +++ apps/business/src/services/workshopService.ts | 413 ++++++++++++++ apps/business/src/types/workshop.ts | 40 ++ apps/business/src/utils/businessModules.ts | 15 +- apps/business/vite.config.ts | 3 + apps/customer/src/components/EmptyState.tsx | 6 + .../src/components/OrderItemsModal.tsx | 3 +- apps/customer/src/components/Toast.module.css | 6 +- apps/customer/src/index.css | 2 + apps/customer/src/pages/AddressesPage.tsx | 11 +- apps/customer/src/pages/FavoritesPage.tsx | 6 +- apps/customer/src/pages/LoginPage.module.css | 4 +- apps/customer/src/pages/MyProductsPage.tsx | 14 +- apps/customer/src/pages/OrdersPage.tsx | 7 +- .../super-admin/src/components/EmptyState.tsx | 6 + .../src/components/RichTextEditor.module.css | 83 --- .../src/components/RichTextEditor.tsx | 183 +----- .../src/components/Toast.module.css | 11 +- apps/super-admin/src/context/ToastContext.tsx | 26 +- apps/super-admin/src/index.css | 3 + apps/super-admin/src/pages/SettingsPage.tsx | 31 +- apps/super-admin/src/utils/businessModules.ts | 9 +- package-lock.json | 31 + packages/dashboard-core/src/styles/tokens.css | 3 + .../src/components/EmptyState.module.css | 130 +++++ .../src/components/EmptyState.tsx | 129 +++++ .../src/components/RichTextEditor.module.css | 112 +++- .../src/components/RichTextEditor.tsx | 530 ++++++++++++++++++ .../dashboard-ui/src/context/Toast.module.css | 6 +- .../dashboard-ui/src/context/ToastContext.tsx | 26 +- packages/dashboard-ui/src/index.ts | 15 + 76 files changed, 4719 insertions(+), 974 deletions(-) create mode 100644 apps/business/src/components/AddWorkshopRegistrationModal.module.css create mode 100644 apps/business/src/components/AddWorkshopRegistrationModal.tsx delete mode 100644 apps/business/src/components/EmptyState.module.css create mode 100644 apps/business/src/components/JalaliDatePicker.module.css create mode 100644 apps/business/src/components/JalaliDatePicker.tsx create mode 100644 apps/business/src/components/ListEmptyState.tsx create mode 100644 apps/business/src/components/WorkshopCard.tsx create mode 100644 apps/business/src/components/WorkshopCategoryTree.tsx create mode 100644 apps/business/src/components/WorkshopRegistrationsModal.module.css create mode 100644 apps/business/src/components/WorkshopRegistrationsModal.tsx create mode 100644 apps/business/src/pages/AddNewWorkshopPage.tsx create mode 100644 apps/business/src/pages/WorkshopCategoriesPage.tsx create mode 100644 apps/business/src/pages/WorkshopDetailsPage.tsx create mode 100644 apps/business/src/pages/WorkshopListPage.tsx create mode 100644 apps/business/src/pages/WorkshopPage.tsx create mode 100644 apps/business/src/services/workshopCategoryService.ts create mode 100644 apps/business/src/services/workshopService.ts create mode 100644 apps/business/src/types/workshop.ts create mode 100644 apps/customer/src/components/EmptyState.tsx create mode 100644 apps/super-admin/src/components/EmptyState.tsx delete mode 100644 apps/super-admin/src/components/RichTextEditor.module.css create mode 100644 packages/dashboard-ui/src/components/EmptyState.module.css create mode 100644 packages/dashboard-ui/src/components/EmptyState.tsx rename {apps/business => packages/dashboard-ui}/src/components/RichTextEditor.module.css (55%) create mode 100644 packages/dashboard-ui/src/components/RichTextEditor.tsx diff --git a/apps/business/package.json b/apps/business/package.json index 23a7360..02ebb78 100644 --- a/apps/business/package.json +++ b/apps/business/package.json @@ -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": { diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 11d6ef3..d02a813 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/AddWorkshopRegistrationModal.module.css b/apps/business/src/components/AddWorkshopRegistrationModal.module.css new file mode 100644 index 0000000..aaa8420 --- /dev/null +++ b/apps/business/src/components/AddWorkshopRegistrationModal.module.css @@ -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; +} diff --git a/apps/business/src/components/AddWorkshopRegistrationModal.tsx b/apps/business/src/components/AddWorkshopRegistrationModal.tsx new file mode 100644 index 0000000..2985f32 --- /dev/null +++ b/apps/business/src/components/AddWorkshopRegistrationModal.tsx @@ -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( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="add-workshop-registration-title" + lang={isFa ? 'fa' : 'en'} + dir={isFa ? 'rtl' : 'ltr'} + > +
+

+ {t('workshop.registrations.add')} +

+ +
+ +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + undefined} + onPick={onPick} + excludeIds={excludeIds} + disabled={isAdding} + placeholder={t('workshop.registrations.searchPlaceholder')} + /> +
+ +

{t('workshop.registrations.searchHint')}

+
+
+
, + document.body, + ) +} diff --git a/apps/business/src/components/CustomerSearchSelect.tsx b/apps/business/src/components/CustomerSearchSelect.tsx index 5292b6b..d2bb1d4 100644 --- a/apps/business/src/components/CustomerSearchSelect.tsx +++ b/apps/business/src/components/CustomerSearchSelect.tsx @@ -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 (
{t('issueInvoice.pickCustomerHint')} ) : loading ? (
  • {t('issueInvoice.loading')}
  • - ) : results.length === 0 ? ( + ) : visibleResults.length === 0 ? (
  • {t('issueInvoice.pickCustomerNoResults')}
  • ) : ( - results.map((customer) => ( + visibleResults.map((customer) => (
  • - ) - ) : null} -
  • - ) -} - -export function EmptyTableRow({ - colSpan, - ...props -}: EmptyStateProps & { colSpan: number }) { - return ( - - - - - - ) -} +export { ListEmptyState } from './ListEmptyState' diff --git a/apps/business/src/components/ImageCropper.module.css b/apps/business/src/components/ImageCropper.module.css index 2ef876a..a2924d1 100644 --- a/apps/business/src/components/ImageCropper.module.css +++ b/apps/business/src/components/ImageCropper.module.css @@ -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; diff --git a/apps/business/src/components/ImageCropper.tsx b/apps/business/src/components/ImageCropper.tsx index ecf4fbb..a046c25 100644 --- a/apps/business/src/components/ImageCropper.tsx +++ b/apps/business/src/components/ImageCropper.tsx @@ -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 (
    @@ -121,7 +118,7 @@ export function ImageCropper({ {imageSrc && (
    -
    +
    - {images.map((src, index) => ( -
    + {images.filter(Boolean).map((src, index) => ( +
    {t('products.form.images')}
    -
    - , - document.body, +
    , + document.body, ) } diff --git a/apps/business/src/components/RichTextEditor.tsx b/apps/business/src/components/RichTextEditor.tsx index fdd63f7..b5cc97d 100644 --- a/apps/business/src/components/RichTextEditor.tsx +++ b/apps/business/src/components/RichTextEditor.tsx @@ -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(null) - const editorRef = useRef(null) - const fileInputRef = useRef(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(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) { - 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) { - 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 ( -
    -
    - - - - - - - - - - {allowImages && ( - <> - - - - - )} -
    - {uploadError && ( -

    - {uploadError} -

    - )} -
    -
    - {allowImages && selectedImageEl && handlePos && ( -
    -
    + { + 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(//gi, ' ') - .replace(/ /gi, ' ') - .replace(/<[^>]+>/g, '') - .replace(/\s+/g, ' ') - .trim() -} - -export function isEmptyRichText(html: string | null | undefined): boolean { - return richTextToPlain(html).length === 0 -} diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx index 4a6bf14..24c8fab 100644 --- a/apps/business/src/components/Sidebar.tsx +++ b/apps/business/src/components/Sidebar.tsx @@ -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) }) diff --git a/apps/business/src/components/Toast.module.css b/apps/business/src/components/Toast.module.css index 997645a..7d71701 100644 --- a/apps/business/src/components/Toast.module.css +++ b/apps/business/src/components/Toast.module.css @@ -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 { diff --git a/apps/business/src/components/WebsiteStaticImageGallery.module.css b/apps/business/src/components/WebsiteStaticImageGallery.module.css index 273b669..3dd0f4a 100644 --- a/apps/business/src/components/WebsiteStaticImageGallery.module.css +++ b/apps/business/src/components/WebsiteStaticImageGallery.module.css @@ -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; +} diff --git a/apps/business/src/components/WebsiteStaticImageGallery.tsx b/apps/business/src/components/WebsiteStaticImageGallery.tsx index ae8f552..f820bb4 100644 --- a/apps/business/src/components/WebsiteStaticImageGallery.tsx +++ b/apps/business/src/components/WebsiteStaticImageGallery.tsx @@ -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() 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 (
    @@ -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 (
    {image ? (
    @@ -183,13 +194,17 @@ export function WebsiteStaticImageGallery({ diff --git a/apps/business/src/components/WorkshopCard.tsx b/apps/business/src/components/WorkshopCard.tsx new file mode 100644 index 0000000..22720bb --- /dev/null +++ b/apps/business/src/components/WorkshopCard.tsx @@ -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 ( +
    + +
    + {workshop.coverImageUrl ? ( + {workshop.title} + ) : ( + + +
    +

    + {workshop.title} +

    + {hasAbstract ? ( +

    + {workshop.abstract} +

    + ) : ( +

    + {t('workshop.card.noSummary')} +

    + )} +

    + {formatWorkshopAuthor(workshop.author)} + · + {metaDate} + {eventTime ? ( + <> + · + {eventTime} + + ) : null} +

    +
    + + +
    + + + + + + + + + + + + +
    +
    + ) +} diff --git a/apps/business/src/components/WorkshopCategoryTree.tsx b/apps/business/src/components/WorkshopCategoryTree.tsx new file mode 100644 index 0000000..6b88efe --- /dev/null +++ b/apps/business/src/components/WorkshopCategoryTree.tsx @@ -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 + 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 ( +
    + onToggle(category.id)} + onAddSub={onAddSub} + onEdit={onEdit} + onRemove={onRemove} + /> + {childCount && ( +
    +
    + +
    +
    + )} +
    + ) + })} + + ) +} diff --git a/apps/business/src/components/WorkshopRegistrationsModal.module.css b/apps/business/src/components/WorkshopRegistrationsModal.module.css new file mode 100644 index 0000000..b89f50f --- /dev/null +++ b/apps/business/src/components/WorkshopRegistrationsModal.module.css @@ -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; + } +} diff --git a/apps/business/src/components/WorkshopRegistrationsModal.tsx b/apps/business/src/components/WorkshopRegistrationsModal.tsx new file mode 100644 index 0000000..20b1730 --- /dev/null +++ b/apps/business/src/components/WorkshopRegistrationsModal.tsx @@ -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([]) + 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(null) + const [removingId, setRemovingId] = useState(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( + <> +
    +
    e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="workshop-registrations-title" + lang={locale === 'fa' ? 'fa' : undefined} + dir={locale === 'fa' ? 'rtl' : undefined} + > +
    +
    +

    + {t('workshop.registrations.title')} +

    +

    {workshopTitle}

    +
    + +
    + +
    +
    + {error && ( +

    + {error} +

    + )} + + {isLoading ? ( +

    {t('workshop.registrations.loading')}

    + ) : registrations.length === 0 ? ( +

    {t('workshop.registrations.empty')}

    + ) : ( +
    + + + + + + + + + + + {registrations.map((registration) => ( + + + + + + + + ))} + +
    {t('workshop.registrations.col.name')}{t('workshop.registrations.col.cell')}{t('workshop.registrations.col.date')}{t('workshop.registrations.col.status')} +
    + {formatWorkshopRegistrationName(registration.user, dateLocale)} + + {registration.user.cellNumber + ? formatCellForDisplay(registration.user.cellNumber) + : '—'} + {formatWorkshopDate(registration.createdAt, dateLocale)} +
    + {STATUS_CONTROLS.map(({ status, icon: Icon, labelKey, className }) => { + const isActive = registration.status === status + const isBusy = + updatingId === registration.id || removingId === registration.id + return ( + + + + ) + })} +
    +
    + + + +
    +
    + )} +
    + +
    + + + +
    +
    +
    +
    + + + , + document.body, + ) +} diff --git a/apps/business/src/context/ToastContext.tsx b/apps/business/src/context/ToastContext.tsx index cc0002e..39800a7 100644 --- a/apps/business/src/context/ToastContext.tsx +++ b/apps/business/src/context/ToastContext.tsx @@ -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 ( {children} -
    - {toast && ( -
    - {toast.message} -
    - )} -
    + {createPortal( +
    + {toast && ( +
    + {toast.message} +
    + )} +
    , + document.body, + )}
    ) } diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 1d43143..82a9201 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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\/[^/]+$/, diff --git a/apps/business/src/index.css b/apps/business/src/index.css index 67c5503..6280b3b 100644 --- a/apps/business/src/index.css +++ b/apps/business/src/index.css @@ -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; diff --git a/apps/business/src/pages/AddNewPortfolioPage.tsx b/apps/business/src/pages/AddNewPortfolioPage.tsx index ac76744..5971b43 100644 --- a/apps/business/src/pages/AddNewPortfolioPage.tsx +++ b/apps/business/src/pages/AddNewPortfolioPage.tsx @@ -319,17 +319,17 @@ export function AddNewPortfolioPage() { disabled={isSubmitting} />
    +
    -
    - - -
    +
    + +
    diff --git a/apps/business/src/pages/AddNewProductPage.module.css b/apps/business/src/pages/AddNewProductPage.module.css index b586302..c189e4f 100644 --- a/apps/business/src/pages/AddNewProductPage.module.css +++ b/apps/business/src/pages/AddNewProductPage.module.css @@ -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 { diff --git a/apps/business/src/pages/AddNewWorkshopPage.tsx b/apps/business/src/pages/AddNewWorkshopPage.tsx new file mode 100644 index 0000000..025d22f --- /dev/null +++ b/apps/business/src/pages/AddNewWorkshopPage.tsx @@ -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([]) + const [categoryId, setCategoryId] = useState('') + const [titleFa, setTitleFa] = useState('') + const [titleEn, setTitleEn] = useState('') + const [abstract, setAbstract] = useState('') + const [descriptionHtml, setDescriptionHtml] = useState('') + const [coverImage, setCoverImage] = useState(null) + const [images, setImages] = useState([]) + const [tags, setTags] = useState([]) + const [eventDate, setEventDate] = useState('') + const [eventTime, setEventTime] = useState('') + const [duration, setDuration] = useState('') + const [featuredMediaId, setFeaturedMediaId] = useState(null) + const [galleryMediaIds, setGalleryMediaIds] = useState([]) + 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 ( +
    + {error ? ( +

    + {error} +

    + ) : ( +

    + {isLoadingWorkshop ? t('workshop.list.loading') : t('workshop.details.errorLoad')} +

    + )} +
    + ) + } + + async function handleSubmit(e: React.FormEvent) { + 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 ( +
    + +
    +
    +

    + {isEdit ? t('title.editWorkshop') : t('title.addWorkshop')} +

    +

    + {isEdit ? t('workshop.form.edit.subtitle') : t('workshop.form.add.subtitle')} +

    +
    +
    + + {error && ( +
    + {error} +
    + )} + +
    +
    +
    + + +
    + +
    +
    + + +
    + +
    +
    + + setTitleFa(e.target.value)} + required + disabled={isSubmitting} + /> +
    + +
    + + setTitleEn(e.target.value)} + disabled={isSubmitting} + /> +
    +
    + +
    + +