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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-13 09:51:22 +03:30
co-authored by Cursor
parent a11f9643bb
commit cc94b929e0
54 changed files with 6415 additions and 96 deletions
@@ -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 (
<article className={cardStyles.card}>
<Link to={`/instructions/detail/${video.id}`} className={cardStyles.clickable}>
<div className={cardStyles.imageWrap}>
{video.coverImageUrl ? (
<img
src={video.coverImageUrl}
alt={displayTitle}
className={cardStyles.image}
loading="lazy"
/>
) : (
<div className={cardStyles.imagePlaceholder} aria-hidden="true">
<Play size={28} strokeWidth={1.5} />
</div>
)}
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
{video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'}
</span>
</div>
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
lang={titleLocale.lang}
dir={titleDir}
>
{displayTitle}
</h3>
{hasAbstract ? (
<p
className={[cardStyles.abstract, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractDir}
>
{video.abstract}
</p>
) : (
<p
className={cardStyles.abstract}
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{t('instruction.card.noSummary')}
</p>
)}
<p className={cardStyles.meta}>
<span>{formatInstructionAuthor(video.author)}</span>
<span className={cardStyles.metaDot}>·</span>
<span>{publishDate}</span>
</p>
</div>
</Link>
<div className={controlStyles.controls}>
<Tooltip label={t('instruction.card.edit')}>
<button
type="button"
onClick={() => onEdit(video.id)}
aria-label={t('instruction.card.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('instruction.card.comments')}>
<button
type="button"
className={controlStyles.iconBtn}
onClick={() => onComments(video.id)}
aria-label={`${t('instruction.card.comments')} (${commentCount})`}
>
<MessageSquare size={16} />
{commentCount > 0 && (
<span className={controlStyles.commentBadge}>{commentCount}</span>
)}
</button>
</Tooltip>
<Tooltip label={t('instruction.card.remove')}>
<button
type="button"
className={controlStyles.danger}
onClick={() => onRemove(video.id)}
aria-label={t('instruction.card.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,68 @@
import type { Category } from '../types/category'
import { BlogCategoryRow } from './BlogCategoryRow'
import { getChildren, hasChildren } from '../utils/categories'
import styles from './CategoryTree.module.css'
interface InstructionCategoryTreeProps {
categories: Category[]
expandedIds: Set<string>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
}
export function 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 (
<div key={category.id}>
<BlogCategoryRow
category={category}
depth={depth}
hasChildren={childCount}
expanded={expanded}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
/>
{childCount && (
<div className={`${styles.childrenWrap} ${expanded ? styles.expanded : ''}`}>
<div className={styles.childrenInner}>
<InstructionCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={onToggle}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
/>
</div>
</div>
)}
</div>
)
})}
</>
)
}
@@ -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<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(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(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="video-comments-title"
>
<div className={styles.header}>
<div>
<h3 id="video-comments-title" className={styles.title}>
Comments
</h3>
{instructionTitle && (
<p className={styles.subtitle}>
{instructionTitle} · {totalComments}{' '}
{totalComments === 1 ? 'comment' : 'comments'}
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this video.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
</div>
</div>,
document.body,
)
}
@@ -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<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(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 (
<section className={styles.section}>
<h3 className={styles.title}>Comments ({totalComments})</h3>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.status}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.status}>No comments yet.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</>
)}
</section>
)
}
+26 -13
View File
@@ -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(() => {
+137
View File
@@ -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 (
<article className={cardStyles.card}>
<Link to={`/videos/detail/${video.id}`} className={cardStyles.clickable}>
<div className={cardStyles.imageWrap}>
{video.coverImageUrl ? (
<img
src={video.coverImageUrl}
alt={displayTitle}
className={cardStyles.image}
loading="lazy"
/>
) : (
<div className={cardStyles.imagePlaceholder} aria-hidden="true">
<Play size={28} strokeWidth={1.5} />
</div>
)}
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
{video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'}
</span>
</div>
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
lang={titleLocale.lang}
dir={titleDir}
>
{displayTitle}
</h3>
{hasAbstract ? (
<p
className={[cardStyles.abstract, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractDir}
>
{video.abstract}
</p>
) : (
<p
className={cardStyles.abstract}
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{t('video.card.noSummary')}
</p>
)}
<p className={cardStyles.meta}>
<span>{formatVideoAuthor(video.author)}</span>
<span className={cardStyles.metaDot}>·</span>
<span>{publishDate}</span>
</p>
</div>
</Link>
<div className={controlStyles.controls}>
<Tooltip label={t('video.card.edit')}>
<button
type="button"
onClick={() => onEdit(video.id)}
aria-label={t('video.card.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('video.card.comments')}>
<button
type="button"
className={controlStyles.iconBtn}
onClick={() => onComments(video.id)}
aria-label={`${t('video.card.comments')} (${commentCount})`}
>
<MessageSquare size={16} />
{commentCount > 0 && (
<span className={controlStyles.commentBadge}>{commentCount}</span>
)}
</button>
</Tooltip>
<Tooltip label={t('video.card.remove')}>
<button
type="button"
className={controlStyles.danger}
onClick={() => onRemove(video.id)}
aria-label={t('video.card.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,68 @@
import type { Category } from '../types/category'
import { BlogCategoryRow } from './BlogCategoryRow'
import { getChildren, hasChildren } from '../utils/categories'
import styles from './CategoryTree.module.css'
interface VideoCategoryTreeProps {
categories: Category[]
expandedIds: Set<string>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
}
export function 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 (
<div key={category.id}>
<BlogCategoryRow
category={category}
depth={depth}
hasChildren={childCount}
expanded={expanded}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
/>
{childCount && (
<div className={`${styles.childrenWrap} ${expanded ? styles.expanded : ''}`}>
<div className={styles.childrenInner}>
<VideoCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={onToggle}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
/>
</div>
</div>
)}
</div>
)
})}
</>
)
}
@@ -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<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(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(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="video-comments-title"
>
<div className={styles.header}>
<div>
<h3 id="video-comments-title" className={styles.title}>
Comments
</h3>
{videoTitle && (
<p className={styles.subtitle}>
{videoTitle} · {totalComments}{' '}
{totalComments === 1 ? 'comment' : 'comments'}
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this video.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
</div>
</div>,
document.body,
)
}
@@ -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<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(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 (
<section className={styles.section}>
<h3 className={styles.title}>Comments ({totalComments})</h3>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.status}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.status}>No comments yet.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</>
)}
</section>
)
}