Files
dashboards/apps/business/src/components/BlogCommentsModal.tsx
T
Alireza HassaniandCursor 672091d1f5 Polish business FA layout and wire special-group keys plus SSL sync.
RTL carousels/FABs, special key+title fields, slider gallery fixes, and dashboard SSL sync agent for super-admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 00:06:44 +03:30

269 lines
8.3 KiB
TypeScript

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,
listBlogComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './ProductCommentsModal.module.css'
interface BlogCommentsModalProps {
open: boolean
blogId: string
blogTitle: string
onClose: () => void
onCountChange?: (count: number) => void
}
const ANIMATION_MS = 220
export function BlogCommentsModal({
open,
blogId,
blogTitle,
onClose,
onCountChange,
}: BlogCommentsModalProps) {
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 || !blogId) return
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [open, blogId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listBlogComments(blogId, 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="blog-comments-title"
>
<div className={styles.header}>
<div>
<h3 id="blog-comments-title" className={styles.title}>
Comments
</h3>
{blogTitle && (
<p className={styles.subtitle}>
{blogTitle} · {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 blog post.</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,
)
}