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:
co-authored by
Cursor
parent
a11f9643bb
commit
cc94b929e0
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user