From cc94b929e04046446afa7b1cd8d641714b6fb0b6 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Thu, 13 Aug 2026 09:51:22 +0330 Subject: [PATCH] Add e-payment UI, checkout payment result, and legacy migrate domain lookup. Wire Mellat settings in business dashboard, customer bank return page with payment details, and super-admin migrate modal domain confirmation. Co-authored-by: Cursor --- apps/business/src/App.tsx | 28 + .../src/components/InstructionCard.tsx | 137 +++++ .../components/InstructionCategoryTree.tsx | 68 +++ .../components/InstructionCommentsModal.tsx | 267 +++++++++ .../components/InstructionCommentsSection.tsx | 191 +++++++ apps/business/src/components/Sidebar.tsx | 39 +- apps/business/src/components/VideoCard.tsx | 137 +++++ .../src/components/VideoCategoryTree.tsx | 68 +++ .../src/components/VideoCommentsModal.tsx | 267 +++++++++ .../src/components/VideoCommentsSection.tsx | 188 +++++++ apps/business/src/i18n/messages.ts | 517 +++++++++++++++++- .../src/pages/AddNewInstructionPage.tsx | 342 ++++++++++++ .../src/pages/AddNewProductPage.module.css | 98 ++++ apps/business/src/pages/AddNewVideoPage.tsx | 342 ++++++++++++ apps/business/src/pages/BlogPage.tsx | 1 + apps/business/src/pages/ContentPage.tsx | 74 +++ apps/business/src/pages/HomePage.tsx | 42 +- .../src/pages/InstructionCategoriesPage.tsx | 237 ++++++++ .../src/pages/InstructionDetailsPage.tsx | 191 +++++++ .../src/pages/InstructionListPage.tsx | 231 ++++++++ apps/business/src/pages/InstructionPage.tsx | 79 +++ .../src/pages/InstructionSettingsPage.tsx | 125 +++++ apps/business/src/pages/StoreSettingsPage.tsx | 17 + .../src/pages/VideoCategoriesPage.tsx | 237 ++++++++ apps/business/src/pages/VideoDetailsPage.tsx | 191 +++++++ apps/business/src/pages/VideoListPage.tsx | 231 ++++++++ apps/business/src/pages/VideoPage.tsx | 79 +++ apps/business/src/pages/VideoSettingsPage.tsx | 125 +++++ .../src/pages/WebsiteEPaymentPage.module.css | 148 +++++ .../src/pages/WebsiteEPaymentPage.tsx | 351 +++++++++++- apps/business/src/services/commentService.ts | 20 +- .../services/instructionCategoryService.ts | 76 +++ .../src/services/instructionService.ts | 243 ++++++++ apps/business/src/services/settingsService.ts | 51 ++ .../src/services/videoCategoryService.ts | 76 +++ apps/business/src/services/videoService.ts | 237 ++++++++ apps/business/src/types/instruction.ts | 40 ++ apps/business/src/types/video.ts | 40 ++ apps/business/src/utils/businessModules.ts | 8 + apps/customer/src/App.tsx | 2 + apps/customer/src/lib/routeTitles.ts | 1 + .../src/pages/checkout/CheckoutFailedStep.tsx | 81 ++- .../src/pages/checkout/CheckoutFlow.tsx | 24 +- .../pages/checkout/CheckoutPaymentStep.tsx | 52 +- .../src/pages/checkout/CheckoutResultStep.tsx | 117 ++++ .../pages/checkout/CheckoutSuccessStep.tsx | 84 ++- apps/customer/src/services/cartService.ts | 50 +- apps/customer/src/services/orderService.ts | 17 + apps/customer/src/utils/checkoutPayment.ts | 82 +++ apps/super-admin/src/pages/BusinessesPage.tsx | 122 ++++- .../src/services/businessService.ts | 26 + apps/super-admin/src/types/business.ts | 2 + apps/super-admin/src/utils/businessModules.ts | 2 + docs/PROJECT_CONTEXT.md | 10 +- 54 files changed, 6415 insertions(+), 96 deletions(-) create mode 100644 apps/business/src/components/InstructionCard.tsx create mode 100644 apps/business/src/components/InstructionCategoryTree.tsx create mode 100644 apps/business/src/components/InstructionCommentsModal.tsx create mode 100644 apps/business/src/components/InstructionCommentsSection.tsx create mode 100644 apps/business/src/components/VideoCard.tsx create mode 100644 apps/business/src/components/VideoCategoryTree.tsx create mode 100644 apps/business/src/components/VideoCommentsModal.tsx create mode 100644 apps/business/src/components/VideoCommentsSection.tsx create mode 100644 apps/business/src/pages/AddNewInstructionPage.tsx create mode 100644 apps/business/src/pages/AddNewVideoPage.tsx create mode 100644 apps/business/src/pages/ContentPage.tsx create mode 100644 apps/business/src/pages/InstructionCategoriesPage.tsx create mode 100644 apps/business/src/pages/InstructionDetailsPage.tsx create mode 100644 apps/business/src/pages/InstructionListPage.tsx create mode 100644 apps/business/src/pages/InstructionPage.tsx create mode 100644 apps/business/src/pages/InstructionSettingsPage.tsx create mode 100644 apps/business/src/pages/VideoCategoriesPage.tsx create mode 100644 apps/business/src/pages/VideoDetailsPage.tsx create mode 100644 apps/business/src/pages/VideoListPage.tsx create mode 100644 apps/business/src/pages/VideoPage.tsx create mode 100644 apps/business/src/pages/VideoSettingsPage.tsx create mode 100644 apps/business/src/pages/WebsiteEPaymentPage.module.css create mode 100644 apps/business/src/services/instructionCategoryService.ts create mode 100644 apps/business/src/services/instructionService.ts create mode 100644 apps/business/src/services/videoCategoryService.ts create mode 100644 apps/business/src/services/videoService.ts create mode 100644 apps/business/src/types/instruction.ts create mode 100644 apps/business/src/types/video.ts create mode 100644 apps/customer/src/pages/checkout/CheckoutResultStep.tsx create mode 100644 apps/customer/src/utils/checkoutPayment.ts diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 8b8517e..03cbc89 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -48,6 +48,19 @@ import { AddNewPortfolioPage } from './pages/AddNewPortfolioPage' import { PortfolioCategoriesPage } from './pages/PortfolioCategoriesPage' import { PortfolioSettingsPage } from './pages/PortfolioSettingsPage' import { PortfolioDetailsPage } from './pages/PortfolioDetailsPage' +import { VideoPage } from './pages/VideoPage' +import { VideoListPage } from './pages/VideoListPage' +import { AddNewVideoPage } from './pages/AddNewVideoPage' +import { VideoCategoriesPage } from './pages/VideoCategoriesPage' +import { VideoSettingsPage } from './pages/VideoSettingsPage' +import { VideoDetailsPage } from './pages/VideoDetailsPage' +import { InstructionPage } from './pages/InstructionPage' +import { InstructionListPage } from './pages/InstructionListPage' +import { AddNewInstructionPage } from './pages/AddNewInstructionPage' +import { InstructionCategoriesPage } from './pages/InstructionCategoriesPage' +import { InstructionSettingsPage } from './pages/InstructionSettingsPage' +import { InstructionDetailsPage } from './pages/InstructionDetailsPage' +import { ContentPage } from './pages/ContentPage' import { WebsitePage } from './pages/WebsitePage' import { WebsiteContactPage } from './pages/WebsiteContactPage' import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage' @@ -104,6 +117,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -118,6 +132,20 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/InstructionCard.tsx b/apps/business/src/components/InstructionCard.tsx new file mode 100644 index 0000000..2a1131a --- /dev/null +++ b/apps/business/src/components/InstructionCard.tsx @@ -0,0 +1,137 @@ +import { Link } from 'react-router-dom' +import { Pencil, MessageSquare, Trash2, Play } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import type { Video } from '../types/instruction' +import { + formatInstructionAuthor, + formatInstructionCardDate, + resolveInstructionTitle, +} from '../services/instructionService' +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 InstructionCardProps { + video: Video + commentCount: number + onEdit: (id: string) => void + onComments: (id: string) => void + onRemove: (id: string) => void +} + +export function InstructionCard({ + video, + commentCount, + onEdit, + onComments, + onRemove, +}: InstructionCardProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const displayTitle = resolveInstructionTitle(video, isFa ? 'fa' : 'en') + const publishDate = formatInstructionCardDate( + video.publishedAt ?? video.createdAt, + isFa ? 'fa' : 'en', + ) + const titleLocale = textLocaleAttrs(displayTitle) + const abstractLocale = textLocaleAttrs(video.abstract) + const hasAbstract = Boolean(video.abstract?.trim()) + const titleDir = isFa ? 'rtl' : titleLocale.dir + const abstractDir = isFa ? 'rtl' : abstractLocale.dir + + return ( +
+ +
+ {video.coverImageUrl ? ( + {displayTitle} + ) : ( + + )} + + {video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'} + +
+ +
+

+ {displayTitle} +

+ {hasAbstract ? ( +

+ {video.abstract} +

+ ) : ( +

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

+ )} +

+ {formatInstructionAuthor(video.author)} + · + {publishDate} +

+
+ + +
+ + + + + + + + + +
+
+ ) +} diff --git a/apps/business/src/components/InstructionCategoryTree.tsx b/apps/business/src/components/InstructionCategoryTree.tsx new file mode 100644 index 0000000..4535837 --- /dev/null +++ b/apps/business/src/components/InstructionCategoryTree.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 InstructionCategoryTreeProps { + 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 InstructionCategoryTree({ + categories, + expandedIds, + onToggle, + onAddSub, + onEdit, + onRemove, + parentId = null, + depth = 0, +}: InstructionCategoryTreeProps) { + 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/InstructionCommentsModal.tsx b/apps/business/src/components/InstructionCommentsModal.tsx new file mode 100644 index 0000000..17b74ea --- /dev/null +++ b/apps/business/src/components/InstructionCommentsModal.tsx @@ -0,0 +1,267 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import type { ProductComment } from '../types/comment' +import { formatCommentDate } from '../data/productComments' +import { ApiError } from '../lib/api' +import { + COMMENTS_PER_PAGE, + deleteComment, + listInstructionComments, + updateCommentApproval, +} from '../services/commentService' +import { Pagination } from './Pagination' +import styles from './ProductCommentsModal.module.css' + +interface InstructionCommentsModalProps { + open: boolean + instructionId: string + instructionTitle: string + onClose: () => void + onCountChange?: (count: number) => void +} + +const ANIMATION_MS = 220 + +export function InstructionCommentsModal({ + open, + instructionId, + instructionTitle, + onClose, + onCountChange, +}: InstructionCommentsModalProps) { + const { locale } = useLocale() + const dateLocale = locale === 'fa' ? 'fa' : 'en' + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [comments, setComments] = useState([]) + const [totalComments, setTotalComments] = useState(0) + const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [actionId, setActionId] = useState(null) + + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)) + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setCurrentPage(1) + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!open || !instructionId) return + + const controller = new AbortController() + void loadComments(currentPage, controller.signal) + return () => controller.abort() + }, [open, instructionId, currentPage]) + + async function loadComments(page: number, signal?: AbortSignal) { + setIsLoading(true) + setError('') + + try { + const data = await listInstructionComments(instructionId, page, COMMENTS_PER_PAGE, signal) + setComments(data.items) + setTotalComments(data.total) + onCountChange?.(data.total) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to load comments.') + } + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages) + } + }, [currentPage, totalPages]) + + 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 + + async function toggleApproval(comment: ProductComment) { + setActionId(comment.id) + setError('') + + try { + const updated = await updateCommentApproval(comment.id, !comment.approved) + setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c))) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to update comment approval.') + } + } finally { + setActionId(null) + } + } + + async function removeComment(commentId: string) { + setActionId(commentId) + setError('') + + try { + await deleteComment(commentId) + const nextTotal = totalComments - 1 + const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE)) + const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage + setTotalComments(nextTotal) + onCountChange?.(nextTotal) + + if (nextPage !== currentPage) { + setCurrentPage(nextPage) + } else { + setComments((prev) => prev.filter((c) => c.id !== commentId)) + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to delete comment.') + } + } finally { + setActionId(null) + } + } + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="video-comments-title" + > +
+
+

+ Comments +

+ {instructionTitle && ( +

+ {instructionTitle} · {totalComments}{' '} + {totalComments === 1 ? 'comment' : 'comments'} +

+ )} +
+ +
+ +
+ {error && ( +

+ {error} +

+ )} + + {isLoading ? ( +

Loading comments...

+ ) : totalComments === 0 ? ( +

No comments yet for this video.

+ ) : ( + <> +
+ {comments.map((comment) => ( +
+
+
+
{comment.author}
+
+ {formatCommentDate(comment.createdAt, dateLocale)} +
+
+ + + {comment.likesCount} + +
+

{comment.text}

+
+ {comment.approved ? ( + + ) : ( + + )} + +
+
+ ))} +
+ + {totalComments > COMMENTS_PER_PAGE && ( +
+ +
+ )} + + )} +
+
+
, + document.body, + ) +} diff --git a/apps/business/src/components/InstructionCommentsSection.tsx b/apps/business/src/components/InstructionCommentsSection.tsx new file mode 100644 index 0000000..8efca40 --- /dev/null +++ b/apps/business/src/components/InstructionCommentsSection.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react' +import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import type { ProductComment } from '../types/comment' +import { formatCommentDate } from '../data/productComments' +import { ApiError } from '../lib/api' +import { + COMMENTS_PER_PAGE, + deleteComment, + listInstructionComments, + updateCommentApproval, +} from '../services/commentService' +import { Pagination } from './Pagination' +import styles from './BlogCommentsSection.module.css' + +interface InstructionCommentsSectionProps { + instructionId: string + onCountChange?: (count: number) => void +} + +export function InstructionCommentsSection({ + instructionId, + onCountChange, +}: InstructionCommentsSectionProps) { + const { locale } = useLocale() + const dateLocale = locale === 'fa' ? 'fa' : 'en' + const [comments, setComments] = useState([]) + const [totalComments, setTotalComments] = useState(0) + const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [actionId, setActionId] = useState(null) + + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)) + + useEffect(() => { + const controller = new AbortController() + void loadComments(currentPage, controller.signal) + return () => controller.abort() + }, [instructionId, currentPage]) + + async function loadComments(page: number, signal?: AbortSignal) { + setIsLoading(true) + setError('') + + try { + const data = await listInstructionComments(instructionId, page, COMMENTS_PER_PAGE, signal) + setComments(data.items) + setTotalComments(data.total) + onCountChange?.(data.total) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to load comments.') + } + } finally { + setIsLoading(false) + } + } + + async function toggleApproval(comment: ProductComment) { + setActionId(comment.id) + setError('') + + try { + const updated = await updateCommentApproval(comment.id, !comment.approved) + setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c))) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to update comment approval.') + } + } finally { + setActionId(null) + } + } + + async function removeComment(commentId: string) { + setActionId(commentId) + setError('') + + try { + await deleteComment(commentId) + const nextTotal = totalComments - 1 + const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE)) + const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage + setTotalComments(nextTotal) + onCountChange?.(nextTotal) + + if (nextPage !== currentPage) { + setCurrentPage(nextPage) + } else { + setComments((prev) => prev.filter((c) => c.id !== commentId)) + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to delete comment.') + } + } finally { + setActionId(null) + } + } + + return ( +
+

Comments ({totalComments})

+ + {error && ( +

+ {error} +

+ )} + + {isLoading ? ( +

Loading comments...

+ ) : totalComments === 0 ? ( +

No comments yet.

+ ) : ( + <> +
+ {comments.map((comment) => ( +
+
+
+
{comment.author}
+
+ {formatCommentDate(comment.createdAt, dateLocale)} +
+
+ + + {comment.likesCount} + +
+

{comment.text}

+
+ {comment.approved ? ( + + ) : ( + + )} + +
+
+ ))} +
+ + {totalComments > COMMENTS_PER_PAGE && ( + + )} + + )} +
+ ) +} diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx index a944cf8..d56752a 100644 --- a/apps/business/src/components/Sidebar.tsx +++ b/apps/business/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { FileText, Briefcase, Globe, + Layers, HelpCircle, LogOut, ChevronDown, @@ -31,6 +32,7 @@ import { getBusinessProfile } from '../services/businessProfileService' import { useTenantBranding } from '../context/TenantBrandingContext' import { hasBusinessModule, + hasAnyBusinessModule, type BusinessModuleId, } from '../utils/businessModules' import { Tooltip } from './Tooltip' @@ -66,6 +68,11 @@ function isGroupActive(group: NavGroup, pathname: string) { if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) { return true } + if (group.id === 'content') { + if (pathname.startsWith('/blog') || pathname.startsWith('/videos') || pathname.startsWith('/instructions')) { + return true + } + } return group.children.some( (child) => pathname === child.to || pathname.startsWith(`${child.to}/`), ) @@ -159,17 +166,20 @@ export function Sidebar() { }, { type: 'group', - id: 'blog', - icon: FileText, - labelKey: 'nav.blog', - basePath: '/blog', - moduleId: 'blog', + id: 'content', + icon: Layers, + labelKey: 'nav.content', + basePath: '/content', children: [ - { labelKey: 'nav.blog.overview', to: '/blog' }, - { labelKey: 'nav.blog.list', to: '/blog/list' }, - { labelKey: 'nav.blog.new', to: '/blog/new' }, - { labelKey: 'nav.blog.categories', to: '/blog/categories' }, - { labelKey: 'nav.blog.settings', to: '/blog/settings' }, + ...(hasBusinessModule(enabledModules, 'blog') + ? [{ labelKey: 'nav.content.blog' as BusinessMessageKey, to: '/blog' }] + : []), + ...(hasBusinessModule(enabledModules, 'videos') + ? [{ labelKey: 'nav.content.videos' as BusinessMessageKey, to: '/videos' }] + : []), + ...(hasBusinessModule(enabledModules, 'instructions') + ? [{ labelKey: 'nav.content.instructions' as BusinessMessageKey, to: '/instructions' }] + : []), ], }, { @@ -209,9 +219,12 @@ export function Sidebar() { { type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' }, ] - return items.filter( - (item) => !item.moduleId || hasBusinessModule(enabledModules, item.moduleId), - ) + return items.filter((item) => { + if (item.type === 'group' && item.id === 'content') { + return hasAnyBusinessModule(enabledModules, ['blog', 'videos', 'instructions']) + } + return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId) + }) }, [enabledModules]) useEffect(() => { diff --git a/apps/business/src/components/VideoCard.tsx b/apps/business/src/components/VideoCard.tsx new file mode 100644 index 0000000..1db3cad --- /dev/null +++ b/apps/business/src/components/VideoCard.tsx @@ -0,0 +1,137 @@ +import { Link } from 'react-router-dom' +import { Pencil, MessageSquare, Trash2, Play } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import type { Video } from '../types/video' +import { + formatVideoAuthor, + formatVideoCardDate, + resolveVideoTitle, +} from '../services/videoService' +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 VideoCardProps { + video: Video + commentCount: number + onEdit: (id: string) => void + onComments: (id: string) => void + onRemove: (id: string) => void +} + +export function VideoCard({ + video, + commentCount, + onEdit, + onComments, + onRemove, +}: VideoCardProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const displayTitle = resolveVideoTitle(video, isFa ? 'fa' : 'en') + const publishDate = formatVideoCardDate( + video.publishedAt ?? video.createdAt, + isFa ? 'fa' : 'en', + ) + const titleLocale = textLocaleAttrs(displayTitle) + const abstractLocale = textLocaleAttrs(video.abstract) + const hasAbstract = Boolean(video.abstract?.trim()) + const titleDir = isFa ? 'rtl' : titleLocale.dir + const abstractDir = isFa ? 'rtl' : abstractLocale.dir + + return ( +
+ +
+ {video.coverImageUrl ? ( + {displayTitle} + ) : ( + + )} + + {video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'} + +
+ +
+

+ {displayTitle} +

+ {hasAbstract ? ( +

+ {video.abstract} +

+ ) : ( +

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

+ )} +

+ {formatVideoAuthor(video.author)} + · + {publishDate} +

+
+ + +
+ + + + + + + + + +
+
+ ) +} diff --git a/apps/business/src/components/VideoCategoryTree.tsx b/apps/business/src/components/VideoCategoryTree.tsx new file mode 100644 index 0000000..63034b3 --- /dev/null +++ b/apps/business/src/components/VideoCategoryTree.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 VideoCategoryTreeProps { + 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 VideoCategoryTree({ + categories, + expandedIds, + onToggle, + onAddSub, + onEdit, + onRemove, + parentId = null, + depth = 0, +}: VideoCategoryTreeProps) { + 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/VideoCommentsModal.tsx b/apps/business/src/components/VideoCommentsModal.tsx new file mode 100644 index 0000000..c3668d6 --- /dev/null +++ b/apps/business/src/components/VideoCommentsModal.tsx @@ -0,0 +1,267 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import type { ProductComment } from '../types/comment' +import { formatCommentDate } from '../data/productComments' +import { ApiError } from '../lib/api' +import { + COMMENTS_PER_PAGE, + deleteComment, + listVideoComments, + updateCommentApproval, +} from '../services/commentService' +import { Pagination } from './Pagination' +import styles from './ProductCommentsModal.module.css' + +interface VideoCommentsModalProps { + open: boolean + videoId: string + videoTitle: string + onClose: () => void + onCountChange?: (count: number) => void +} + +const ANIMATION_MS = 220 + +export function VideoCommentsModal({ + open, + videoId, + videoTitle, + onClose, + onCountChange, +}: VideoCommentsModalProps) { + const { locale } = useLocale() + const dateLocale = locale === 'fa' ? 'fa' : 'en' + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [comments, setComments] = useState([]) + const [totalComments, setTotalComments] = useState(0) + const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [actionId, setActionId] = useState(null) + + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)) + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setCurrentPage(1) + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!open || !videoId) return + + const controller = new AbortController() + void loadComments(currentPage, controller.signal) + return () => controller.abort() + }, [open, videoId, currentPage]) + + async function loadComments(page: number, signal?: AbortSignal) { + setIsLoading(true) + setError('') + + try { + const data = await listVideoComments(videoId, page, COMMENTS_PER_PAGE, signal) + setComments(data.items) + setTotalComments(data.total) + onCountChange?.(data.total) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to load comments.') + } + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages) + } + }, [currentPage, totalPages]) + + 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 + + async function toggleApproval(comment: ProductComment) { + setActionId(comment.id) + setError('') + + try { + const updated = await updateCommentApproval(comment.id, !comment.approved) + setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c))) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to update comment approval.') + } + } finally { + setActionId(null) + } + } + + async function removeComment(commentId: string) { + setActionId(commentId) + setError('') + + try { + await deleteComment(commentId) + const nextTotal = totalComments - 1 + const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE)) + const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage + setTotalComments(nextTotal) + onCountChange?.(nextTotal) + + if (nextPage !== currentPage) { + setCurrentPage(nextPage) + } else { + setComments((prev) => prev.filter((c) => c.id !== commentId)) + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to delete comment.') + } + } finally { + setActionId(null) + } + } + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="video-comments-title" + > +
+
+

+ Comments +

+ {videoTitle && ( +

+ {videoTitle} · {totalComments}{' '} + {totalComments === 1 ? 'comment' : 'comments'} +

+ )} +
+ +
+ +
+ {error && ( +

+ {error} +

+ )} + + {isLoading ? ( +

Loading comments...

+ ) : totalComments === 0 ? ( +

No comments yet for this video.

+ ) : ( + <> +
+ {comments.map((comment) => ( +
+
+
+
{comment.author}
+
+ {formatCommentDate(comment.createdAt, dateLocale)} +
+
+ + + {comment.likesCount} + +
+

{comment.text}

+
+ {comment.approved ? ( + + ) : ( + + )} + +
+
+ ))} +
+ + {totalComments > COMMENTS_PER_PAGE && ( +
+ +
+ )} + + )} +
+
+
, + document.body, + ) +} diff --git a/apps/business/src/components/VideoCommentsSection.tsx b/apps/business/src/components/VideoCommentsSection.tsx new file mode 100644 index 0000000..9b09c66 --- /dev/null +++ b/apps/business/src/components/VideoCommentsSection.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from 'react' +import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import type { ProductComment } from '../types/comment' +import { formatCommentDate } from '../data/productComments' +import { ApiError } from '../lib/api' +import { + COMMENTS_PER_PAGE, + deleteComment, + listVideoComments, + updateCommentApproval, +} from '../services/commentService' +import { Pagination } from './Pagination' +import styles from './BlogCommentsSection.module.css' + +interface VideoCommentsSectionProps { + videoId: string + onCountChange?: (count: number) => void +} + +export function VideoCommentsSection({ videoId, onCountChange }: VideoCommentsSectionProps) { + const { locale } = useLocale() + const dateLocale = locale === 'fa' ? 'fa' : 'en' + const [comments, setComments] = useState([]) + const [totalComments, setTotalComments] = useState(0) + const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [actionId, setActionId] = useState(null) + + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)) + + useEffect(() => { + const controller = new AbortController() + void loadComments(currentPage, controller.signal) + return () => controller.abort() + }, [videoId, currentPage]) + + async function loadComments(page: number, signal?: AbortSignal) { + setIsLoading(true) + setError('') + + try { + const data = await listVideoComments(videoId, page, COMMENTS_PER_PAGE, signal) + setComments(data.items) + setTotalComments(data.total) + onCountChange?.(data.total) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to load comments.') + } + } finally { + setIsLoading(false) + } + } + + async function toggleApproval(comment: ProductComment) { + setActionId(comment.id) + setError('') + + try { + const updated = await updateCommentApproval(comment.id, !comment.approved) + setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c))) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to update comment approval.') + } + } finally { + setActionId(null) + } + } + + async function removeComment(commentId: string) { + setActionId(commentId) + setError('') + + try { + await deleteComment(commentId) + const nextTotal = totalComments - 1 + const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE)) + const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage + setTotalComments(nextTotal) + onCountChange?.(nextTotal) + + if (nextPage !== currentPage) { + setCurrentPage(nextPage) + } else { + setComments((prev) => prev.filter((c) => c.id !== commentId)) + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to delete comment.') + } + } finally { + setActionId(null) + } + } + + return ( +
+

Comments ({totalComments})

+ + {error && ( +

+ {error} +

+ )} + + {isLoading ? ( +

Loading comments...

+ ) : totalComments === 0 ? ( +

No comments yet.

+ ) : ( + <> +
+ {comments.map((comment) => ( +
+
+
+
{comment.author}
+
+ {formatCommentDate(comment.createdAt, dateLocale)} +
+
+ + + {comment.likesCount} + +
+

{comment.text}

+
+ {comment.approved ? ( + + ) : ( + + )} + +
+
+ ))} +
+ + {totalComments > COMMENTS_PER_PAGE && ( + + )} + + )} +
+ ) +} diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index f903f7b..494333a 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -38,6 +38,11 @@ const en = { 'nav.finance.invoices': 'Invoices', 'nav.finance.transactions': 'Transactions', 'nav.settings': 'Settings', + 'nav.content': 'Content Management', + 'nav.content.blog': 'News & Articles', + 'nav.content.videos': 'Videos', + 'nav.content.instructions': 'Instructions', + 'nav.content.overview': 'Overview', 'nav.blog': 'Blog', 'nav.blog.overview': 'Overview', 'nav.blog.list': 'My Blogs', @@ -50,6 +55,24 @@ const en = { 'nav.portfolios.new': 'Add New Portfolio', 'nav.portfolios.categories': 'Categories', 'nav.portfolios.settings': 'Settings', + 'nav.videos': 'Videos', + 'nav.videos.overview': 'Overview', + 'nav.videos.list': 'My Videos', + 'nav.videos.new': 'Add New Video', + 'nav.videos.categories': 'Categories', + 'nav.videos.settings': 'Settings', + 'nav.instructions': 'Instructions', + 'nav.instructions.overview': 'Overview', + 'nav.instructions.list': 'My Instructions', + 'nav.instructions.new': 'Add New Instruction', + 'nav.instructions.categories': 'Categories', + 'nav.instructions.settings': 'Settings', + 'nav.instructions': 'Instructions', + 'nav.instructions.overview': 'Overview', + 'nav.instructions.list': 'My Instructions', + 'nav.instructions.new': 'Add New Instruction', + 'nav.instructions.categories': 'Categories', + 'nav.instructions.settings': 'Settings', 'nav.website': 'Website', 'nav.website.overview': 'Overview', 'nav.website.sliders': 'Sliders', @@ -154,6 +177,10 @@ const en = { 'bc.blogDetails': 'Blog Details', 'bc.editPortfolio': 'Edit Portfolio', 'bc.portfolioDetails': 'Portfolio Details', + 'bc.editVideo': 'Edit Video', + 'bc.videoDetails': 'Video Details', + 'bc.editInstruction': 'Edit Instruction', + 'bc.instructionDetails': 'Instruction Details', 'home.welcome': 'Welcome back, {name}!', 'home.welcomeFallback': 'there', @@ -173,10 +200,25 @@ const en = { 'home.card.settings.title': 'Settings', 'home.card.settings.desc': 'Configure your store preferences and system settings.', 'home.card.settings.link': 'View settings', + 'home.card.content.title': 'Content', + 'home.card.content.desc': 'Manage blog posts, videos, and other published content.', + 'home.card.content.link': 'Manage content', + 'home.card.content.count': 'items', + 'content.page.subtitle': 'Blog, videos, and other content types for your website.', + 'content.card.blog.desc': 'Create and manage blog posts and categories.', + 'content.card.blog.link': 'Open blog', + 'content.card.videos.desc': 'Embed YouTube and Aparat videos with categories and comments.', + 'content.card.videos.link': 'Open videos', + 'content.card.instructions.desc': 'Tutorials with rich text and optional embedded video.', + 'content.card.instructions.link': 'Open instructions', 'home.card.blog.title': 'Blog', 'home.card.blog.desc': 'Create and manage blog posts and categories.', 'home.card.blog.link': 'View blog', 'home.card.blog.count': 'posts', + 'home.card.videos.title': 'Videos', + 'home.card.videos.desc': 'Embed YouTube and Aparat videos with categories and comments.', + 'home.card.videos.link': 'View videos', + 'home.card.videos.count': 'videos', 'home.card.portfolios.title': 'Portfolios', 'home.card.portfolios.desc': 'Manage your portfolio items and showcase projects.', 'home.card.portfolios.link': 'View portfolios', @@ -968,6 +1010,162 @@ const en = { 'blog.details.noContent': 'No content yet.', 'blog.details.author': 'Author', + 'video.page.subtitle': 'Embed and manage YouTube and Aparat videos for your website.', + 'video.card.list.desc': 'View, edit and manage all your videos.', + 'video.card.list.link': 'View videos', + 'video.card.new.desc': 'Add a new embedded video with cover image and description.', + 'video.card.new.link': 'Add video', + 'video.card.categories.title': 'Video Categories', + 'video.card.categories.desc': 'Organize videos into categories and subcategories.', + 'video.card.categories.link': 'View categories', + 'video.card.settings.desc': 'Configure video comment moderation options.', + 'video.card.settings.link': 'View settings', + + '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.emptyHint': 'Share embedded videos from YouTube or Aparat with your audience.', + 'video.list.emptyCta': 'Add your first video', + 'video.list.errorLoad': 'Unable to load videos.', + 'video.list.errorDelete': 'Unable to delete video.', + 'video.list.toast.removed': 'Video removed.', + 'video.list.deleteTitle': 'Delete Video', + 'video.list.deleteMessage': + 'Are you sure you want to delete "{name}"? This action cannot be undone.', + 'video.list.addNew': 'Add new video', + + 'video.card.edit': 'Edit video', + 'video.card.comments': 'View comments', + 'video.card.remove': 'Remove video', + 'video.card.noSummary': 'No summary yet.', + + 'video.settings.title': 'Video settings', + 'video.settings.subtitle': 'Configure how video comments are moderated.', + 'video.settings.commentsAuto': 'Auto-approve comments', + 'video.settings.commentsAutoDesc': + 'When enabled, new comments on videos are published immediately without manual approval.', + 'video.settings.commentsAutoAria': 'Auto-approve video comments', + + 'video.categories.title': 'Video Categories', + 'video.categories.subtitle': 'Organize your videos into categories and subcategories.', + 'video.categories.empty': 'No categories yet. Click + to add one.', + 'video.categories.errorLoad': 'Unable to load categories.', + 'video.categories.errorSave': 'Unable to save category.', + 'video.categories.errorDelete': 'Unable to delete category.', + + 'video.form.add.subtitle': 'Add an embedded video from YouTube or Aparat.', + 'video.form.edit.subtitle': 'Update video details and save changes.', + 'video.form.cancel': 'Cancel', + 'video.form.saving': 'Saving...', + 'video.form.save': 'Save Video', + 'video.form.update': 'Update Video', + 'video.form.errorSave': 'Unable to save video.', + 'video.form.toast.created': 'Video created.', + 'video.form.toast.updated': 'Video updated.', + 'video.form.coverImage': 'Cover Image', + 'video.form.uploadCoverImage': 'Upload cover image', + 'video.form.changeCoverImage': 'Change cover image', + 'video.form.cropHint': 'Click to select, then crop (3:2)', + 'video.form.provider': 'Video Provider', + 'video.form.category': 'Category', + 'video.form.categoryPlaceholder': 'Search and select category...', + 'video.form.titleFa': 'Title (FA)', + 'video.form.titleFaPlaceholder': 'عنوان فارسی', + 'video.form.titleEn': 'Title (EN)', + 'video.form.titleEnPlaceholder': 'English title', + 'video.form.abstract': 'Abstract', + 'video.form.abstractPlaceholder': 'Short summary shown in video listings', + 'video.form.embedCode': 'Embed Code', + 'video.form.embedCodePlaceholder': 'Paste the full iframe embed code from YouTube or Aparat', + 'video.form.description': 'Description', + 'video.form.descriptionPlaceholder': 'Full video description with formatting...', + 'video.form.tags': 'Tags', + + 'video.details.loading': 'Loading video...', + 'video.details.notFound': 'Video not found.', + 'video.details.errorLoad': 'Unable to load video.', + 'video.details.back': 'Back to My Videos', + 'video.details.noDescription': 'No description yet.', + 'video.details.author': 'Author', + + 'instruction.page.subtitle': 'Create and manage tutorials with rich text and optional embedded video.', + 'instruction.card.list.desc': 'View, edit and manage all your instructions.', + 'instruction.card.list.link': 'View instructions', + 'instruction.card.new.desc': 'Add a new tutorial with cover image, rich text, and optional video embed.', + 'instruction.card.new.link': 'Add instruction', + 'instruction.card.categories.title': 'Instruction Categories', + 'instruction.card.categories.desc': 'Organize instructions into categories and subcategories.', + 'instruction.card.categories.link': 'View categories', + 'instruction.card.settings.desc': 'Configure instruction comment moderation options.', + 'instruction.card.settings.link': 'View settings', + + 'instruction.list.subtitle': '{count} instructions · View, edit and manage your tutorials.', + 'instruction.list.loading': 'Loading instructions...', + 'instruction.list.empty': 'No instructions yet.', + 'instruction.list.emptyHint': 'Publish tutorials with rich text and optional video for your audience.', + 'instruction.list.emptyCta': 'Add your first instruction', + 'instruction.list.errorLoad': 'Unable to load instructions.', + 'instruction.list.errorDelete': 'Unable to delete instruction.', + 'instruction.list.toast.removed': 'Instruction removed.', + 'instruction.list.deleteTitle': 'Delete Instruction', + 'instruction.list.deleteMessage': + 'Are you sure you want to delete "{name}"? This action cannot be undone.', + 'instruction.list.addNew': 'Add new instruction', + + 'instruction.card.edit': 'Edit instruction', + 'instruction.card.comments': 'View comments', + 'instruction.card.remove': 'Remove instruction', + 'instruction.card.noSummary': 'No summary yet.', + + 'instruction.settings.title': 'Instruction settings', + 'instruction.settings.subtitle': 'Configure how instruction comments are moderated.', + 'instruction.settings.commentsAuto': 'Auto-approve comments', + 'instruction.settings.commentsAutoDesc': + 'When enabled, new comments on instructions are published immediately without manual approval.', + 'instruction.settings.commentsAutoAria': 'Auto-approve instruction comments', + + 'instruction.categories.title': 'Instruction Categories', + 'instruction.categories.subtitle': 'Organize your instructions into categories and subcategories.', + 'instruction.categories.empty': 'No categories yet. Click + to add one.', + 'instruction.categories.errorLoad': 'Unable to load categories.', + 'instruction.categories.errorSave': 'Unable to save category.', + 'instruction.categories.errorDelete': 'Unable to delete category.', + + 'instruction.form.add.subtitle': 'Add a tutorial with rich text and optional embedded video.', + 'instruction.form.edit.subtitle': 'Update instruction details and save changes.', + 'instruction.form.cancel': 'Cancel', + 'instruction.form.saving': 'Saving...', + 'instruction.form.save': 'Save Instruction', + 'instruction.form.update': 'Update Instruction', + 'instruction.form.errorSave': 'Unable to save instruction.', + 'instruction.form.toast.created': 'Instruction created.', + 'instruction.form.toast.updated': 'Instruction updated.', + 'instruction.form.coverImage': 'Cover Image', + 'instruction.form.uploadCoverImage': 'Upload cover image', + 'instruction.form.changeCoverImage': 'Change cover image', + 'instruction.form.cropHint': 'Click to select, then crop (3:2)', + 'instruction.form.provider': 'Video provider', + 'instruction.form.category': 'Category', + 'instruction.form.categoryPlaceholder': 'Search and select category...', + 'instruction.form.titleFa': 'Title (FA)', + 'instruction.form.titleFaPlaceholder': 'عنوان فارسی', + 'instruction.form.titleEn': 'Title (EN)', + 'instruction.form.titleEnPlaceholder': 'English title', + 'instruction.form.abstract': 'Abstract', + 'instruction.form.abstractPlaceholder': 'Short summary shown in instruction listings', + 'instruction.form.embedCode': 'Embed Code', + 'instruction.form.embedCodePlaceholder': 'Paste the full iframe embed code from YouTube or Aparat', + 'instruction.form.description': 'Description', + 'instruction.form.descriptionPlaceholder': 'Full instruction description with formatting...', + 'instruction.form.tags': 'Tags', + + 'instruction.details.loading': 'Loading instruction...', + 'instruction.details.notFound': 'Instruction not found.', + 'instruction.details.errorLoad': 'Unable to load instruction.', + 'instruction.details.back': 'Back to My Instructions', + 'instruction.details.noDescription': 'No description yet.', + 'instruction.details.author': 'Author', + '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', @@ -1266,11 +1464,32 @@ const en = { 'website.badges.feature.toggle': 'Toggle visibility per badge', 'website.ePayment.subtitle': 'Configure online payment methods for your website checkout.', - 'website.ePayment.lead': 'Connect payment gateways so customers can pay online through your store.', - 'website.ePayment.note': 'E-payment settings will be connected here. Planned options include:', - 'website.ePayment.feature.enable': 'Enable or disable online payments', - 'website.ePayment.feature.credentials': 'Configure payment provider credentials', - 'website.ePayment.feature.methods': 'Set supported payment methods and test mode', + 'website.ePayment.master': 'Enable online payments', + 'website.ePayment.masterDesc': + 'When enabled, customers can pay through a configured gateway at website checkout.', + 'website.ePayment.gateway.mellat': 'Bank Mellat (Behpardakht)', + 'website.ePayment.gateway.mellatDesc': + 'First gateway. Enter terminal ID, username, and password from the Mellat merchant panel.', + 'website.ePayment.gateway.sep': 'SEP (Saman Kish)', + 'website.ePayment.gateway.sepDesc': 'Saman electronic payment — coming soon.', + 'website.ePayment.gateway.snappay': 'Snapp Pay', + 'website.ePayment.gateway.snappayDesc': 'Snapp Pay checkout — coming soon.', + 'website.ePayment.gateway.digipay': 'DigiPay', + 'website.ePayment.gateway.digipayDesc': 'DigiPay checkout — coming soon.', + 'website.ePayment.gateway.zarinpal': 'Zarinpal', + 'website.ePayment.gateway.zarinpalDesc': 'Zarinpal gateway — coming soon.', + 'website.ePayment.comingSoon': 'Coming soon', + 'website.ePayment.terminalId': 'Terminal ID', + 'website.ePayment.username': 'Username', + 'website.ePayment.password': 'Password', + 'website.ePayment.passwordSet': 'saved', + 'website.ePayment.passwordPlaceholder': 'Enter gateway password', + 'website.ePayment.passwordPlaceholderSet': 'Leave blank to keep current password', + 'website.ePayment.showPassword': 'Show password', + 'website.ePayment.hidePassword': 'Hide password', + 'website.ePayment.saveCredentials': 'Save credentials', + 'website.ePayment.error.credentials': 'Terminal ID and username are required when Mellat is enabled.', + 'website.ePayment.error.passwordRequired': 'Password is required the first time you enable Mellat.', 'title.signIn': 'Sign in', 'title.home': 'Home', @@ -1290,11 +1509,22 @@ const en = { 'title.shoppingCards': 'Shopping Cards', 'title.customers': 'Users', 'title.customerProducts': 'Customer products', + 'title.content': 'Content', 'title.blog': 'Blog', 'title.myBlogs': 'My Blogs', 'title.addBlog': 'Add New Blog', 'title.editBlog': 'Edit Blog', 'title.blogDetails': 'Blog Details', + 'title.videos': 'Videos', + 'title.instructions': 'Instructions', + 'title.myInstructions': 'My Instructions', + 'title.addInstruction': 'Add New Instruction', + 'title.editInstruction': 'Edit Instruction', + 'title.instructionDetails': 'Instruction Details', + 'title.myVideos': 'My Videos', + 'title.addVideo': 'Add New Video', + 'title.editVideo': 'Edit Video', + 'title.videoDetails': 'Video Details', 'title.portfolios': 'Portfolios', 'title.myPortfolios': 'My Portfolios', 'title.addPortfolio': 'Add New Portfolio', @@ -1650,6 +1880,11 @@ const fa: Record = { 'nav.finance.invoices': 'فاکتورها', 'nav.finance.transactions': 'تراکنش‌ها', 'nav.settings': 'تنظیمات', + 'nav.content': 'مدیریت محتوا', + 'nav.content.blog': 'اخبار و مطالب', + 'nav.content.videos': 'ویدیوها', + 'nav.content.instructions': 'آموزش‌ها', + 'nav.content.overview': 'نمای کلی', 'nav.blog': 'بلاگ', 'nav.blog.overview': 'نمای کلی', 'nav.blog.list': 'بلاگ‌های من', @@ -1662,6 +1897,18 @@ const fa: Record = { 'nav.portfolios.new': 'افزودن نمونه کار', 'nav.portfolios.categories': 'دسته‌بندی‌ها', 'nav.portfolios.settings': 'تنظیمات', + 'nav.videos': 'ویدیوها', + 'nav.videos.overview': 'نمای کلی', + 'nav.videos.list': 'ویدیوهای من', + 'nav.videos.new': 'افزودن ویدیو', + 'nav.videos.categories': 'دسته‌بندی‌ها', + 'nav.videos.settings': 'تنظیمات', + 'nav.instructions': 'آموزش‌ها', + 'nav.instructions.overview': 'نمای کلی', + 'nav.instructions.list': 'آموزش‌های من', + 'nav.instructions.new': 'افزودن آموزش', + 'nav.instructions.categories': 'دسته‌بندی‌ها', + 'nav.instructions.settings': 'تنظیمات', 'nav.website': 'وب‌سایت', 'nav.website.overview': 'نمای کلی', 'nav.website.sliders': 'اسلایدرها', @@ -1766,6 +2013,10 @@ const fa: Record = { 'bc.blogDetails': 'جزئیات بلاگ', 'bc.editPortfolio': 'ویرایش نمونه کار', 'bc.portfolioDetails': 'جزئیات نمونه کار', + 'bc.editVideo': 'ویرایش ویدیو', + 'bc.videoDetails': 'جزئیات ویدیو', + 'bc.editInstruction': 'ویرایش آموزش', + 'bc.instructionDetails': 'جزئیات آموزش', 'home.welcome': '{name} عزیز، خوش آمدی.', 'home.welcomeFallback': 'کاربر', @@ -1785,6 +2036,17 @@ const fa: Record = { 'home.card.settings.title': 'تنظیمات', 'home.card.settings.desc': 'ترجیحات فروشگاه و تنظیمات سیستم را پیکربندی کنید.', 'home.card.settings.link': 'مشاهده تنظیمات', + 'home.card.content.title': 'مدیریت محتوا', + 'home.card.content.desc': 'بلاگ، ویدیو و سایر محتوای منتشرشده را مدیریت کنید.', + 'home.card.content.link': 'مدیریت محتوا', + 'home.card.content.count': 'مورد', + 'content.page.subtitle': 'بلاگ، ویدیو و سایر انواع محتوا برای وب‌سایت شما.', + 'content.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.', + 'content.card.blog.link': 'ورود به بلاگ', + 'content.card.videos.desc': 'ویدیوهای یوتیوب و آپارات را با دسته‌بندی و نظرات مدیریت کنید.', + 'content.card.videos.link': 'ورود به ویدیوها', + 'content.card.instructions.desc': 'آموزش‌ها با متن غنی و ویدیوی جاسازی‌شده اختیاری.', + 'content.card.instructions.link': 'ورود به آموزش‌ها', 'home.card.blog.title': 'بلاگ', 'home.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.', 'home.card.blog.link': 'مشاهده بلاگ', @@ -1793,6 +2055,10 @@ const fa: Record = { 'home.card.portfolios.desc': 'نمونه کارها و پروژه‌های نمایشی را مدیریت کنید.', 'home.card.portfolios.link': 'مشاهده نمونه کارها', 'home.card.portfolios.count': 'نمونه کار', + 'home.card.videos.title': 'ویدیوها', + 'home.card.videos.desc': 'ویدیوهای یوتیوب و آپارات را با دسته‌بندی و نظرات مدیریت کنید.', + 'home.card.videos.link': 'مشاهده ویدیوها', + 'home.card.videos.count': 'ویدیو', 'home.card.website.title': 'وب‌سایت', 'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها، عضویت‌ها و پرداخت الکترونیک.', 'home.card.website.link': 'مشاهده وب‌سایت', @@ -2578,6 +2844,162 @@ const fa: Record = { 'blog.details.noContent': 'هنوز محتوایی نیست.', 'blog.details.author': 'نویسنده', + 'video.page.subtitle': 'ویدیوهای یوتیوب و آپارات را برای وب‌سایت خود مدیریت کنید.', + 'video.card.list.desc': 'همه ویدیوها را مشاهده، ویرایش و مدیریت کنید.', + 'video.card.list.link': 'مشاهده ویدیوها', + 'video.card.new.desc': 'یک ویدیوی جاسازی‌شده با تصویر کاور و توضیحات اضافه کنید.', + 'video.card.new.link': 'افزودن ویدیو', + 'video.card.categories.title': 'دسته‌بندی‌های ویدیو', + 'video.card.categories.desc': 'ویدیوها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', + 'video.card.categories.link': 'مشاهده دسته‌بندی‌ها', + 'video.card.settings.desc': 'تأیید نظرات ویدیو را پیکربندی کنید.', + 'video.card.settings.link': 'مشاهده تنظیمات', + + 'video.list.subtitle': '{count} ویدیو · مشاهده، ویرایش و مدیریت محتوای ویدیو.', + 'video.list.loading': 'در حال بارگذاری ویدیوها...', + 'video.list.empty': 'هنوز ویدیویی نیست.', + 'video.list.emptyHint': 'ویدیوهای جاسازی‌شده از یوتیوب یا آپارات را با مخاطبان به اشتراک بگذارید.', + 'video.list.emptyCta': 'اولین ویدیو را اضافه کنید', + 'video.list.errorLoad': 'بارگذاری ویدیوها ممکن نشد.', + 'video.list.errorDelete': 'حذف ویدیو ممکن نشد.', + 'video.list.toast.removed': 'ویدیو حذف شد.', + 'video.list.deleteTitle': 'حذف ویدیو', + 'video.list.deleteMessage': + 'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.', + 'video.list.addNew': 'افزودن ویدیو', + + 'video.card.edit': 'ویرایش ویدیو', + 'video.card.comments': 'مشاهده نظرات', + 'video.card.remove': 'حذف ویدیو', + 'video.card.noSummary': 'هنوز خلاصه‌ای نیست.', + + 'video.settings.title': 'تنظیمات ویدیو', + 'video.settings.subtitle': 'نحوه تأیید نظرات ویدیو را پیکربندی کنید.', + 'video.settings.commentsAuto': 'تأیید خودکار نظرات', + 'video.settings.commentsAutoDesc': + 'در صورت فعال بودن، نظرات جدید روی ویدیوها بدون تأیید دستی منتشر می‌شوند.', + 'video.settings.commentsAutoAria': 'تأیید خودکار نظرات ویدیو', + + 'video.categories.title': 'دسته‌بندی‌های ویدیو', + 'video.categories.subtitle': 'ویدیوها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', + 'video.categories.empty': 'هنوز دسته‌بندی‌ای نیست. برای افزودن روی + بزنید.', + 'video.categories.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.', + 'video.categories.errorSave': 'ذخیره دسته‌بندی ممکن نشد.', + 'video.categories.errorDelete': 'حذف دسته‌بندی ممکن نشد.', + + 'video.form.add.subtitle': 'یک ویدیوی جاسازی‌شده از یوتیوب یا آپارات اضافه کنید.', + 'video.form.edit.subtitle': 'جزئیات ویدیو را به‌روزرسانی و ذخیره کنید.', + 'video.form.cancel': 'انصراف', + 'video.form.saving': 'در حال ذخیره...', + 'video.form.save': 'ذخیره ویدیو', + 'video.form.update': 'به‌روزرسانی ویدیو', + 'video.form.errorSave': 'ذخیره ویدیو ممکن نشد.', + 'video.form.toast.created': 'ویدیو ایجاد شد.', + 'video.form.toast.updated': 'ویدیو به‌روزرسانی شد.', + 'video.form.coverImage': 'تصویر کاور', + 'video.form.uploadCoverImage': 'بارگذاری تصویر کاور', + 'video.form.changeCoverImage': 'تغییر تصویر کاور', + 'video.form.cropHint': 'برای انتخاب و برش (۳:۲) کلیک کنید', + 'video.form.provider': 'ارائه‌دهنده ویدیو', + 'video.form.category': 'دسته‌بندی', + 'video.form.categoryPlaceholder': 'جستجو و انتخاب دسته‌بندی...', + 'video.form.titleFa': 'عنوان (فارسی)', + 'video.form.titleFaPlaceholder': 'عنوان فارسی', + 'video.form.titleEn': 'عنوان (انگلیسی)', + 'video.form.titleEnPlaceholder': 'English title', + 'video.form.abstract': 'چکیده', + 'video.form.abstractPlaceholder': 'خلاصه کوتاه در فهرست ویدیوها', + 'video.form.embedCode': 'کد جاسازی', + 'video.form.embedCodePlaceholder': 'کد iframe کامل را از یوتیوب یا آپارات بچسبانید', + 'video.form.description': 'توضیحات', + 'video.form.descriptionPlaceholder': 'توضیحات کامل ویدیو با قالب‌بندی...', + 'video.form.tags': 'برچسب‌ها', + + 'video.details.loading': 'در حال بارگذاری ویدیو...', + 'video.details.notFound': 'ویدیو یافت نشد.', + 'video.details.errorLoad': 'بارگذاری ویدیو ممکن نشد.', + 'video.details.back': 'بازگشت به ویدیوهای من', + 'video.details.noDescription': 'هنوز توضیحاتی نیست.', + 'video.details.author': 'نویسنده', + + 'instruction.page.subtitle': 'آموزش‌ها را با متن غنی و ویدیوی جاسازی‌شده اختیاری ایجاد و مدیریت کنید.', + 'instruction.card.list.desc': 'همه آموزش‌ها را مشاهده، ویرایش و مدیریت کنید.', + 'instruction.card.list.link': 'مشاهده آموزش‌ها', + 'instruction.card.new.desc': 'یک آموزش جدید با تصویر کاور، متن غنی و ویدیوی اختیاری اضافه کنید.', + 'instruction.card.new.link': 'افزودن آموزش', + 'instruction.card.categories.title': 'دسته‌بندی‌های آموزش', + 'instruction.card.categories.desc': 'آموزش‌ها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', + 'instruction.card.categories.link': 'مشاهده دسته‌بندی‌ها', + 'instruction.card.settings.desc': 'تأیید نظرات آموزش را پیکربندی کنید.', + 'instruction.card.settings.link': 'مشاهده تنظیمات', + + 'instruction.list.subtitle': '{count} آموزش · مشاهده، ویرایش و مدیریت محتوای آموزشی.', + 'instruction.list.loading': 'در حال بارگذاری آموزش‌ها...', + 'instruction.list.empty': 'هنوز آموزشی نیست.', + 'instruction.list.emptyHint': 'آموزش‌ها را با متن غنی و ویدیوی اختیاری برای مخاطبان منتشر کنید.', + 'instruction.list.emptyCta': 'اولین آموزش را اضافه کنید', + 'instruction.list.errorLoad': 'بارگذاری آموزش‌ها ممکن نشد.', + 'instruction.list.errorDelete': 'حذف آموزش ممکن نشد.', + 'instruction.list.toast.removed': 'آموزش حذف شد.', + 'instruction.list.deleteTitle': 'حذف آموزش', + 'instruction.list.deleteMessage': + 'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.', + 'instruction.list.addNew': 'افزودن آموزش', + + 'instruction.card.edit': 'ویرایش آموزش', + 'instruction.card.comments': 'مشاهده نظرات', + 'instruction.card.remove': 'حذف آموزش', + 'instruction.card.noSummary': 'هنوز خلاصه‌ای نیست.', + + 'instruction.settings.title': 'تنظیمات آموزش', + 'instruction.settings.subtitle': 'نحوه تأیید نظرات آموزش را پیکربندی کنید.', + 'instruction.settings.commentsAuto': 'تأیید خودکار نظرات', + 'instruction.settings.commentsAutoDesc': + 'در صورت فعال بودن، نظرات جدید روی آموزش‌ها بدون تأیید دستی منتشر می‌شوند.', + 'instruction.settings.commentsAutoAria': 'تأیید خودکار نظرات آموزش', + + 'instruction.categories.title': 'دسته‌بندی‌های آموزش', + 'instruction.categories.subtitle': 'آموزش‌ها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', + 'instruction.categories.empty': 'هنوز دسته‌بندی‌ای نیست. برای افزودن روی + بزنید.', + 'instruction.categories.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.', + 'instruction.categories.errorSave': 'ذخیره دسته‌بندی ممکن نشد.', + 'instruction.categories.errorDelete': 'حذف دسته‌بندی ممکن نشد.', + + 'instruction.form.add.subtitle': 'یک آموزش با متن غنی و ویدیوی جاسازی‌شده اختیاری اضافه کنید.', + 'instruction.form.edit.subtitle': 'جزئیات آموزش را به‌روزرسانی و ذخیره کنید.', + 'instruction.form.cancel': 'انصراف', + 'instruction.form.saving': 'در حال ذخیره...', + 'instruction.form.save': 'ذخیره آموزش', + 'instruction.form.update': 'به‌روزرسانی آموزش', + 'instruction.form.errorSave': 'ذخیره آموزش ممکن نشد.', + 'instruction.form.toast.created': 'آموزش ایجاد شد.', + 'instruction.form.toast.updated': 'آموزش به‌روزرسانی شد.', + 'instruction.form.coverImage': 'تصویر کاور', + 'instruction.form.uploadCoverImage': 'بارگذاری تصویر کاور', + 'instruction.form.changeCoverImage': 'تغییر تصویر کاور', + 'instruction.form.cropHint': 'برای انتخاب و برش (۳:۲) کلیک کنید', + 'instruction.form.provider': 'ارائه‌دهنده ویدیو', + 'instruction.form.category': 'دسته‌بندی', + 'instruction.form.categoryPlaceholder': 'جستجو و انتخاب دسته‌بندی...', + 'instruction.form.titleFa': 'عنوان (فارسی)', + 'instruction.form.titleFaPlaceholder': 'عنوان فارسی', + 'instruction.form.titleEn': 'عنوان (انگلیسی)', + 'instruction.form.titleEnPlaceholder': 'English title', + 'instruction.form.abstract': 'چکیده', + 'instruction.form.abstractPlaceholder': 'خلاصه کوتاه در فهرست آموزش‌ها', + 'instruction.form.embedCode': 'کد جاسازی', + 'instruction.form.embedCodePlaceholder': 'کد iframe کامل را از یوتیوب یا آپارات بچسبانید', + 'instruction.form.description': 'توضیحات', + 'instruction.form.descriptionPlaceholder': 'توضیحات کامل آموزش با قالب‌بندی...', + 'instruction.form.tags': 'برچسب‌ها', + + 'instruction.details.loading': 'در حال بارگذاری آموزش...', + 'instruction.details.notFound': 'آموزش یافت نشد.', + 'instruction.details.errorLoad': 'بارگذاری آموزش ممکن نشد.', + 'instruction.details.back': 'بازگشت به آموزش‌های من', + 'instruction.details.noDescription': 'هنوز توضیحاتی نیست.', + 'instruction.details.author': 'نویسنده', + 'portfolio.page.subtitle': 'نمونه کارها را مدیریت کنید و پروژه‌ها را به نمایش بگذارید.', 'portfolio.card.list.desc': 'همه نمونه کارها را مشاهده، ویرایش و مدیریت کنید.', 'portfolio.card.list.link': 'مشاهده نمونه کارها', @@ -2874,12 +3296,34 @@ const fa: Record = { 'website.badges.feature.toggle': 'نمایش یا مخفی کردن هر نشان', 'website.ePayment.subtitle': 'روش‌های پرداخت آنلاین برای تسویه حساب وب‌سایت را پیکربندی کنید.', - 'website.ePayment.lead': - 'درگاه‌های پرداخت را متصل کنید تا مشتریان از طریق فروشگاه آنلاین پرداخت کنند.', - 'website.ePayment.note': 'تنظیمات پرداخت الکترونیک به‌زودی اینجا متصل می‌شود. گزینه‌های برنامه‌ریزی‌شده:', - 'website.ePayment.feature.enable': 'فعال یا غیرفعال کردن پرداخت آنلاین', - 'website.ePayment.feature.credentials': 'پیکربندی اطلاعات درگاه پرداخت', - 'website.ePayment.feature.methods': 'تنظیم روش‌های پرداخت و حالت آزمایشی', + 'website.ePayment.master': 'فعال‌سازی پرداخت آنلاین', + 'website.ePayment.masterDesc': + 'با فعال بودن این گزینه، مشتریان در تسویه حساب وب‌سایت می‌توانند از درگاه پیکربندی‌شده پرداخت کنند.', + 'website.ePayment.gateway.mellat': 'بانک ملت (به‌پرداخت)', + 'website.ePayment.gateway.mellatDesc': + 'اولین درگاه. شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.', + 'website.ePayment.gateway.sep': 'سپ (سامان کیش)', + 'website.ePayment.gateway.sepDesc': 'درگاه پرداخت سامان — به‌زودی.', + 'website.ePayment.gateway.snappay': 'اسنپ‌پی', + 'website.ePayment.gateway.snappayDesc': 'پرداخت اسنپ‌پی — به‌زودی.', + 'website.ePayment.gateway.digipay': 'دیجی‌پی', + 'website.ePayment.gateway.digipayDesc': 'پرداخت دیجی‌پی — به‌زودی.', + 'website.ePayment.gateway.zarinpal': 'زرین‌پال', + 'website.ePayment.gateway.zarinpalDesc': 'درگاه زرین‌پال — به‌زودی.', + 'website.ePayment.comingSoon': 'به‌زودی', + 'website.ePayment.terminalId': 'شناسه ترمینال', + 'website.ePayment.username': 'نام کاربری', + 'website.ePayment.password': 'رمز عبور', + 'website.ePayment.passwordSet': 'ذخیره‌شده', + 'website.ePayment.passwordPlaceholder': 'رمز عبور درگاه را وارد کنید', + 'website.ePayment.passwordPlaceholderSet': 'برای حفظ رمز فعلی خالی بگذارید', + 'website.ePayment.showPassword': 'نمایش رمز', + 'website.ePayment.hidePassword': 'مخفی کردن رمز', + 'website.ePayment.saveCredentials': 'ذخیره اطلاعات', + 'website.ePayment.error.credentials': + 'با فعال بودن ملت، شناسه ترمینال و نام کاربری الزامی است.', + 'website.ePayment.error.passwordRequired': + 'برای اولین بار فعال‌سازی ملت، رمز عبور الزامی است.', 'title.signIn': 'ورود', 'title.home': 'خانه', @@ -2899,11 +3343,22 @@ const fa: Record = { 'title.shoppingCards': 'کارت‌های خرید', 'title.customers': 'کاربران', 'title.customerProducts': 'محصولات مشتریان', + 'title.content': 'مدیریت محتوا', 'title.blog': 'بلاگ', 'title.myBlogs': 'بلاگ‌های من', 'title.addBlog': 'افزودن بلاگ', 'title.editBlog': 'ویرایش بلاگ', 'title.blogDetails': 'جزئیات بلاگ', + 'title.videos': 'ویدیوها', + 'title.instructions': 'آموزش‌ها', + 'title.myInstructions': 'آموزش‌های من', + 'title.addInstruction': 'افزودن آموزش', + 'title.editInstruction': 'ویرایش آموزش', + 'title.instructionDetails': 'جزئیات آموزش', + 'title.myVideos': 'ویدیوهای من', + 'title.addVideo': 'افزودن ویدیو', + 'title.editVideo': 'ویرایش ویدیو', + 'title.videoDetails': 'جزئیات ویدیو', 'title.portfolios': 'نمونه کارها', 'title.myPortfolios': 'نمونه کارهای من', 'title.addPortfolio': 'افزودن نمونه کار', @@ -3249,15 +3704,26 @@ const BREADCRUMB_LABEL_KEYS: Record = { Customers: 'nav.customers', 'Customer products': 'nav.customerProducts', Blog: 'nav.blog', + Content: 'nav.content', 'My Blogs': 'nav.blog.list', 'Add New Blog': 'nav.blog.new', 'Edit Blog': 'bc.editBlog', 'Blog Details': 'bc.blogDetails', + Videos: 'nav.videos', + Instructions: 'nav.instructions', + 'My Instructions': 'nav.instructions.list', + 'Add New Instruction': 'nav.instructions.new', + 'Edit Instruction': 'bc.editInstruction', + 'Instruction Details': 'bc.instructionDetails', Portfolios: 'nav.portfolios', 'My Portfolios': 'nav.portfolios.list', 'Add New Portfolio': 'nav.portfolios.new', 'Edit Portfolio': 'bc.editPortfolio', 'Portfolio Details': 'bc.portfolioDetails', + 'My Videos': 'nav.videos.list', + 'Add New Video': 'nav.videos.new', + 'Edit Video': 'bc.editVideo', + 'Video Details': 'bc.videoDetails', Website: 'nav.website', Sliders: 'nav.website.sliders', 'Special Categories': 'nav.website.specialCategories', @@ -3337,13 +3803,28 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR }, { match: '/invoices', labels: [t('title.finance'), t('title.invoices')] }, { match: '/transactions', labels: [t('title.finance'), t('title.transactions')] }, - { match: '/blog/list', labels: [t('title.blog'), t('title.myBlogs')] }, - { match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] }, - { match: '/blog/new', labels: [t('title.blog'), t('title.addBlog')] }, - { match: /^\/blog\/edit\/[^/]+$/, labels: [t('title.blog'), t('title.editBlog')] }, - { match: '/blog/categories', labels: [t('title.blog'), t('title.categories')] }, - { match: '/blog/settings', labels: [t('title.blog'), t('title.settings')] }, - { match: '/blog', labels: [t('title.blog')] }, + { match: '/content', labels: [t('title.content')] }, + { match: '/blog/list', labels: [t('title.content'), t('title.blog'), t('title.myBlogs')] }, + { match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.content'), t('title.blog'), t('title.blogDetails')] }, + { match: '/blog/new', labels: [t('title.content'), t('title.blog'), t('title.addBlog')] }, + { match: /^\/blog\/edit\/[^/]+$/, labels: [t('title.content'), t('title.blog'), t('title.editBlog')] }, + { match: '/blog/categories', labels: [t('title.content'), t('title.blog'), t('title.categories')] }, + { match: '/blog/settings', labels: [t('title.content'), t('title.blog'), t('title.settings')] }, + { match: '/blog', labels: [t('title.content'), t('title.blog')] }, + { match: '/videos/list', labels: [t('title.content'), t('title.videos'), t('title.myVideos')] }, + { match: /^\/videos\/detail\/[^/]+$/, labels: [t('title.content'), t('title.videos'), t('title.videoDetails')] }, + { match: '/videos/new', labels: [t('title.content'), t('title.videos'), t('title.addVideo')] }, + { match: /^\/videos\/edit\/[^/]+$/, labels: [t('title.content'), t('title.videos'), t('title.editVideo')] }, + { match: '/videos/categories', labels: [t('title.content'), t('title.videos'), t('title.categories')] }, + { match: '/videos/settings', labels: [t('title.content'), t('title.videos'), t('title.settings')] }, + { match: '/videos', labels: [t('title.content'), t('title.videos')] }, + { match: '/instructions/list', labels: [t('title.content'), t('title.instructions'), t('title.myInstructions')] }, + { match: /^\/instructions\/detail\/[^/]+$/, labels: [t('title.content'), t('title.instructions'), t('title.instructionDetails')] }, + { match: '/instructions/new', labels: [t('title.content'), t('title.instructions'), t('title.addInstruction')] }, + { match: /^\/instructions\/edit\/[^/]+$/, labels: [t('title.content'), t('title.instructions'), t('title.editInstruction')] }, + { 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: '/portfolios/list', labels: [t('title.portfolios'), t('title.myPortfolios')] }, { match: /^\/portfolios\/detail\/[^/]+$/, diff --git a/apps/business/src/pages/AddNewInstructionPage.tsx b/apps/business/src/pages/AddNewInstructionPage.tsx new file mode 100644 index 0000000..13804c8 --- /dev/null +++ b/apps/business/src/pages/AddNewInstructionPage.tsx @@ -0,0 +1,342 @@ +import { useEffect, useState } from 'react' +import { Link, useParams, useNavigate } from 'react-router-dom' +import { SearchableSelect } from '../components/SearchableSelect' +import { ImageCropper } from '../components/ImageCropper' +import { TagInput } from '../components/TagInput' +import { RichTextEditor } from '../components/RichTextEditor' +import { Breadcrumbs } from '../components/Breadcrumbs' +import { useToast } from '../context/ToastContext' +import { useT } from '../i18n/useT' +import { ApiError } from '../lib/api' +import { + listInstructionCategories, + mapInstructionCategoryToUi, +} from '../services/instructionCategoryService' +import { + resolveDataUrlToMediaId, + resolveHtmlEmbeddedDataUrls, +} from '../services/mediaService' +import { + createInstruction, + getInstruction, + mapInstructionApiToFormState, + updateInstruction, +} from '../services/instructionService' +import type { Category } from '../types/category' +import type { VideoProvider } from '../types/instruction' +import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction' +import { flattenCategories } from '../utils/categories' +import pageStyles from '../components/PageContent.module.css' +import styles from './AddNewProductPage.module.css' + +export function AddNewInstructionPage() { + 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 [videoProvider, setInstructionProvider] = useState('youtube') + const [embedCode, setEmbedCode] = useState('') + const [coverImage, setCoverImage] = useState(null) + const [tags, setTags] = useState([]) + const [featuredMediaId, setFeaturedMediaId] = useState(null) + const [loaded, setLoaded] = 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 listInstructionCategories(controller.signal) + setCategories(items.map(mapInstructionCategoryToUi)) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(t('instruction.categories.errorLoad')) + } + } + } + + void loadCategories() + return () => controller.abort() + }, []) + + useEffect(() => { + if (!isEdit || !id) return + + const controller = new AbortController() + + async function loadVideo() { + setError('') + try { + const video = await getInstruction(id!, controller.signal) + const form = mapInstructionApiToFormState(video) + setCategoryId(form.categoryId) + setTitleFa(form.titleFa) + setTitleEn(form.titleEn) + setAbstract(form.abstract) + setDescriptionHtml(form.descriptionHtml) + setInstructionProvider(form.videoProvider) + setEmbedCode(form.embedCode) + setCoverImage(form.coverImage) + setTags(form.tags) + setFeaturedMediaId(form.featuredMediaId) + setLoaded(true) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + navigate('/instructions/list', { replace: true }) + } + } + + void loadVideo() + return () => controller.abort() + }, [isEdit, id, navigate]) + + if (!loaded) return null + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setIsSubmitting(true) + setError('') + + try { + const nextFeaturedMediaId = await resolveDataUrlToMediaId( + coverImage, + 'instruction-cover-image.jpg', + featuredMediaId, + ) + const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml) + + const trimmedEmbed = embedCode.trim() + const payload = { + titleFa: titleFa.trim(), + titleEn: titleEn.trim() || undefined, + abstract: abstract.trim(), + descriptionHtml: resolvedDescriptionHtml, + videoProvider: trimmedEmbed ? videoProvider : undefined, + embedCode: trimmedEmbed || undefined, + categoryId: categoryId || undefined, + featuredMediaId: nextFeaturedMediaId ?? undefined, + tags, + status: 'published' as const, + } + + if (isEdit && id) { + await updateInstruction(id, { + ...payload, + categoryId: categoryId || null, + featuredMediaId: nextFeaturedMediaId, + }) + showToast(t('instruction.form.toast.updated'), 'success') + } else { + await createInstruction(payload) + showToast(t('instruction.form.toast.created'), 'success') + } + + navigate('/instructions/list') + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else if (err instanceof Error) { + setError(err.message) + } else { + setError(t('instruction.form.errorSave')) + } + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ +
+
+

+ {isEdit ? t('title.editInstruction') : t('title.addInstruction')} +

+

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

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