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
@@ -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() {
|
||||
<Route path="customer-products/new" element={<AddCustomerProductPage />} />
|
||||
<Route path="customer-products/:id/edit" element={<AddCustomerProductPage />} />
|
||||
<Route path="customer-products/:id" element={<CustomerProductDetailsPage />} />
|
||||
<Route path="content" element={<ContentPage />} />
|
||||
<Route path="blog" element={<BlogPage />} />
|
||||
<Route path="blog/list" element={<BlogListPage />} />
|
||||
<Route path="blog/detail/:id" element={<BlogDetailsPage />} />
|
||||
@@ -118,6 +132,20 @@ function App() {
|
||||
<Route path="portfolios/edit/:id" element={<AddNewPortfolioPage />} />
|
||||
<Route path="portfolios/categories" element={<PortfolioCategoriesPage />} />
|
||||
<Route path="portfolios/settings" element={<PortfolioSettingsPage />} />
|
||||
<Route path="videos" element={<VideoPage />} />
|
||||
<Route path="videos/list" element={<VideoListPage />} />
|
||||
<Route path="videos/detail/:id" element={<VideoDetailsPage />} />
|
||||
<Route path="videos/new" element={<AddNewVideoPage />} />
|
||||
<Route path="videos/edit/:id" element={<AddNewVideoPage />} />
|
||||
<Route path="videos/categories" element={<VideoCategoriesPage />} />
|
||||
<Route path="videos/settings" element={<VideoSettingsPage />} />
|
||||
<Route path="instructions" element={<InstructionPage />} />
|
||||
<Route path="instructions/list" element={<InstructionListPage />} />
|
||||
<Route path="instructions/detail/:id" element={<InstructionDetailsPage />} />
|
||||
<Route path="instructions/new" element={<AddNewInstructionPage />} />
|
||||
<Route path="instructions/edit/:id" element={<AddNewInstructionPage />} />
|
||||
<Route path="instructions/categories" element={<InstructionCategoriesPage />} />
|
||||
<Route path="instructions/settings" element={<InstructionSettingsPage />} />
|
||||
<Route path="website" element={<WebsitePage />} />
|
||||
<Route path="website/sliders" element={<WebsiteSlidersPage />} />
|
||||
<Route path="website/special-categories" element={<WebsiteSpecialCategoriesPage />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<string, MessageKey> = {
|
||||
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\/[^/]+$/,
|
||||
|
||||
@@ -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<Category[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleFa, setTitleFa] = useState('')
|
||||
const [titleEn, setTitleEn] = useState('')
|
||||
const [abstract, setAbstract] = useState('')
|
||||
const [descriptionHtml, setDescriptionHtml] = useState('')
|
||||
const [videoProvider, setInstructionProvider] = useState<VideoProvider>('youtube')
|
||||
const [embedCode, setEmbedCode] = useState('')
|
||||
const [coverImage, setCoverImage] = useState<string | null>(null)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={
|
||||
isEdit
|
||||
? [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Instructions', href: '/instructions' },
|
||||
{ label: 'My Instructions', href: '/instructions/list' },
|
||||
{ label: 'Edit Instruction' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Instructions', href: '/instructions' },
|
||||
{ label: 'Add New Instruction' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? t('title.editInstruction') : t('title.addInstruction')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit ? t('instruction.form.edit.subtitle') : t('instruction.form.add.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4}`}>
|
||||
<label>{t('instruction.form.coverImage')}</label>
|
||||
<ImageCropper
|
||||
value={coverImage}
|
||||
onChange={setCoverImage}
|
||||
aspect={3 / 2}
|
||||
uploadLabel={t('instruction.form.uploadCoverImage')}
|
||||
hint={t('instruction.form.cropHint')}
|
||||
changeLabel={t('instruction.form.changeCoverImage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formColumn}>
|
||||
<div className={styles.field}>
|
||||
<label>{t('instruction.form.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t('instruction.form.categoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="instruction-title-fa">{t('instruction.form.titleFa')}</label>
|
||||
<input
|
||||
id="instruction-title-fa"
|
||||
name="titleFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
className="faText"
|
||||
placeholder={t('instruction.form.titleFaPlaceholder')}
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="instruction-title-en">{t('instruction.form.titleEn')}</label>
|
||||
<input
|
||||
id="instruction-title-en"
|
||||
name="titleEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder={t('instruction.form.titleEnPlaceholder')}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="instruction-abstract">{t('instruction.form.abstract')}</label>
|
||||
<textarea
|
||||
id="instruction-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder={t('instruction.form.abstractPlaceholder')}
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.videoEmbedRow}>
|
||||
<div className={styles.providerPanel}>
|
||||
<span className={styles.providerLabel}>{t('instruction.form.provider')}</span>
|
||||
<div
|
||||
className={styles.providerRadioGroup}
|
||||
role="radiogroup"
|
||||
aria-label={t('instruction.form.provider')}
|
||||
>
|
||||
{VIDEO_PROVIDER_OPTIONS.map((option) => (
|
||||
<label key={option.value} className={styles.providerRadioOption}>
|
||||
<input
|
||||
type="radio"
|
||||
name="videoProvider"
|
||||
value={option.value}
|
||||
checked={videoProvider === option.value}
|
||||
onChange={() => setInstructionProvider(option.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.embedPanel}>
|
||||
<label htmlFor="instruction-embed">{t('instruction.form.embedCode')}</label>
|
||||
<textarea
|
||||
id="instruction-embed"
|
||||
name="embedCode"
|
||||
rows={5}
|
||||
dir="ltr"
|
||||
placeholder={t('instruction.form.embedCodePlaceholder')}
|
||||
value={embedCode}
|
||||
onChange={(e) => setEmbedCode(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('instruction.form.description')}</label>
|
||||
<RichTextEditor
|
||||
value={descriptionHtml}
|
||||
onChange={setDescriptionHtml}
|
||||
placeholder={t('instruction.form.descriptionPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={320}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('instruction.form.tags')}</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/instructions/list" className={styles.cancelBtn}>
|
||||
{t('instruction.form.cancel')}
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t('instruction.form.saving')
|
||||
: isEdit
|
||||
? t('instruction.form.update')
|
||||
: t('instruction.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -258,6 +258,100 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.videoEmbedRow {
|
||||
grid-column: span 12;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, 180px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.providerPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.providerLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.providerRadioGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providerRadioOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.providerRadioOption:has(input:checked) {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.providerRadioOption:has(input:disabled) {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.providerRadioOption input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.embedPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embedPanel label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.embedPanel textarea {
|
||||
width: 100%;
|
||||
min-height: calc(var(--field-height) * 2 + 24px);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.embedPanel textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form {
|
||||
padding: 20px 16px;
|
||||
@@ -287,6 +381,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.videoEmbedRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fieldFull,
|
||||
.fieldHalf,
|
||||
.fieldCategory,
|
||||
|
||||
@@ -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 {
|
||||
listVideoCategories,
|
||||
mapVideoCategoryToUi,
|
||||
} from '../services/videoCategoryService'
|
||||
import {
|
||||
resolveDataUrlToMediaId,
|
||||
resolveHtmlEmbeddedDataUrls,
|
||||
} from '../services/mediaService'
|
||||
import {
|
||||
createVideo,
|
||||
getVideo,
|
||||
mapVideoApiToFormState,
|
||||
updateVideo,
|
||||
} from '../services/videoService'
|
||||
import type { Category } from '../types/category'
|
||||
import type { VideoProvider } from '../types/video'
|
||||
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
|
||||
import { flattenCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
|
||||
export function AddNewVideoPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleFa, setTitleFa] = useState('')
|
||||
const [titleEn, setTitleEn] = useState('')
|
||||
const [abstract, setAbstract] = useState('')
|
||||
const [descriptionHtml, setDescriptionHtml] = useState('')
|
||||
const [videoProvider, setVideoProvider] = useState<VideoProvider>('youtube')
|
||||
const [embedCode, setEmbedCode] = useState('')
|
||||
const [coverImage, setCoverImage] = useState<string | null>(null)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(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 listVideoCategories(controller.signal)
|
||||
setCategories(items.map(mapVideoCategoryToUi))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.categories.errorLoad'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCategories()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit || !id) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadVideo() {
|
||||
setError('')
|
||||
try {
|
||||
const video = await getVideo(id!, controller.signal)
|
||||
const form = mapVideoApiToFormState(video)
|
||||
setCategoryId(form.categoryId)
|
||||
setTitleFa(form.titleFa)
|
||||
setTitleEn(form.titleEn)
|
||||
setAbstract(form.abstract)
|
||||
setDescriptionHtml(form.descriptionHtml)
|
||||
setVideoProvider(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('/videos/list', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
void loadVideo()
|
||||
return () => controller.abort()
|
||||
}, [isEdit, id, navigate])
|
||||
|
||||
if (!loaded) return null
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
|
||||
coverImage,
|
||||
'video-cover-image.jpg',
|
||||
featuredMediaId,
|
||||
)
|
||||
const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml)
|
||||
|
||||
const payload = {
|
||||
titleFa: titleFa.trim(),
|
||||
titleEn: titleEn.trim() || undefined,
|
||||
abstract: abstract.trim(),
|
||||
descriptionHtml: resolvedDescriptionHtml,
|
||||
videoProvider,
|
||||
embedCode: embedCode.trim(),
|
||||
categoryId: categoryId || undefined,
|
||||
featuredMediaId: nextFeaturedMediaId ?? undefined,
|
||||
tags,
|
||||
status: 'published' as const,
|
||||
}
|
||||
|
||||
if (isEdit && id) {
|
||||
await updateVideo(id, {
|
||||
...payload,
|
||||
categoryId: categoryId || null,
|
||||
featuredMediaId: nextFeaturedMediaId,
|
||||
})
|
||||
showToast(t('video.form.toast.updated'), 'success')
|
||||
} else {
|
||||
await createVideo(payload)
|
||||
showToast(t('video.form.toast.created'), 'success')
|
||||
}
|
||||
|
||||
navigate('/videos/list')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.form.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={
|
||||
isEdit
|
||||
? [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Videos', href: '/videos' },
|
||||
{ label: 'My Videos', href: '/videos/list' },
|
||||
{ label: 'Edit Video' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Videos', href: '/videos' },
|
||||
{ label: 'Add New Video' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? t('title.editVideo') : t('title.addVideo')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit ? t('video.form.edit.subtitle') : t('video.form.add.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4}`}>
|
||||
<label>{t('video.form.coverImage')}</label>
|
||||
<ImageCropper
|
||||
value={coverImage}
|
||||
onChange={setCoverImage}
|
||||
aspect={3 / 2}
|
||||
uploadLabel={t('video.form.uploadCoverImage')}
|
||||
hint={t('video.form.cropHint')}
|
||||
changeLabel={t('video.form.changeCoverImage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formColumn}>
|
||||
<div className={styles.field}>
|
||||
<label>{t('video.form.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t('video.form.categoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="video-title-fa">{t('video.form.titleFa')}</label>
|
||||
<input
|
||||
id="video-title-fa"
|
||||
name="titleFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
className="faText"
|
||||
placeholder={t('video.form.titleFaPlaceholder')}
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="video-title-en">{t('video.form.titleEn')}</label>
|
||||
<input
|
||||
id="video-title-en"
|
||||
name="titleEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder={t('video.form.titleEnPlaceholder')}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="video-abstract">{t('video.form.abstract')}</label>
|
||||
<textarea
|
||||
id="video-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder={t('video.form.abstractPlaceholder')}
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.videoEmbedRow}>
|
||||
<div className={styles.providerPanel}>
|
||||
<span className={styles.providerLabel}>{t('video.form.provider')}</span>
|
||||
<div
|
||||
className={styles.providerRadioGroup}
|
||||
role="radiogroup"
|
||||
aria-label={t('video.form.provider')}
|
||||
>
|
||||
{VIDEO_PROVIDER_OPTIONS.map((option) => (
|
||||
<label key={option.value} className={styles.providerRadioOption}>
|
||||
<input
|
||||
type="radio"
|
||||
name="videoProvider"
|
||||
value={option.value}
|
||||
checked={videoProvider === option.value}
|
||||
onChange={() => setVideoProvider(option.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.embedPanel}>
|
||||
<label htmlFor="video-embed">{t('video.form.embedCode')}</label>
|
||||
<textarea
|
||||
id="video-embed"
|
||||
name="embedCode"
|
||||
rows={5}
|
||||
dir="ltr"
|
||||
placeholder={t('video.form.embedCodePlaceholder')}
|
||||
value={embedCode}
|
||||
onChange={(e) => setEmbedCode(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('video.form.description')}</label>
|
||||
<RichTextEditor
|
||||
value={descriptionHtml}
|
||||
onChange={setDescriptionHtml}
|
||||
placeholder={t('video.form.descriptionPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={320}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('video.form.tags')}</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/videos/list" className={styles.cancelBtn}>
|
||||
{t('video.form.cancel')}
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t('video.form.saving')
|
||||
: isEdit
|
||||
? t('video.form.update')
|
||||
: t('video.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export function BlogPage() {
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Content', href: '/content' },
|
||||
{ label: 'Blog' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FileText, Play, GraduationCap } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { hasBusinessModule } from '../utils/businessModules'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const contentSections: {
|
||||
icon: typeof FileText
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
moduleId: 'blog' | 'videos' | 'instructions'
|
||||
}[] = [
|
||||
{
|
||||
icon: FileText,
|
||||
titleKey: 'nav.content.blog',
|
||||
descKey: 'content.card.blog.desc',
|
||||
linkKey: 'content.card.blog.link',
|
||||
href: '/blog',
|
||||
moduleId: 'blog',
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
titleKey: 'nav.content.instructions',
|
||||
descKey: 'content.card.instructions.desc',
|
||||
linkKey: 'content.card.instructions.link',
|
||||
href: '/instructions',
|
||||
moduleId: 'instructions',
|
||||
},
|
||||
]
|
||||
|
||||
export function ContentPage() {
|
||||
const t = useT()
|
||||
const { enabledModules } = useTenantBranding()
|
||||
|
||||
const visibleSections = contentSections.filter((section) =>
|
||||
hasBusinessModule(enabledModules, section.moduleId),
|
||||
)
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Content' },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="CONTENT">{t('title.content')}</PageTitle>
|
||||
<p className={styles.pageSubtitle}>{t('content.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{visibleSections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
ShoppingBag,
|
||||
Store,
|
||||
Users,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Layers,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
@@ -19,15 +19,18 @@ import { listStoreItems } from '../services/storeItemService'
|
||||
import { listCustomers } from '../services/customerService'
|
||||
import { listBlogs } from '../services/blogService'
|
||||
import { listPortfolios } from '../services/portfolioService'
|
||||
import { listVideos } from '../services/videoService'
|
||||
import { listInstructions } from '../services/instructionService'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import {
|
||||
hasBusinessModule,
|
||||
hasAnyBusinessModule,
|
||||
isActiveHomeChart,
|
||||
type BusinessModuleId,
|
||||
} from '../utils/businessModules'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios'
|
||||
type CountKey = 'products' | 'store' | 'customers' | 'content' | 'portfolios'
|
||||
|
||||
const sections: {
|
||||
icon: typeof ShoppingBag
|
||||
@@ -39,6 +42,8 @@ const sections: {
|
||||
countKey?: CountKey
|
||||
/** When set, card is shown only if this optional module is enabled. */
|
||||
moduleId?: BusinessModuleId
|
||||
/** When set, card is shown if any listed module is enabled. */
|
||||
anyModuleIds?: BusinessModuleId[]
|
||||
}[] = [
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
@@ -78,14 +83,14 @@ const sections: {
|
||||
moduleId: 'finance',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
titleKey: 'home.card.blog.title',
|
||||
descKey: 'home.card.blog.desc',
|
||||
linkKey: 'home.card.blog.link',
|
||||
countLabelKey: 'home.card.blog.count',
|
||||
href: '/blog',
|
||||
countKey: 'blog',
|
||||
moduleId: 'blog',
|
||||
icon: Layers,
|
||||
titleKey: 'home.card.content.title',
|
||||
descKey: 'home.card.content.desc',
|
||||
linkKey: 'home.card.content.link',
|
||||
countLabelKey: 'home.card.content.count',
|
||||
href: '/content',
|
||||
countKey: 'content',
|
||||
anyModuleIds: ['blog', 'videos', 'instructions'],
|
||||
},
|
||||
{
|
||||
icon: Briefcase,
|
||||
@@ -102,20 +107,23 @@ const sections: {
|
||||
type SectionCounts = Partial<Record<CountKey, number>>
|
||||
|
||||
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
const [products, store, customers, blog, portfolios] = await Promise.all([
|
||||
const [products, store, customers, blog, portfolios, videos, instructions] = await Promise.all([
|
||||
listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listStoreItems(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
|
||||
listBlogs(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listVideos(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listInstructions(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
])
|
||||
|
||||
const counts: SectionCounts = {}
|
||||
if (products !== null) counts.products = products
|
||||
if (store !== null) counts.store = store
|
||||
if (customers !== null) counts.customers = customers
|
||||
if (blog !== null) counts.blog = blog
|
||||
if (portfolios !== null) counts.portfolios = portfolios
|
||||
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0)
|
||||
if (blog !== null || videos !== null || instructions !== null) counts.content = contentTotal
|
||||
return counts
|
||||
}
|
||||
|
||||
@@ -134,10 +142,12 @@ export function HomePage() {
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const visibleSections = sections.filter(
|
||||
(section) =>
|
||||
!section.moduleId || hasBusinessModule(enabledModules, section.moduleId),
|
||||
)
|
||||
const visibleSections = sections.filter((section) => {
|
||||
if (section.anyModuleIds) {
|
||||
return hasAnyBusinessModule(enabledModules, section.anyModuleIds)
|
||||
}
|
||||
return !section.moduleId || hasBusinessModule(enabledModules, section.moduleId)
|
||||
})
|
||||
const visibleCharts = homeCharts.filter(isActiveHomeChart)
|
||||
|
||||
const firstName =
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { CategoryModal } from '../components/CategoryModal'
|
||||
import { InstructionCategoryTree } from '../components/InstructionCategoryTree'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import {
|
||||
createInstructionCategory,
|
||||
deleteInstructionCategory,
|
||||
listInstructionCategories,
|
||||
mapInstructionCategoryToUi,
|
||||
toInstructionCategoryFormPayload,
|
||||
updateInstructionCategory,
|
||||
} from '../services/instructionCategoryService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
export function InstructionCategoriesPage() {
|
||||
const t = useT()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [defaultParentId, setDefaultParentId] = useState('')
|
||||
const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadCategories(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadCategories(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const items = await listInstructionCategories(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'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal(parentId = '', title?: string) {
|
||||
setEditingCategory(null)
|
||||
setDefaultParentId(parentId)
|
||||
setModalTitle(title ?? t('categories.add'))
|
||||
setModalOpen(true)
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId))
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle(t('categories.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(data: CategoryFormData) {
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = toInstructionCategoryFormPayload(data)
|
||||
|
||||
if (editingCategory) {
|
||||
const result = await updateInstructionCategory(editingCategory.id, {
|
||||
...payload,
|
||||
parentId: data.parentId || null,
|
||||
})
|
||||
setCategories((prev) =>
|
||||
prev.map((category) =>
|
||||
category.id === editingCategory.id
|
||||
? mapInstructionCategoryToUi(result.category)
|
||||
: category,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const result = await createInstructionCategory(payload)
|
||||
setCategories((prev) => [...prev, mapInstructionCategoryToUi(result.category)])
|
||||
if (data.parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(data.parentId))
|
||||
}
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('instruction.categories.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await deleteInstructionCategory(deleteTarget.id)
|
||||
const removed = new Set(result.deletedIds)
|
||||
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
removed.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('instruction.categories.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Instructions', href: '/instructions' },
|
||||
{ label: 'Categories' },
|
||||
]}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="INSTRUCTION CATEGORIES">{t('instruction.categories.title')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('instruction.categories.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>{t('instruction.categories.empty')}</p>
|
||||
) : (
|
||||
<InstructionCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
onClose={() => {
|
||||
if (!isSubmitting) {
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
}
|
||||
}}
|
||||
title={modalTitle}
|
||||
defaultParentId={defaultParentId}
|
||||
categories={categories}
|
||||
editingCategory={editingCategory}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('categories.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className={styles.fabDock}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label={t('categories.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { CalendarDays, User } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { InstructionCommentsSection } from '../components/InstructionCommentsSection'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
formatInstructionAuthor,
|
||||
formatInstructionDate,
|
||||
getInstructionDetail,
|
||||
resolveInstructionTitle,
|
||||
} from '../services/instructionService'
|
||||
import type { InstructionDetail } from '../types/instruction'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogDetailsPage.module.css'
|
||||
|
||||
export function InstructionDetailsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { id } = useParams()
|
||||
const [video, setInstruction] = useState<InstructionDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadVideo() {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getInstructionDetail(id!, controller.signal)
|
||||
setInstruction(data)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('instruction.details.errorLoad'))
|
||||
}
|
||||
setInstruction(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadVideo()
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>{t('instruction.details.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !video || !id) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.error}>{error || t('instruction.details.notFound')}</p>
|
||||
<Link to="/instructions/list" className={styles.backLink}>
|
||||
{t('instruction.details.back')}
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const displayTitle = resolveInstructionTitle(video, locale === 'fa' ? 'fa' : 'en')
|
||||
const displayDate = formatInstructionDate(
|
||||
video.publishedAt ?? video.createdAt,
|
||||
locale === 'fa' ? 'fa' : 'en',
|
||||
)
|
||||
const titleLocale = textLocaleAttrs(displayTitle)
|
||||
const abstractLocale = textLocaleAttrs(video.abstract)
|
||||
const contentLocale = textLocaleAttrs(
|
||||
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
|
||||
)
|
||||
const providerLabel =
|
||||
video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Instructions', href: '/instructions' },
|
||||
{ label: 'My Instructions', href: '/instructions/list' },
|
||||
{ label: displayTitle },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2
|
||||
className={[pageStyles.pageTitle, titleLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={titleLocale.lang}
|
||||
dir={titleLocale.dir}
|
||||
>
|
||||
{displayTitle}
|
||||
</h2>
|
||||
{video.abstract ? (
|
||||
<p
|
||||
className={[pageStyles.pageSubtitleContent, abstractLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
>
|
||||
{video.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className={styles.blogDetail}>
|
||||
<div className={styles.heroImageWrap}>
|
||||
{video.coverImageUrl ? (
|
||||
<img
|
||||
src={video.coverImageUrl}
|
||||
alt={displayTitle}
|
||||
className={styles.heroImage}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.heroPlaceholder} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaItem}>
|
||||
<CalendarDays size={15} />
|
||||
{displayDate}
|
||||
</span>
|
||||
{(providerLabel || video.categoryName) && (
|
||||
<>
|
||||
<span className={styles.metaDot}>·</span>
|
||||
<div className={styles.metaChips}>
|
||||
{providerLabel && (
|
||||
<span className={styles.typeChip}>{providerLabel}</span>
|
||||
)}
|
||||
{video.categoryName && (
|
||||
<span className={styles.categoryChip}>{video.categoryName}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{video.embedCode ? (
|
||||
<div
|
||||
className={styles.contentPanel}
|
||||
dir="ltr"
|
||||
dangerouslySetInnerHTML={{ __html: video.embedCode }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={styles.contentPanel}>
|
||||
{video.descriptionHtml ? (
|
||||
<div
|
||||
className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
|
||||
lang={contentLocale.lang}
|
||||
dir={contentLocale.dir}
|
||||
dangerouslySetInnerHTML={{ __html: video.descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content} lang="en" dir="ltr">
|
||||
{t('instruction.details.noDescription')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.authorRow}>
|
||||
<User size={16} />
|
||||
<span className={styles.authorLabel}>{t('instruction.details.author')}</span>
|
||||
<span className={styles.authorName}>{formatInstructionAuthor(video.author)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InstructionCommentsSection instructionId={id} />
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { InstructionCard } from '../components/InstructionCard'
|
||||
import { InstructionCommentsModal } from '../components/InstructionCommentsModal'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
INSTRUCTIONS_PER_PAGE,
|
||||
deleteInstruction,
|
||||
listInstructions,
|
||||
resolveInstructionTitle,
|
||||
} from '../services/instructionService'
|
||||
import type { Video } from '../types/instruction'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogPage.module.css'
|
||||
|
||||
export function InstructionListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [videos, setInstructions] = useState<Video[]>([])
|
||||
const [totalInstructions, setTotalVideos] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalInstructions / INSTRUCTIONS_PER_PAGE))
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadVideos(currentPage, controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [currentPage])
|
||||
|
||||
async function loadVideos(page: number, signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listInstructions(page, INSTRUCTIONS_PER_PAGE, signal)
|
||||
setInstructions(data.items)
|
||||
setTotalVideos(data.total)
|
||||
setCommentCounts(
|
||||
Object.fromEntries(data.items.map((video) => [video.id, video.commentCount ?? 0])),
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('instruction.list.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(id: string) {
|
||||
navigate(`/instructions/edit/${id}`)
|
||||
}
|
||||
|
||||
function handleComments(id: string) {
|
||||
const video = videos.find((item) => item.id === id)
|
||||
if (video) {
|
||||
setCommentsTarget({
|
||||
id,
|
||||
title: resolveInstructionTitle(video, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveRequest(id: string) {
|
||||
const video = videos.find((item) => item.id === id)
|
||||
if (video) {
|
||||
setDeleteTarget({
|
||||
id,
|
||||
title: resolveInstructionTitle(video, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsDeleting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await deleteInstruction(deleteTarget.id)
|
||||
showToast(t('instruction.list.toast.removed'), 'success')
|
||||
const nextTotal = totalInstructions - 1
|
||||
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / INSTRUCTIONS_PER_PAGE))
|
||||
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
||||
setDeleteTarget(null)
|
||||
setCurrentPage(nextPage)
|
||||
await loadVideos(nextPage)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('instruction.list.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setCurrentPage(page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function handleCommentCountChange(count: number) {
|
||||
if (!commentsTarget) return
|
||||
setCommentCounts((prev) => ({
|
||||
...prev,
|
||||
[commentsTarget.id]: count,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Instructions', href: '/instructions' },
|
||||
{ label: 'My Instructions' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="MY INSTRUCTIONS">{t('title.myInstructions')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{t('instruction.list.subtitle', { count: totalInstructions })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('instruction.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon} aria-hidden="true">
|
||||
<Play size={28} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>{t('instruction.list.empty')}</h3>
|
||||
<p className={styles.emptyHint}>{t('instruction.list.emptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.emptyCta}
|
||||
onClick={() => navigate('/instructions/new')}
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.25} />
|
||||
{t('instruction.list.emptyCta')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
{videos.map((video) => (
|
||||
<InstructionCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
commentCount={commentCounts[video.id] ?? video.commentCount}
|
||||
onEdit={handleEdit}
|
||||
onComments={handleComments}
|
||||
onRemove={handleRemoveRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => navigate('/instructions/new')}
|
||||
aria-label={t('instruction.list.addNew')}
|
||||
>
|
||||
<Plus size={26} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('instruction.list.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('instruction.list.deleteMessage', { name: deleteTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isDeleting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{commentsTarget && (
|
||||
<InstructionCommentsModal
|
||||
open={!!commentsTarget}
|
||||
instructionId={commentsTarget.id}
|
||||
instructionTitle={commentsTarget.title}
|
||||
onClose={() => setCommentsTarget(null)}
|
||||
onCountChange={handleCommentCountChange}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { GraduationCap, PlusCircle, FolderTree, Settings } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const instructionSections: {
|
||||
icon: typeof GraduationCap
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: GraduationCap,
|
||||
titleKey: 'nav.instructions.list',
|
||||
descKey: 'instruction.card.list.desc',
|
||||
linkKey: 'instruction.card.list.link',
|
||||
href: '/instructions/list',
|
||||
},
|
||||
{
|
||||
icon: PlusCircle,
|
||||
titleKey: 'nav.instructions.new',
|
||||
descKey: 'instruction.card.new.desc',
|
||||
linkKey: 'instruction.card.new.link',
|
||||
href: '/instructions/new',
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
titleKey: 'instruction.card.categories.title',
|
||||
descKey: 'instruction.card.categories.desc',
|
||||
linkKey: 'instruction.card.categories.link',
|
||||
href: '/instructions/categories',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
titleKey: 'nav.instructions.settings',
|
||||
descKey: 'instruction.card.settings.desc',
|
||||
linkKey: 'instruction.card.settings.link',
|
||||
href: '/instructions/settings',
|
||||
},
|
||||
]
|
||||
|
||||
export function InstructionPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Content', href: '/content' },
|
||||
{ label: 'Instructions' },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="INSTRUCTIONS">{t('title.instructions')}</PageTitle>
|
||||
<p className={styles.pageSubtitle}>{t('instruction.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{instructionSections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getSettings,
|
||||
updateDashboardSettings,
|
||||
type DashboardSettings,
|
||||
} from '../services/settingsService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './ProductSettingsPage.module.css'
|
||||
|
||||
const DEFAULT_SETTINGS: DashboardSettings = {
|
||||
comments: { autoApprove: false },
|
||||
expertReviews: { autoApprove: false },
|
||||
}
|
||||
|
||||
export function InstructionSettingsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSettings(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadSettings(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getSettings(signal)
|
||||
setSettings(data.settings.dashboard)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommentsAutoApprove(checked: boolean) {
|
||||
setSavingKey('comments')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await updateDashboardSettings({
|
||||
comments: { autoApprove: checked },
|
||||
})
|
||||
setSettings(data.settings.dashboard)
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Instructions', href: '/instructions' },
|
||||
{ label: 'Settings' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="INSTRUCTION SETTINGS">{t('instruction.settings.title')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('instruction.settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.alertError} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.status}>{t('productSettings.loading')}</p>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowText}>
|
||||
<label htmlFor="video-comments-auto-approve" className={styles.rowLabel}>
|
||||
{t('instruction.settings.commentsAuto')}
|
||||
</label>
|
||||
<p className={styles.rowDescription}>{t('instruction.settings.commentsAutoDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="video-comments-auto-approve"
|
||||
checked={settings.comments.autoApprove}
|
||||
disabled={savingKey === 'comments'}
|
||||
aria-label={t('instruction.settings.commentsAutoAria')}
|
||||
onChange={(checked) => void handleCommentsAutoApprove(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,23 @@ import styles from './StoreSettingsPage.module.css'
|
||||
const DEFAULT_STORE_SETTINGS: StoreSettings = {
|
||||
onlineSellEnabled: true,
|
||||
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
||||
ePayment: {
|
||||
enabled: false,
|
||||
defaultGateway: null,
|
||||
gateways: {
|
||||
mellat: {
|
||||
enabled: false,
|
||||
terminalId: '',
|
||||
username: '',
|
||||
password: '',
|
||||
passwordSet: false,
|
||||
},
|
||||
sep: { enabled: false },
|
||||
snappay: { enabled: false },
|
||||
digipay: { enabled: false },
|
||||
zarinpal: { enabled: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function normalizeSteps(steps: OrderProcessStep[]) {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { CategoryModal } from '../components/CategoryModal'
|
||||
import { VideoCategoryTree } from '../components/VideoCategoryTree'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import {
|
||||
createVideoCategory,
|
||||
deleteVideoCategory,
|
||||
listVideoCategories,
|
||||
mapVideoCategoryToUi,
|
||||
toVideoCategoryFormPayload,
|
||||
updateVideoCategory,
|
||||
} from '../services/videoCategoryService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
export function VideoCategoriesPage() {
|
||||
const t = useT()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [defaultParentId, setDefaultParentId] = useState('')
|
||||
const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadCategories(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadCategories(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const items = await listVideoCategories(signal)
|
||||
setCategories(items.map(mapVideoCategoryToUi))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.categories.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal(parentId = '', title?: string) {
|
||||
setEditingCategory(null)
|
||||
setDefaultParentId(parentId)
|
||||
setModalTitle(title ?? t('categories.add'))
|
||||
setModalOpen(true)
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId))
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle(t('categories.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(data: CategoryFormData) {
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = toVideoCategoryFormPayload(data)
|
||||
|
||||
if (editingCategory) {
|
||||
const result = await updateVideoCategory(editingCategory.id, {
|
||||
...payload,
|
||||
parentId: data.parentId || null,
|
||||
})
|
||||
setCategories((prev) =>
|
||||
prev.map((category) =>
|
||||
category.id === editingCategory.id
|
||||
? mapVideoCategoryToUi(result.category)
|
||||
: category,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const result = await createVideoCategory(payload)
|
||||
setCategories((prev) => [...prev, mapVideoCategoryToUi(result.category)])
|
||||
if (data.parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(data.parentId))
|
||||
}
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.categories.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await deleteVideoCategory(deleteTarget.id)
|
||||
const removed = new Set(result.deletedIds)
|
||||
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
removed.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.categories.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Videos', href: '/videos' },
|
||||
{ label: 'Categories' },
|
||||
]}
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="VIDEO CATEGORIES">{t('video.categories.title')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('video.categories.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>{t('video.categories.empty')}</p>
|
||||
) : (
|
||||
<VideoCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
onClose={() => {
|
||||
if (!isSubmitting) {
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
}
|
||||
}}
|
||||
title={modalTitle}
|
||||
defaultParentId={defaultParentId}
|
||||
categories={categories}
|
||||
editingCategory={editingCategory}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('categories.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className={styles.fabDock}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label={t('categories.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { CalendarDays, User } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { VideoCommentsSection } from '../components/VideoCommentsSection'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
formatVideoAuthor,
|
||||
formatVideoDate,
|
||||
getVideoDetail,
|
||||
resolveVideoTitle,
|
||||
} from '../services/videoService'
|
||||
import type { VideoDetail } from '../types/video'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogDetailsPage.module.css'
|
||||
|
||||
export function VideoDetailsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { id } = useParams()
|
||||
const [video, setVideo] = useState<VideoDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadVideo() {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getVideoDetail(id!, controller.signal)
|
||||
setVideo(data)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.details.errorLoad'))
|
||||
}
|
||||
setVideo(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadVideo()
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>{t('video.details.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !video || !id) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.error}>{error || t('video.details.notFound')}</p>
|
||||
<Link to="/videos/list" className={styles.backLink}>
|
||||
{t('video.details.back')}
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const displayTitle = resolveVideoTitle(video, locale === 'fa' ? 'fa' : 'en')
|
||||
const displayDate = formatVideoDate(
|
||||
video.publishedAt ?? video.createdAt,
|
||||
locale === 'fa' ? 'fa' : 'en',
|
||||
)
|
||||
const titleLocale = textLocaleAttrs(displayTitle)
|
||||
const abstractLocale = textLocaleAttrs(video.abstract)
|
||||
const contentLocale = textLocaleAttrs(
|
||||
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
|
||||
)
|
||||
const providerLabel =
|
||||
video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Videos', href: '/videos' },
|
||||
{ label: 'My Videos', href: '/videos/list' },
|
||||
{ label: displayTitle },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2
|
||||
className={[pageStyles.pageTitle, titleLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={titleLocale.lang}
|
||||
dir={titleLocale.dir}
|
||||
>
|
||||
{displayTitle}
|
||||
</h2>
|
||||
{video.abstract ? (
|
||||
<p
|
||||
className={[pageStyles.pageSubtitleContent, abstractLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
>
|
||||
{video.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className={styles.blogDetail}>
|
||||
<div className={styles.heroImageWrap}>
|
||||
{video.coverImageUrl ? (
|
||||
<img
|
||||
src={video.coverImageUrl}
|
||||
alt={displayTitle}
|
||||
className={styles.heroImage}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.heroPlaceholder} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaItem}>
|
||||
<CalendarDays size={15} />
|
||||
{displayDate}
|
||||
</span>
|
||||
{(providerLabel || video.categoryName) && (
|
||||
<>
|
||||
<span className={styles.metaDot}>·</span>
|
||||
<div className={styles.metaChips}>
|
||||
{providerLabel && (
|
||||
<span className={styles.typeChip}>{providerLabel}</span>
|
||||
)}
|
||||
{video.categoryName && (
|
||||
<span className={styles.categoryChip}>{video.categoryName}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{video.embedCode ? (
|
||||
<div
|
||||
className={styles.contentPanel}
|
||||
dir="ltr"
|
||||
dangerouslySetInnerHTML={{ __html: video.embedCode }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={styles.contentPanel}>
|
||||
{video.descriptionHtml ? (
|
||||
<div
|
||||
className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
|
||||
lang={contentLocale.lang}
|
||||
dir={contentLocale.dir}
|
||||
dangerouslySetInnerHTML={{ __html: video.descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content} lang="en" dir="ltr">
|
||||
{t('video.details.noDescription')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.authorRow}>
|
||||
<User size={16} />
|
||||
<span className={styles.authorLabel}>{t('video.details.author')}</span>
|
||||
<span className={styles.authorName}>{formatVideoAuthor(video.author)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoCommentsSection videoId={id} />
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { VideoCard } from '../components/VideoCard'
|
||||
import { VideoCommentsModal } from '../components/VideoCommentsModal'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
VIDEOS_PER_PAGE,
|
||||
deleteVideo,
|
||||
listVideos,
|
||||
resolveVideoTitle,
|
||||
} from '../services/videoService'
|
||||
import type { Video } from '../types/video'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogPage.module.css'
|
||||
|
||||
export function VideoListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [videos, setVideos] = useState<Video[]>([])
|
||||
const [totalVideos, setTotalVideos] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalVideos / VIDEOS_PER_PAGE))
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadVideos(currentPage, controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [currentPage])
|
||||
|
||||
async function loadVideos(page: number, signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listVideos(page, VIDEOS_PER_PAGE, signal)
|
||||
setVideos(data.items)
|
||||
setTotalVideos(data.total)
|
||||
setCommentCounts(
|
||||
Object.fromEntries(data.items.map((video) => [video.id, video.commentCount ?? 0])),
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.list.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(id: string) {
|
||||
navigate(`/videos/edit/${id}`)
|
||||
}
|
||||
|
||||
function handleComments(id: string) {
|
||||
const video = videos.find((item) => item.id === id)
|
||||
if (video) {
|
||||
setCommentsTarget({
|
||||
id,
|
||||
title: resolveVideoTitle(video, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveRequest(id: string) {
|
||||
const video = videos.find((item) => item.id === id)
|
||||
if (video) {
|
||||
setDeleteTarget({
|
||||
id,
|
||||
title: resolveVideoTitle(video, locale === 'fa' ? 'fa' : 'en'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setIsDeleting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await deleteVideo(deleteTarget.id)
|
||||
showToast(t('video.list.toast.removed'), 'success')
|
||||
const nextTotal = totalVideos - 1
|
||||
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / VIDEOS_PER_PAGE))
|
||||
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
||||
setDeleteTarget(null)
|
||||
setCurrentPage(nextPage)
|
||||
await loadVideos(nextPage)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('video.list.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setCurrentPage(page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function handleCommentCountChange(count: number) {
|
||||
if (!commentsTarget) return
|
||||
setCommentCounts((prev) => ({
|
||||
...prev,
|
||||
[commentsTarget.id]: count,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Videos', href: '/videos' },
|
||||
{ label: 'My Videos' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="VIDEOS">{t('title.myVideos')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{t('video.list.subtitle', { count: totalVideos })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('video.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon} aria-hidden="true">
|
||||
<Play size={28} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>{t('video.list.empty')}</h3>
|
||||
<p className={styles.emptyHint}>{t('video.list.emptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.emptyCta}
|
||||
onClick={() => navigate('/videos/new')}
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.25} />
|
||||
{t('video.list.emptyCta')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
{videos.map((video) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
commentCount={commentCounts[video.id] ?? video.commentCount}
|
||||
onEdit={handleEdit}
|
||||
onComments={handleComments}
|
||||
onRemove={handleRemoveRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => navigate('/videos/new')}
|
||||
aria-label={t('video.list.addNew')}
|
||||
>
|
||||
<Plus size={26} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title={t('video.list.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? t('video.list.deleteMessage', { name: deleteTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isDeleting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{commentsTarget && (
|
||||
<VideoCommentsModal
|
||||
open={!!commentsTarget}
|
||||
videoId={commentsTarget.id}
|
||||
videoTitle={commentsTarget.title}
|
||||
onClose={() => setCommentsTarget(null)}
|
||||
onCountChange={handleCommentCountChange}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Play, PlusCircle, FolderTree, Settings } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const videoSections: {
|
||||
icon: typeof Play
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: Play,
|
||||
titleKey: 'nav.videos.list',
|
||||
descKey: 'video.card.list.desc',
|
||||
linkKey: 'video.card.list.link',
|
||||
href: '/videos/list',
|
||||
},
|
||||
{
|
||||
icon: PlusCircle,
|
||||
titleKey: 'nav.videos.new',
|
||||
descKey: 'video.card.new.desc',
|
||||
linkKey: 'video.card.new.link',
|
||||
href: '/videos/new',
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
titleKey: 'video.card.categories.title',
|
||||
descKey: 'video.card.categories.desc',
|
||||
linkKey: 'video.card.categories.link',
|
||||
href: '/videos/categories',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
titleKey: 'nav.videos.settings',
|
||||
descKey: 'video.card.settings.desc',
|
||||
linkKey: 'video.card.settings.link',
|
||||
href: '/videos/settings',
|
||||
},
|
||||
]
|
||||
|
||||
export function VideoPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Content', href: '/content' },
|
||||
{ label: 'Videos' },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="VIDEOS">{t('title.videos')}</PageTitle>
|
||||
<p className={styles.pageSubtitle}>{t('video.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{videoSections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getSettings,
|
||||
updateDashboardSettings,
|
||||
type DashboardSettings,
|
||||
} from '../services/settingsService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './ProductSettingsPage.module.css'
|
||||
|
||||
const DEFAULT_SETTINGS: DashboardSettings = {
|
||||
comments: { autoApprove: false },
|
||||
expertReviews: { autoApprove: false },
|
||||
}
|
||||
|
||||
export function VideoSettingsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSettings(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadSettings(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getSettings(signal)
|
||||
setSettings(data.settings.dashboard)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommentsAutoApprove(checked: boolean) {
|
||||
setSavingKey('comments')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await updateDashboardSettings({
|
||||
comments: { autoApprove: checked },
|
||||
})
|
||||
setSettings(data.settings.dashboard)
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Videos', href: '/videos' },
|
||||
{ label: 'Settings' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="VIDEO SETTINGS">{t('video.settings.title')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('video.settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.alertError} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.status}>{t('productSettings.loading')}</p>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowText}>
|
||||
<label htmlFor="video-comments-auto-approve" className={styles.rowLabel}>
|
||||
{t('video.settings.commentsAuto')}
|
||||
</label>
|
||||
<p className={styles.rowDescription}>{t('video.settings.commentsAutoDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="video-comments-auto-approve"
|
||||
checked={settings.comments.autoApprove}
|
||||
disabled={savingKey === 'comments'}
|
||||
aria-label={t('video.settings.commentsAutoAria')}
|
||||
onChange={(checked) => void handleCommentsAutoApprove(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.gatewayCard,
|
||||
.comingSoonCard {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: var(--card-hover-transition);
|
||||
}
|
||||
|
||||
.gatewayCard[data-card-hover]:hover {
|
||||
transform: translateY(var(--card-hover-lift));
|
||||
box-shadow: var(--card-hover-shadow);
|
||||
}
|
||||
|
||||
.gatewayHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gatewayTitle {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gatewayDesc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.credentials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field:last-of-type {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.passwordWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.passwordWrap input {
|
||||
padding-inline-end: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.passwordToggle {
|
||||
position: absolute;
|
||||
inset-inline-end: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.passwordToggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
min-height: var(--field-height);
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: var(--field-font-size);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.comingSoonGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.credentials,
|
||||
.comingSoonGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.gatewayHeader {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,177 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './WebsitePage.module.css'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
DEFAULT_EPAYMENT_SETTINGS,
|
||||
getSettings,
|
||||
updateStoreSettings,
|
||||
type EPaymentSettings,
|
||||
type PaymentGatewayId,
|
||||
} from '../services/settingsService'
|
||||
import sharedStyles from './ProductSettingsPage.module.css'
|
||||
import fieldStyles from './AddNewProductPage.module.css'
|
||||
import styles from './WebsiteEPaymentPage.module.css'
|
||||
|
||||
const COMING_SOON_GATEWAYS: {
|
||||
id: Exclude<PaymentGatewayId, 'mellat'>
|
||||
nameKey: string
|
||||
descKey: string
|
||||
}[] = [
|
||||
{
|
||||
id: 'sep',
|
||||
nameKey: 'website.ePayment.gateway.sep',
|
||||
descKey: 'website.ePayment.gateway.sepDesc',
|
||||
},
|
||||
{
|
||||
id: 'snappay',
|
||||
nameKey: 'website.ePayment.gateway.snappay',
|
||||
descKey: 'website.ePayment.gateway.snappayDesc',
|
||||
},
|
||||
{
|
||||
id: 'digipay',
|
||||
nameKey: 'website.ePayment.gateway.digipay',
|
||||
descKey: 'website.ePayment.gateway.digipayDesc',
|
||||
},
|
||||
{
|
||||
id: 'zarinpal',
|
||||
nameKey: 'website.ePayment.gateway.zarinpal',
|
||||
descKey: 'website.ePayment.gateway.zarinpalDesc',
|
||||
},
|
||||
]
|
||||
|
||||
export function WebsiteEPaymentPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [ePayment, setEPayment] = useState<EPaymentSettings>(DEFAULT_EPAYMENT_SETTINGS)
|
||||
const [passwordDraft, setPasswordDraft] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSettings(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadSettings(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getSettings(signal)
|
||||
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
|
||||
setPasswordDraft('')
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function patchEPayment(
|
||||
saving: string,
|
||||
next: EPaymentSettings,
|
||||
password?: string,
|
||||
) {
|
||||
setSavingKey(saving)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload: EPaymentSettings = {
|
||||
...next,
|
||||
gateways: {
|
||||
...next.gateways,
|
||||
mellat: {
|
||||
...next.gateways.mellat,
|
||||
password: password ?? '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const data = await updateStoreSettings({ ePayment: payload })
|
||||
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
|
||||
if (password) setPasswordDraft('')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMasterToggle(checked: boolean) {
|
||||
await patchEPayment('master', {
|
||||
...ePayment,
|
||||
enabled: checked,
|
||||
defaultGateway: checked
|
||||
? ePayment.defaultGateway ?? 'mellat'
|
||||
: ePayment.defaultGateway,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMellatToggle(checked: boolean) {
|
||||
await patchEPayment('mellat-enabled', {
|
||||
...ePayment,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
mellat: { ...ePayment.gateways.mellat, enabled: checked },
|
||||
},
|
||||
defaultGateway:
|
||||
checked && !ePayment.defaultGateway ? 'mellat' : ePayment.defaultGateway,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveMellatCredentials(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const terminalId = ePayment.gateways.mellat.terminalId.trim()
|
||||
const username = ePayment.gateways.mellat.username.trim()
|
||||
|
||||
if (ePayment.gateways.mellat.enabled && (!terminalId || !username)) {
|
||||
setError(t('website.ePayment.error.credentials'))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
ePayment.gateways.mellat.enabled &&
|
||||
!ePayment.gateways.mellat.passwordSet &&
|
||||
!passwordDraft.trim()
|
||||
) {
|
||||
setError(t('website.ePayment.error.passwordRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
await patchEPayment(
|
||||
'mellat-credentials',
|
||||
{
|
||||
...ePayment,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
mellat: {
|
||||
...ePayment.gateways.mellat,
|
||||
terminalId,
|
||||
username,
|
||||
},
|
||||
},
|
||||
},
|
||||
passwordDraft.trim() || undefined,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WebsiteSectionShell
|
||||
@@ -11,13 +179,180 @@ export function WebsiteEPaymentPage() {
|
||||
title={t('title.ePayment')}
|
||||
subtitle={t('website.ePayment.subtitle')}
|
||||
>
|
||||
<p className={styles.placeholderLead}>{t('website.ePayment.lead')}</p>
|
||||
<p className={styles.placeholderNote}>{t('website.ePayment.note')}</p>
|
||||
<ul className={styles.featureList}>
|
||||
<li>{t('website.ePayment.feature.enable')}</li>
|
||||
<li>{t('website.ePayment.feature.credentials')}</li>
|
||||
<li>{t('website.ePayment.feature.methods')}</li>
|
||||
</ul>
|
||||
{error ? <div className={sharedStyles.alertError}>{error}</div> : null}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
||||
) : (
|
||||
<div className={styles.stack}>
|
||||
<div className={sharedStyles.list}>
|
||||
<div className={sharedStyles.row}>
|
||||
<div className={sharedStyles.rowText}>
|
||||
<label className={sharedStyles.rowLabel} htmlFor="epayment-master">
|
||||
{t('website.ePayment.master')}
|
||||
</label>
|
||||
<p className={sharedStyles.rowDescription}>
|
||||
{t('website.ePayment.masterDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-master"
|
||||
checked={ePayment.enabled}
|
||||
disabled={savingKey !== null}
|
||||
onCheckedChange={(checked) => void handleMasterToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={styles.gatewayCard} data-card-hover>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>
|
||||
{t('website.ePayment.gateway.mellat')}
|
||||
</h3>
|
||||
<p className={styles.gatewayDesc}>
|
||||
{t('website.ePayment.gateway.mellatDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-mellat"
|
||||
checked={ePayment.gateways.mellat.enabled}
|
||||
disabled={savingKey !== null || !ePayment.enabled}
|
||||
onCheckedChange={(checked) => void handleMellatToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className={styles.credentials} onSubmit={handleSaveMellatCredentials}>
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-terminal">
|
||||
{t('website.ePayment.terminalId')}
|
||||
</label>
|
||||
<input
|
||||
id="mellat-terminal"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
value={ePayment.gateways.mellat.terminalId}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
mellat: {
|
||||
...current.gateways.mellat,
|
||||
terminalId: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-username">
|
||||
{t('website.ePayment.username')}
|
||||
</label>
|
||||
<input
|
||||
id="mellat-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
value={ePayment.gateways.mellat.username}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
mellat: {
|
||||
...current.gateways.mellat,
|
||||
username: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-password">
|
||||
{t('website.ePayment.password')}
|
||||
{ePayment.gateways.mellat.passwordSet ? (
|
||||
<span className={styles.hint}>
|
||||
{' '}
|
||||
({t('website.ePayment.passwordSet')})
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<div className={styles.passwordWrap}>
|
||||
<input
|
||||
id="mellat-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
dir="ltr"
|
||||
placeholder={
|
||||
ePayment.gateways.mellat.passwordSet
|
||||
? t('website.ePayment.passwordPlaceholderSet')
|
||||
: t('website.ePayment.passwordPlaceholder')
|
||||
}
|
||||
value={passwordDraft}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(e) => setPasswordDraft(e.target.value)}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
showPassword
|
||||
? t('website.ePayment.hidePassword')
|
||||
: t('website.ePayment.showPassword')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t('website.ePayment.hidePassword')
|
||||
: t('website.ePayment.showPassword')
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveBtn}
|
||||
disabled={savingKey !== null}
|
||||
>
|
||||
{savingKey === 'mellat-credentials'
|
||||
? t('storeSettings.saving')
|
||||
: t('website.ePayment.saveCredentials')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className={styles.comingSoonGrid}>
|
||||
{COMING_SOON_GATEWAYS.map((gateway) => (
|
||||
<section key={gateway.id} className={styles.comingSoonCard}>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>{t(gateway.nameKey)}</h3>
|
||||
<p className={styles.gatewayDesc}>{t(gateway.descKey)}</p>
|
||||
</div>
|
||||
<span className={styles.badge}>{t('website.ePayment.comingSoon')}</span>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WebsiteSectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function mapCommentApiToUi(comment: CommentApi): ProductComment {
|
||||
}
|
||||
|
||||
async function listEntityComments(
|
||||
entityType: 'product' | 'blog' | 'portfolio',
|
||||
entityType: 'product' | 'blog' | 'portfolio' | 'video' | 'instruction',
|
||||
entityId: string,
|
||||
page = 1,
|
||||
pageSize = COMMENTS_PER_PAGE,
|
||||
@@ -98,6 +98,24 @@ export async function listPortfolioComments(
|
||||
return listEntityComments('portfolio', portfolioId, page, pageSize, signal)
|
||||
}
|
||||
|
||||
export async function listVideoComments(
|
||||
videoId: string,
|
||||
page = 1,
|
||||
pageSize = COMMENTS_PER_PAGE,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return listEntityComments('video', videoId, page, pageSize, signal)
|
||||
}
|
||||
|
||||
export async function listInstructionComments(
|
||||
instructionId: string,
|
||||
page = 1,
|
||||
pageSize = COMMENTS_PER_PAGE,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return listEntityComments('instruction', instructionId, page, pageSize, signal)
|
||||
}
|
||||
|
||||
export async function updateCommentApproval(commentId: string, isApproved: boolean) {
|
||||
const data = await apiRequest<{ comment: CommentApi }>(commentsPath(`/${commentId}`), {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import type {
|
||||
CategoryFormData,
|
||||
CreateCategoryPayload,
|
||||
ProductCategoriesResponse,
|
||||
ProductCategory,
|
||||
UpdateCategoryPayload,
|
||||
} from '../types/category'
|
||||
|
||||
const ENTITY_TYPE = 'instruction' as const
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/categories${suffix}`
|
||||
}
|
||||
|
||||
export async function listInstructionCategories(signal?: AbortSignal) {
|
||||
const data = await apiRequest<ProductCategoriesResponse>(
|
||||
`${businessPath()}?entityType=${ENTITY_TYPE}`,
|
||||
{ auth: true, signal },
|
||||
)
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function createInstructionCategory(payload: CreateCategoryPayload) {
|
||||
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { ...payload, entityType: ENTITY_TYPE },
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateInstructionCategory(categoryId: string, payload: UpdateCategoryPayload) {
|
||||
return apiRequest<{ message: string; category: ProductCategory }>(
|
||||
businessPath(`/${categoryId}`),
|
||||
{
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteInstructionCategory(categoryId: string) {
|
||||
return apiRequest<{ message: string; deletedIds: string[] }>(
|
||||
businessPath(`/${categoryId}`),
|
||||
{
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function toInstructionCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
|
||||
return {
|
||||
parentId: data.parentId || undefined,
|
||||
name: data.nameEn,
|
||||
nameFa: data.nameFa,
|
||||
description: data.description || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapInstructionCategoryToUi(category: ProductCategory) {
|
||||
return {
|
||||
id: category.id,
|
||||
nameEn: category.name,
|
||||
nameFa: category.nameFa ?? '',
|
||||
description: category.description ?? '',
|
||||
parentId: category.parentId,
|
||||
variationCount: category.variationCount ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import type {
|
||||
Instruction,
|
||||
InstructionAuthor,
|
||||
InstructionDetail,
|
||||
InstructionStatus,
|
||||
VideoProvider,
|
||||
} from '../types/instruction'
|
||||
|
||||
export const INSTRUCTIONS_PER_PAGE = 12
|
||||
|
||||
export interface InstructionApi {
|
||||
id: string
|
||||
businessId: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
descriptionHtml: string
|
||||
videoProvider: VideoProvider | null
|
||||
embedCode: string
|
||||
status: InstructionStatus
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
tags: string[]
|
||||
coverImageUrl: string | null
|
||||
featuredMediaId: string | null
|
||||
authorId?: string | null
|
||||
author?: InstructionAuthor | null
|
||||
commentCount: number
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InstructionsListResponse {
|
||||
items: InstructionApi[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ListInstructionsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: InstructionStatus
|
||||
provider?: VideoProvider
|
||||
categoryId?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/instructions${suffix}`
|
||||
}
|
||||
|
||||
export interface CreateInstructionPayload {
|
||||
titleFa: string
|
||||
titleEn?: string
|
||||
abstract?: string
|
||||
descriptionHtml?: string
|
||||
videoProvider?: VideoProvider
|
||||
embedCode?: string
|
||||
categoryId?: string
|
||||
status?: InstructionStatus
|
||||
featuredMediaId?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateInstructionPayload {
|
||||
titleFa?: string
|
||||
titleEn?: string | null
|
||||
abstract?: string | null
|
||||
descriptionHtml?: string | null
|
||||
videoProvider?: VideoProvider | null
|
||||
embedCode?: string | null
|
||||
categoryId?: string | null
|
||||
status?: InstructionStatus
|
||||
featuredMediaId?: string | null
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export function resolveInstructionTitle(
|
||||
instruction: Pick<Instruction, 'titleFa' | 'titleEn' | 'title'>,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (locale === 'fa') {
|
||||
return instruction.titleFa || instruction.title
|
||||
}
|
||||
return instruction.titleEn?.trim() || instruction.titleFa || instruction.title
|
||||
}
|
||||
|
||||
export function mapInstructionApiToUi(instruction: InstructionApi): Instruction {
|
||||
return {
|
||||
id: instruction.id,
|
||||
title: instruction.title,
|
||||
titleFa: instruction.titleFa || instruction.title,
|
||||
titleEn: instruction.titleEn,
|
||||
slug: instruction.slug,
|
||||
abstract: instruction.abstract,
|
||||
videoProvider: instruction.videoProvider,
|
||||
embedCode: instruction.embedCode,
|
||||
status: instruction.status,
|
||||
categoryId: instruction.categoryId,
|
||||
categoryName: instruction.categoryName,
|
||||
tags: instruction.tags,
|
||||
coverImageUrl: instruction.coverImageUrl,
|
||||
commentCount: instruction.commentCount,
|
||||
author: instruction.author ?? null,
|
||||
publishedAt: instruction.publishedAt,
|
||||
createdAt: instruction.createdAt,
|
||||
updatedAt: instruction.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapInstructionDetailFromApi(instruction: InstructionApi): InstructionDetail {
|
||||
return {
|
||||
...mapInstructionApiToUi(instruction),
|
||||
descriptionHtml: instruction.descriptionHtml,
|
||||
author: instruction.author ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatInstructionAuthor(author: InstructionAuthor | null): string {
|
||||
if (!author) return 'Unknown author'
|
||||
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || author.email || 'Unknown author'
|
||||
}
|
||||
|
||||
export function formatInstructionDate(
|
||||
value: string | null,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatInstructionCardDate(
|
||||
value: string | null,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function mapInstructionApiToFormState(instruction: InstructionApi) {
|
||||
return {
|
||||
titleFa: instruction.titleFa || instruction.title,
|
||||
titleEn: instruction.titleEn ?? '',
|
||||
abstract: instruction.abstract,
|
||||
descriptionHtml: instruction.descriptionHtml,
|
||||
videoProvider: instruction.videoProvider ?? 'youtube',
|
||||
embedCode: instruction.embedCode,
|
||||
categoryId: instruction.categoryId ?? '',
|
||||
tags: instruction.tags,
|
||||
coverImage: instruction.coverImageUrl,
|
||||
featuredMediaId: instruction.featuredMediaId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInstructions(
|
||||
page = 1,
|
||||
pageSize = INSTRUCTIONS_PER_PAGE,
|
||||
signal?: AbortSignal,
|
||||
filters?: Omit<ListInstructionsParams, 'page' | 'pageSize'>,
|
||||
) {
|
||||
const q = new URLSearchParams()
|
||||
q.set('page', String(page))
|
||||
q.set('pageSize', String(pageSize))
|
||||
if (filters?.status) q.set('status', filters.status)
|
||||
if (filters?.provider) q.set('provider', filters.provider)
|
||||
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
|
||||
if (filters?.title) q.set('title', filters.title)
|
||||
|
||||
const data = await apiRequest<InstructionsListResponse>(`${businessPath()}?${q.toString()}`, {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map(mapInstructionApiToUi),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstruction(instructionId: string, signal?: AbortSignal) {
|
||||
const data = await apiRequest<{ instruction: InstructionApi }>(businessPath(`/${instructionId}`), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
return data.instruction
|
||||
}
|
||||
|
||||
export async function getInstructionDetail(instructionId: string, signal?: AbortSignal) {
|
||||
const instruction = await getInstruction(instructionId, signal)
|
||||
return mapInstructionDetailFromApi(instruction)
|
||||
}
|
||||
|
||||
export async function createInstruction(payload: CreateInstructionPayload) {
|
||||
return apiRequest<{ message: string; instruction: InstructionApi }>(businessPath(), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateInstruction(
|
||||
instructionId: string,
|
||||
payload: UpdateInstructionPayload,
|
||||
) {
|
||||
return apiRequest<{ message: string; instruction: InstructionApi }>(
|
||||
businessPath(`/${instructionId}`),
|
||||
{
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteInstruction(instructionId: string) {
|
||||
return apiRequest<{ message: string }>(businessPath(`/${instructionId}`), {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
@@ -20,9 +20,60 @@ export interface OrderProcessStep {
|
||||
color: string
|
||||
}
|
||||
|
||||
export type PaymentGatewayId =
|
||||
| 'mellat'
|
||||
| 'sep'
|
||||
| 'snappay'
|
||||
| 'digipay'
|
||||
| 'zarinpal'
|
||||
|
||||
export interface MellatGatewaySettings {
|
||||
enabled: boolean
|
||||
terminalId: string
|
||||
username: string
|
||||
/** Always empty in API responses; use passwordSet. */
|
||||
password: string
|
||||
passwordSet?: boolean
|
||||
}
|
||||
|
||||
export interface StubGatewaySettings {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EPaymentSettings {
|
||||
enabled: boolean
|
||||
defaultGateway: PaymentGatewayId | null
|
||||
gateways: {
|
||||
mellat: MellatGatewaySettings
|
||||
sep: StubGatewaySettings
|
||||
snappay: StubGatewaySettings
|
||||
digipay: StubGatewaySettings
|
||||
zarinpal: StubGatewaySettings
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoreSettings {
|
||||
onlineSellEnabled: boolean
|
||||
orderProcessSteps: OrderProcessStep[]
|
||||
ePayment: EPaymentSettings
|
||||
}
|
||||
|
||||
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
|
||||
enabled: false,
|
||||
defaultGateway: null,
|
||||
gateways: {
|
||||
mellat: {
|
||||
enabled: false,
|
||||
terminalId: '',
|
||||
username: '',
|
||||
password: '',
|
||||
passwordSet: false,
|
||||
},
|
||||
sep: { enabled: false },
|
||||
snappay: { enabled: false },
|
||||
digipay: { enabled: false },
|
||||
zarinpal: { enabled: false },
|
||||
},
|
||||
}
|
||||
|
||||
export interface ModulesSettings {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import type {
|
||||
CategoryFormData,
|
||||
CreateCategoryPayload,
|
||||
ProductCategoriesResponse,
|
||||
ProductCategory,
|
||||
UpdateCategoryPayload,
|
||||
} from '../types/category'
|
||||
|
||||
const ENTITY_TYPE = 'video' as const
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/categories${suffix}`
|
||||
}
|
||||
|
||||
export async function listVideoCategories(signal?: AbortSignal) {
|
||||
const data = await apiRequest<ProductCategoriesResponse>(
|
||||
`${businessPath()}?entityType=${ENTITY_TYPE}`,
|
||||
{ auth: true, signal },
|
||||
)
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function createVideoCategory(payload: CreateCategoryPayload) {
|
||||
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { ...payload, entityType: ENTITY_TYPE },
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateVideoCategory(categoryId: string, payload: UpdateCategoryPayload) {
|
||||
return apiRequest<{ message: string; category: ProductCategory }>(
|
||||
businessPath(`/${categoryId}`),
|
||||
{
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteVideoCategory(categoryId: string) {
|
||||
return apiRequest<{ message: string; deletedIds: string[] }>(
|
||||
businessPath(`/${categoryId}`),
|
||||
{
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function toVideoCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
|
||||
return {
|
||||
parentId: data.parentId || undefined,
|
||||
name: data.nameEn,
|
||||
nameFa: data.nameFa,
|
||||
description: data.description || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapVideoCategoryToUi(category: ProductCategory) {
|
||||
return {
|
||||
id: category.id,
|
||||
nameEn: category.name,
|
||||
nameFa: category.nameFa ?? '',
|
||||
description: category.description ?? '',
|
||||
parentId: category.parentId,
|
||||
variationCount: category.variationCount ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import type {
|
||||
Video,
|
||||
VideoAuthor,
|
||||
VideoDetail,
|
||||
VideoProvider,
|
||||
VideoStatus,
|
||||
} from '../types/video'
|
||||
|
||||
export const VIDEOS_PER_PAGE = 12
|
||||
|
||||
export interface VideoApi {
|
||||
id: string
|
||||
businessId: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
descriptionHtml: string
|
||||
videoProvider: VideoProvider
|
||||
embedCode: string
|
||||
status: VideoStatus
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
tags: string[]
|
||||
coverImageUrl: string | null
|
||||
featuredMediaId: string | null
|
||||
authorId?: string | null
|
||||
author?: VideoAuthor | null
|
||||
commentCount: number
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface VideosListResponse {
|
||||
items: VideoApi[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ListVideosParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: VideoStatus
|
||||
provider?: VideoProvider
|
||||
categoryId?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/videos${suffix}`
|
||||
}
|
||||
|
||||
export interface CreateVideoPayload {
|
||||
titleFa: string
|
||||
titleEn?: string
|
||||
abstract?: string
|
||||
descriptionHtml?: string
|
||||
videoProvider: VideoProvider
|
||||
embedCode: string
|
||||
categoryId?: string
|
||||
status?: VideoStatus
|
||||
featuredMediaId?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateVideoPayload {
|
||||
titleFa?: string
|
||||
titleEn?: string | null
|
||||
abstract?: string | null
|
||||
descriptionHtml?: string | null
|
||||
videoProvider?: VideoProvider
|
||||
embedCode?: string
|
||||
categoryId?: string | null
|
||||
status?: VideoStatus
|
||||
featuredMediaId?: string | null
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export function resolveVideoTitle(
|
||||
video: Pick<Video, 'titleFa' | 'titleEn' | 'title'>,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (locale === 'fa') {
|
||||
return video.titleFa || video.title
|
||||
}
|
||||
return video.titleEn?.trim() || video.titleFa || video.title
|
||||
}
|
||||
|
||||
export function mapVideoApiToUi(video: VideoApi): Video {
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
titleFa: video.titleFa || video.title,
|
||||
titleEn: video.titleEn,
|
||||
slug: video.slug,
|
||||
abstract: video.abstract,
|
||||
videoProvider: video.videoProvider,
|
||||
embedCode: video.embedCode,
|
||||
status: video.status,
|
||||
categoryId: video.categoryId,
|
||||
categoryName: video.categoryName,
|
||||
tags: video.tags,
|
||||
coverImageUrl: video.coverImageUrl,
|
||||
commentCount: video.commentCount,
|
||||
author: video.author ?? null,
|
||||
publishedAt: video.publishedAt,
|
||||
createdAt: video.createdAt,
|
||||
updatedAt: video.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapVideoDetailFromApi(video: VideoApi): VideoDetail {
|
||||
return {
|
||||
...mapVideoApiToUi(video),
|
||||
descriptionHtml: video.descriptionHtml,
|
||||
author: video.author ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatVideoAuthor(author: VideoAuthor | null): string {
|
||||
if (!author) return 'Unknown author'
|
||||
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || author.email || 'Unknown author'
|
||||
}
|
||||
|
||||
export function formatVideoDate(
|
||||
value: string | null,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatVideoCardDate(
|
||||
value: string | null,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
): string {
|
||||
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function mapVideoApiToFormState(video: VideoApi) {
|
||||
return {
|
||||
titleFa: video.titleFa || video.title,
|
||||
titleEn: video.titleEn ?? '',
|
||||
abstract: video.abstract,
|
||||
descriptionHtml: video.descriptionHtml,
|
||||
videoProvider: video.videoProvider,
|
||||
embedCode: video.embedCode,
|
||||
categoryId: video.categoryId ?? '',
|
||||
tags: video.tags,
|
||||
coverImage: video.coverImageUrl,
|
||||
featuredMediaId: video.featuredMediaId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listVideos(
|
||||
page = 1,
|
||||
pageSize = VIDEOS_PER_PAGE,
|
||||
signal?: AbortSignal,
|
||||
filters?: Omit<ListVideosParams, 'page' | 'pageSize'>,
|
||||
) {
|
||||
const q = new URLSearchParams()
|
||||
q.set('page', String(page))
|
||||
q.set('pageSize', String(pageSize))
|
||||
if (filters?.status) q.set('status', filters.status)
|
||||
if (filters?.provider) q.set('provider', filters.provider)
|
||||
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
|
||||
if (filters?.title) q.set('title', filters.title)
|
||||
|
||||
const data = await apiRequest<VideosListResponse>(`${businessPath()}?${q.toString()}`, {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map(mapVideoApiToUi),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVideo(videoId: string, signal?: AbortSignal) {
|
||||
const data = await apiRequest<{ video: VideoApi }>(businessPath(`/${videoId}`), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
return data.video
|
||||
}
|
||||
|
||||
export async function getVideoDetail(videoId: string, signal?: AbortSignal) {
|
||||
const video = await getVideo(videoId, signal)
|
||||
return mapVideoDetailFromApi(video)
|
||||
}
|
||||
|
||||
export async function createVideo(payload: CreateVideoPayload) {
|
||||
return apiRequest<{ message: string; video: VideoApi }>(businessPath(), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateVideo(videoId: string, payload: UpdateVideoPayload) {
|
||||
return apiRequest<{ message: string; video: VideoApi }>(businessPath(`/${videoId}`), {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteVideo(videoId: string) {
|
||||
return apiRequest<{ message: string }>(businessPath(`/${videoId}`), {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type VideoProvider = 'youtube' | 'aparat'
|
||||
export type InstructionStatus = 'draft' | 'published' | 'archived'
|
||||
|
||||
export interface InstructionAuthor {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface Instruction {
|
||||
id: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
videoProvider: VideoProvider | null
|
||||
embedCode: string
|
||||
status: InstructionStatus
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
tags: string[]
|
||||
coverImageUrl: string | null
|
||||
commentCount: number
|
||||
author: InstructionAuthor | null
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InstructionDetail extends Instruction {
|
||||
descriptionHtml: string
|
||||
author: InstructionAuthor | null
|
||||
}
|
||||
|
||||
export const VIDEO_PROVIDER_OPTIONS: { value: VideoProvider; label: string }[] = [
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'aparat', label: 'Aparat' },
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
export type VideoProvider = 'youtube' | 'aparat'
|
||||
export type VideoStatus = 'draft' | 'published' | 'archived'
|
||||
|
||||
export interface VideoAuthor {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
videoProvider: VideoProvider
|
||||
embedCode: string
|
||||
status: VideoStatus
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
tags: string[]
|
||||
coverImageUrl: string | null
|
||||
commentCount: number
|
||||
author: VideoAuthor | null
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface VideoDetail extends Video {
|
||||
descriptionHtml: string
|
||||
author: VideoAuthor | null
|
||||
}
|
||||
|
||||
export const VIDEO_PROVIDER_OPTIONS: { value: VideoProvider; label: string }[] = [
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'aparat', label: 'Aparat' },
|
||||
]
|
||||
@@ -6,6 +6,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'blog',
|
||||
'warehouse',
|
||||
'videos',
|
||||
'instructions',
|
||||
'finance',
|
||||
] as const
|
||||
|
||||
@@ -89,6 +90,13 @@ export function hasBusinessModule(
|
||||
return enabled.includes(moduleId)
|
||||
}
|
||||
|
||||
export function hasAnyBusinessModule(
|
||||
enabledModules: readonly BusinessModuleId[] | null | undefined,
|
||||
moduleIds: readonly BusinessModuleId[],
|
||||
): boolean {
|
||||
return moduleIds.some((moduleId) => hasBusinessModule(enabledModules, moduleId))
|
||||
}
|
||||
|
||||
export function isActiveHomeChart(value: HomeChartId): boolean {
|
||||
return value !== 'none'
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CheckoutDeliveryStep } from './pages/checkout/CheckoutDeliveryStep'
|
||||
import { CheckoutPaymentStep } from './pages/checkout/CheckoutPaymentStep'
|
||||
import { CheckoutSuccessStep } from './pages/checkout/CheckoutSuccessStep'
|
||||
import { CheckoutFailedStep } from './pages/checkout/CheckoutFailedStep'
|
||||
import { CheckoutResultStep } from './pages/checkout/CheckoutResultStep'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -44,6 +45,7 @@ function App() {
|
||||
<Route path="cart" element={<CheckoutCartStep />} />
|
||||
<Route path="delivery" element={<CheckoutDeliveryStep />} />
|
||||
<Route path="payment" element={<CheckoutPaymentStep />} />
|
||||
<Route path="result" element={<CheckoutResultStep />} />
|
||||
<Route path="success" element={<CheckoutSuccessStep />} />
|
||||
<Route path="failed" element={<CheckoutFailedStep />} />
|
||||
<Route index element={<CheckoutCartStep />} />
|
||||
|
||||
@@ -8,6 +8,7 @@ export const customerRouteTitleRules: RouteTitleRule[] = [
|
||||
{ match: '/checkout/cart', labels: ['Checkout', 'Shopping Cart'] },
|
||||
{ match: '/checkout/delivery', labels: ['Checkout', 'Delivery'] },
|
||||
{ match: '/checkout/payment', labels: ['Checkout', 'Payment'] },
|
||||
{ match: '/checkout/result', labels: ['Checkout', 'Payment result'] },
|
||||
{ match: '/checkout/success', labels: ['Checkout', 'Success'] },
|
||||
{ match: '/checkout/failed', labels: ['Checkout', 'Failed'] },
|
||||
{ match: '/checkout', labels: ['Checkout'] },
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import orderFailedImg from '../../assets/order-failed.png'
|
||||
import type { Order } from '../../services/orderService'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import {
|
||||
gatewayLabelFa,
|
||||
transactionStatusLabelFa,
|
||||
type CheckoutPaymentInfo,
|
||||
} from '../../utils/checkoutPayment'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutFailedStep() {
|
||||
type FailedLocationState = {
|
||||
message?: string
|
||||
payment?: CheckoutPaymentInfo
|
||||
order?: Order
|
||||
}
|
||||
|
||||
type CheckoutFailedStepProps = {
|
||||
message?: string
|
||||
payment?: CheckoutPaymentInfo | null
|
||||
order?: Order | null
|
||||
}
|
||||
|
||||
export function CheckoutFailedStep({
|
||||
message: messageProp,
|
||||
payment: paymentProp,
|
||||
order: orderProp,
|
||||
}: CheckoutFailedStepProps = {}) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const navState = (location.state as FailedLocationState | null) ?? null
|
||||
const payment = paymentProp ?? navState?.payment ?? null
|
||||
const order = orderProp ?? navState?.order ?? null
|
||||
const message =
|
||||
(location.state as { message?: string } | null)?.message ||
|
||||
messageProp ||
|
||||
navState?.message ||
|
||||
payment?.message ||
|
||||
'ثبت سفارش ممکن نشد. لطفاً دوباره تلاش کنید.'
|
||||
|
||||
return (
|
||||
@@ -20,10 +48,57 @@ export function CheckoutFailedStep() {
|
||||
</div>
|
||||
|
||||
<div className={styles.successHeader}>
|
||||
<h1 className={styles.stepTitle}>سفارش ثبت نشد</h1>
|
||||
<h1 className={styles.stepTitle}>
|
||||
{payment ? 'پرداخت ناموفق' : 'سفارش ثبت نشد'}
|
||||
</h1>
|
||||
<p className={styles.stepDesc}>{message}</p>
|
||||
</div>
|
||||
|
||||
{(order || payment) && (
|
||||
<dl className={styles.successMeta}>
|
||||
{order ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>شماره سفارش</dt>
|
||||
<dd className={styles.successMetaValue}>{order.orderNumber}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{payment?.amount != null ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>مبلغ</dt>
|
||||
<dd className={styles.successMetaValue}>
|
||||
{formatIrtPrice(payment.amount)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{payment ? (
|
||||
<>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>وضعیت پرداخت</dt>
|
||||
<dd>{transactionStatusLabelFa('failed')}</dd>
|
||||
</div>
|
||||
{payment.gatewayType ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>درگاه</dt>
|
||||
<dd>{gatewayLabelFa(payment.gatewayType)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{payment.trackId ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>کد پیگیری</dt>
|
||||
<dd className={styles.successMetaValue}>{payment.trackId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{payment.transactionId ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>شناسه تراکنش</dt>
|
||||
<dd className={styles.successMetaValue}>{payment.transactionId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className={styles.successActions}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,14 +4,20 @@ import { useAuth } from '../../context/AuthContext'
|
||||
import { CheckoutStepper, type CheckoutStepId } from '../../components/checkout/CheckoutStepper'
|
||||
import { loginPathWithRedirect, safeRedirectPath } from '../../lib/redirect'
|
||||
|
||||
/** Cart and checkout login are public. Delivery, payment, and success require auth. */
|
||||
/** Cart and checkout login are public. Delivery, payment, and result require auth. */
|
||||
const PUBLIC_STEPS = new Set<CheckoutStepId>(['cart', 'login'])
|
||||
|
||||
function stepFromPath(pathname: string): CheckoutStepId {
|
||||
if (pathname.endsWith('/login')) return 'login'
|
||||
if (pathname.endsWith('/delivery')) return 'delivery'
|
||||
if (pathname.endsWith('/payment')) return 'payment'
|
||||
if (pathname.endsWith('/success') || pathname.endsWith('/failed')) return 'payment'
|
||||
if (
|
||||
pathname.endsWith('/success') ||
|
||||
pathname.endsWith('/failed') ||
|
||||
pathname.endsWith('/result')
|
||||
) {
|
||||
return 'payment'
|
||||
}
|
||||
return 'cart'
|
||||
}
|
||||
|
||||
@@ -26,11 +32,12 @@ export function CheckoutFlow() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !PUBLIC_STEPS.has(step)) {
|
||||
const returnTo = `${location.pathname}${location.search}`
|
||||
return (
|
||||
<Navigate
|
||||
to={loginPathWithRedirect(location.pathname, '/checkout/login')}
|
||||
to={loginPathWithRedirect(returnTo, '/checkout/login')}
|
||||
replace
|
||||
state={{ from: location.pathname }}
|
||||
state={{ from: returnTo }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -43,11 +50,14 @@ export function CheckoutFlow() {
|
||||
return <Navigate to={destination} replace />
|
||||
}
|
||||
|
||||
const hideStepper =
|
||||
location.pathname === '/checkout/success' ||
|
||||
location.pathname === '/checkout/failed' ||
|
||||
location.pathname === '/checkout/result'
|
||||
|
||||
return (
|
||||
<>
|
||||
{step !== 'login' &&
|
||||
location.pathname !== '/checkout/success' &&
|
||||
location.pathname !== '/checkout/failed' && (
|
||||
{step !== 'login' && !hideStepper && (
|
||||
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
|
||||
)}
|
||||
<Outlet />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ApiError, isAbortError } from '../../lib/api'
|
||||
import {
|
||||
checkoutCart,
|
||||
getCart,
|
||||
submitPaymentRedirect,
|
||||
type CheckoutPaymentType,
|
||||
type OnlineGatewayType,
|
||||
} from '../../services/cartService'
|
||||
@@ -16,9 +17,9 @@ import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string }[] = [
|
||||
{ id: 'mellat_behpardakht', label: 'درگاه پرداخت بانک ملت - بهپرداخت' },
|
||||
{ id: 'saman_kish', label: 'درگاه پرداخت بانک سامان - سامان کیش' },
|
||||
const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string; ready: boolean }[] = [
|
||||
{ id: 'mellat', label: 'درگاه پرداخت بانک ملت - بهپرداخت', ready: true },
|
||||
{ id: 'sep', label: 'درگاه پرداخت بانک سامان - سامان کیش', ready: false },
|
||||
]
|
||||
|
||||
export function CheckoutPaymentStep() {
|
||||
@@ -31,7 +32,7 @@ export function CheckoutPaymentStep() {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate')
|
||||
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat_behpardakht')
|
||||
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat')
|
||||
const [transferAccount, setTransferAccount] = useState('')
|
||||
const [transferRefNumber, setTransferRefNumber] = useState('')
|
||||
const [discountApplied, setDiscountApplied] = useState(false)
|
||||
@@ -100,7 +101,14 @@ export function CheckoutPaymentStep() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only bank transfer requires extra fields before creating the order
|
||||
if (paymentType === 'e_payment_gate') {
|
||||
const gateway = ONLINE_GATEWAYS.find((item) => item.id === gatewayType)
|
||||
if (!gateway?.ready) {
|
||||
setError('این درگاه هنوز فعال نیست. لطفاً ملت را انتخاب کنید.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (paymentType === 'transfer') {
|
||||
if (!transferAccount.trim() || !transferRefNumber.trim()) {
|
||||
setError('لطفاً شماره حساب/کارت و شماره مرجع را وارد کنید.')
|
||||
@@ -119,9 +127,12 @@ export function CheckoutPaymentStep() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Both types persist via existing backend TransactionType:
|
||||
// e_payment_gate (+ gatewayType) | transfer (+ account/ref)
|
||||
const payment = buildPaymentPayload()
|
||||
const returnUrl =
|
||||
paymentType === 'e_payment_gate'
|
||||
? `${window.location.origin}/checkout/result`
|
||||
: undefined
|
||||
|
||||
const payload =
|
||||
deliveryMode === 'pickup'
|
||||
? {
|
||||
@@ -132,17 +143,25 @@ export function CheckoutPaymentStep() {
|
||||
postalCode: '0000000000',
|
||||
},
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
returnUrl,
|
||||
payment,
|
||||
}
|
||||
: {
|
||||
addressId: selectedAddressId!,
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
returnUrl,
|
||||
payment,
|
||||
}
|
||||
|
||||
const result = await checkoutCart(payload)
|
||||
setPlacedOrder(result.order)
|
||||
// Pass order in navigation state so success page does not race context updates
|
||||
|
||||
if (result.payment?.redirect) {
|
||||
showToast('در حال انتقال به درگاه پرداخت...', 'info')
|
||||
submitPaymentRedirect(result.payment.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
navigate('/checkout/success', { replace: true, state: { order: result.order } })
|
||||
showToast('سفارش با موفقیت ثبت شد.', 'success')
|
||||
} catch (err) {
|
||||
@@ -243,12 +262,15 @@ export function CheckoutPaymentStep() {
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setGatewayType(gateway.id)}
|
||||
disabled={submitting}
|
||||
disabled={submitting || !gateway.ready}
|
||||
>
|
||||
<span className={styles.paymentSubRadio}>
|
||||
{active ? <span className={styles.paymentSubRadioInner} /> : null}
|
||||
</span>
|
||||
<span>{gateway.label}</span>
|
||||
<span>
|
||||
{gateway.label}
|
||||
{!gateway.ready ? ' (بهزودی)' : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -321,7 +343,15 @@ export function CheckoutPaymentStep() {
|
||||
</div>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel={submitting ? 'در حال ثبت سفارش...' : 'پرداخت و ثبت سفارش'}
|
||||
primaryLabel={
|
||||
submitting
|
||||
? paymentType === 'e_payment_gate'
|
||||
? 'در حال انتقال به درگاه...'
|
||||
: 'در حال ثبت سفارش...'
|
||||
: paymentType === 'e_payment_gate'
|
||||
? 'پرداخت آنلاین'
|
||||
: 'پرداخت و ثبت سفارش'
|
||||
}
|
||||
onPrimary={() => void handlePlaceOrder()}
|
||||
primaryDisabled={submitting}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, useSearchParams } from 'react-router-dom'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import { getOrder, type Order } from '../../services/orderService'
|
||||
import {
|
||||
buildPaymentInfoFromOrder,
|
||||
type CheckoutPaymentInfo,
|
||||
type PaymentResultStatus,
|
||||
} from '../../utils/checkoutPayment'
|
||||
import { CheckoutFailedStep } from './CheckoutFailedStep'
|
||||
import { CheckoutSuccessStep } from './CheckoutSuccessStep'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
function readStatus(raw: string | null): PaymentResultStatus | null {
|
||||
if (raw === 'success' || raw === 'failed') return raw
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bank return URL target (`returnUrl`).
|
||||
* Query: status, orderId, transactionId, trackId, message
|
||||
*/
|
||||
export function CheckoutResultStep() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { setPlacedOrder } = useCheckout()
|
||||
const [order, setOrder] = useState<Order | null>(null)
|
||||
const [payment, setPayment] = useState<CheckoutPaymentInfo | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const status = readStatus(searchParams.get('status'))
|
||||
const orderId = searchParams.get('orderId')?.trim() || ''
|
||||
const transactionId = searchParams.get('transactionId')?.trim() || undefined
|
||||
const trackId = searchParams.get('trackId')?.trim() || undefined
|
||||
const message = searchParams.get('message')?.trim() || undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!status || !orderId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await getOrder(orderId, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setOrder(data.order)
|
||||
setPlacedOrder(data.order)
|
||||
setPayment(
|
||||
buildPaymentInfoFromOrder(data.order, {
|
||||
status,
|
||||
transactionId,
|
||||
trackId,
|
||||
message,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
// Still show result shell with query payment data if order fetch fails
|
||||
setPayment({
|
||||
status: status!,
|
||||
transactionId,
|
||||
trackId,
|
||||
message:
|
||||
message ||
|
||||
(err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری جزئیات سفارش ممکن نشد.'),
|
||||
})
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری سفارش ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [status, orderId, transactionId, trackId, message, setPlacedOrder])
|
||||
|
||||
if (!status) {
|
||||
return <Navigate to="/checkout/cart" replace />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>در حال دریافت نتیجه پرداخت...</div>
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
if (!order) {
|
||||
return (
|
||||
<CheckoutFailedStep
|
||||
message={error || message || 'پرداخت تأیید شد اما سفارش یافت نشد.'}
|
||||
payment={payment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <CheckoutSuccessStep order={order} payment={payment} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckoutFailedStep
|
||||
message={
|
||||
payment?.message ||
|
||||
message ||
|
||||
error ||
|
||||
'پرداخت انجام نشد. در صورت کسر وجه، مبلغ بهزودی به حساب شما بازمیگردد.'
|
||||
}
|
||||
payment={payment}
|
||||
order={order}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,35 @@ import { useCheckout } from '../../context/CheckoutContext'
|
||||
import type { Order } from '../../services/orderService'
|
||||
import { getWebsiteUrl } from '../../services/websiteService'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import {
|
||||
gatewayLabelFa,
|
||||
transactionStatusLabelFa,
|
||||
type CheckoutPaymentInfo,
|
||||
} from '../../utils/checkoutPayment'
|
||||
import orderSucceedImg from '../../assets/order-succeed.png'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutSuccessStep() {
|
||||
type SuccessLocationState = {
|
||||
order?: Order
|
||||
payment?: CheckoutPaymentInfo
|
||||
}
|
||||
|
||||
type CheckoutSuccessStepProps = {
|
||||
/** When rendered from bank result page */
|
||||
order?: Order
|
||||
payment?: CheckoutPaymentInfo | null
|
||||
}
|
||||
|
||||
export function CheckoutSuccessStep({
|
||||
order: orderProp,
|
||||
payment: paymentProp,
|
||||
}: CheckoutSuccessStepProps = {}) {
|
||||
const location = useLocation()
|
||||
const { placedOrder, setPlacedOrder, resetCheckout } = useCheckout()
|
||||
const orderFromNav = (location.state as { order?: Order } | null)?.order
|
||||
const order = placedOrder ?? orderFromNav ?? null
|
||||
const navState = (location.state as SuccessLocationState | null) ?? null
|
||||
const orderFromNav = navState?.order
|
||||
const payment = paymentProp ?? navState?.payment ?? null
|
||||
const order = orderProp ?? placedOrder ?? orderFromNav ?? null
|
||||
const websiteUrl = getWebsiteUrl()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,8 +56,14 @@ export function CheckoutSuccessStep() {
|
||||
</div>
|
||||
|
||||
<div className={styles.successHeader}>
|
||||
<h1 className={styles.stepTitle}>سفارش ثبت شد</h1>
|
||||
<p className={styles.stepDesc}>از خرید شما سپاسگزاریم — سفارش دریافت شد.</p>
|
||||
<h1 className={styles.stepTitle}>
|
||||
{payment ? 'پرداخت موفق' : 'سفارش ثبت شد'}
|
||||
</h1>
|
||||
<p className={styles.stepDesc}>
|
||||
{payment
|
||||
? 'پرداخت شما تأیید شد و سفارش ثبت گردید.'
|
||||
: 'از خرید شما سپاسگزاریم — سفارش دریافت شد.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className={styles.successMeta}>
|
||||
@@ -46,17 +73,50 @@ export function CheckoutSuccessStep() {
|
||||
</div>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>مبلغ</dt>
|
||||
<dd className={styles.successMetaValue}>{formatIrtPrice(order.total)}</dd>
|
||||
</div>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>وضعیت</dt>
|
||||
<dd>در انتظار تأیید</dd>
|
||||
<dd className={styles.successMetaValue}>
|
||||
{formatIrtPrice(payment?.amount ?? order.total)}
|
||||
</dd>
|
||||
</div>
|
||||
{payment ? (
|
||||
<>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>وضعیت پرداخت</dt>
|
||||
<dd>{transactionStatusLabelFa('completed')}</dd>
|
||||
</div>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>درگاه</dt>
|
||||
<dd>{gatewayLabelFa(payment.gatewayType)}</dd>
|
||||
</div>
|
||||
{payment.trackId ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>کد پیگیری</dt>
|
||||
<dd className={styles.successMetaValue}>{payment.trackId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{payment.transactionId ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>شناسه تراکنش</dt>
|
||||
<dd className={styles.successMetaValue}>{payment.transactionId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{payment.gatewayRef ? (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>شناسه مرجع درگاه</dt>
|
||||
<dd className={styles.successMetaValue}>{payment.gatewayRef}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>وضعیت</dt>
|
||||
<dd>در انتظار تأیید</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className={styles.successActions}>
|
||||
<Link to="/" className={styles.successDarkBtn} onClick={() => resetCheckout()}>
|
||||
رفتن به داشبورد مدیریت
|
||||
<Link to="/orders" className={styles.successDarkBtn} onClick={() => resetCheckout()}>
|
||||
مشاهده سفارشها
|
||||
</Link>
|
||||
<a href={websiteUrl} className={styles.successNeutralBtn} onClick={() => resetCheckout()}>
|
||||
بازگشت به وبسایت
|
||||
|
||||
@@ -46,7 +46,8 @@ export interface ShippingAddressInput {
|
||||
|
||||
export type CheckoutPaymentType = 'e_payment_gate' | 'transfer'
|
||||
|
||||
export type OnlineGatewayType = 'mellat_behpardakht' | 'saman_kish'
|
||||
/** Matches backend PaymentGatewayId (mellat first; others later). */
|
||||
export type OnlineGatewayType = 'mellat' | 'sep' | 'snappay' | 'digipay' | 'zarinpal'
|
||||
|
||||
export interface CheckoutPaymentInput {
|
||||
type: CheckoutPaymentType
|
||||
@@ -55,13 +56,33 @@ export interface CheckoutPaymentInput {
|
||||
transferRefNumber?: string
|
||||
}
|
||||
|
||||
export interface PaymentRedirect {
|
||||
method: 'GET' | 'POST'
|
||||
url: string
|
||||
fields?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CheckoutPaymentResult {
|
||||
gatewayType: string
|
||||
transactionId: string
|
||||
redirect: PaymentRedirect
|
||||
}
|
||||
|
||||
export interface CheckoutInput {
|
||||
addressId?: string
|
||||
shippingAddress?: ShippingAddressInput
|
||||
customerNotes?: string
|
||||
/** Absolute URL for bank callback return (required for e_payment_gate). */
|
||||
returnUrl?: string
|
||||
payment: CheckoutPaymentInput
|
||||
}
|
||||
|
||||
export interface CheckoutResponse {
|
||||
message: string
|
||||
order: Order
|
||||
payment?: CheckoutPaymentResult
|
||||
}
|
||||
|
||||
function cartPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
@@ -98,9 +119,34 @@ export async function removeCartItem(itemId: string) {
|
||||
}
|
||||
|
||||
export async function checkoutCart(input: CheckoutInput) {
|
||||
return apiRequest<{ message: string; order: Order }>(cartPath('/checkout'), {
|
||||
return apiRequest<CheckoutResponse>(cartPath('/checkout'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
/** Submit a gateway redirect (POST form or GET navigation). */
|
||||
export function submitPaymentRedirect(redirect: PaymentRedirect) {
|
||||
if (redirect.method === 'GET') {
|
||||
window.location.assign(redirect.url)
|
||||
return
|
||||
}
|
||||
|
||||
const form = document.createElement('form')
|
||||
form.method = 'POST'
|
||||
form.action = redirect.url
|
||||
form.style.display = 'none'
|
||||
form.acceptCharset = 'UTF-8'
|
||||
|
||||
for (const [name, value] of Object.entries(redirect.fields ?? {})) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = name
|
||||
input.value = value
|
||||
form.appendChild(input)
|
||||
}
|
||||
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,22 @@ export interface OrderCustomer {
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface OrderTransaction {
|
||||
id: string
|
||||
type: string
|
||||
status: string
|
||||
amount: number
|
||||
posType: string | null
|
||||
gatewayType: string | null
|
||||
gatewayRef: string | null
|
||||
gatewayTrackId: string | null
|
||||
transferAccount: string | null
|
||||
transferRefNumber: string | null
|
||||
notes: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
businessId: string
|
||||
@@ -61,6 +77,7 @@ export interface Order {
|
||||
updatedAt: string
|
||||
customer: OrderCustomer
|
||||
items: OrderItem[]
|
||||
transactions?: OrderTransaction[]
|
||||
}
|
||||
|
||||
export interface OrdersListResponse {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Order, OrderTransaction } from '../services/orderService'
|
||||
|
||||
export type PaymentResultStatus = 'success' | 'failed'
|
||||
|
||||
export interface CheckoutPaymentInfo {
|
||||
status: PaymentResultStatus
|
||||
transactionId?: string
|
||||
trackId?: string
|
||||
gatewayType?: string
|
||||
gatewayRef?: string
|
||||
amount?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function gatewayLabelFa(gatewayType: string | null | undefined): string {
|
||||
switch (gatewayType) {
|
||||
case 'mellat':
|
||||
return 'بانک ملت (بهپرداخت)'
|
||||
case 'sep':
|
||||
return 'سپ (سامان کیش)'
|
||||
case 'snappay':
|
||||
return 'اسنپپی'
|
||||
case 'digipay':
|
||||
return 'دیجیپی'
|
||||
case 'zarinpal':
|
||||
return 'زرینپال'
|
||||
default:
|
||||
return gatewayType?.trim() || 'درگاه آنلاین'
|
||||
}
|
||||
}
|
||||
|
||||
export function transactionStatusLabelFa(status: string | null | undefined): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'پرداخت موفق'
|
||||
case 'pending':
|
||||
return 'در انتظار پرداخت'
|
||||
case 'failed':
|
||||
return 'پرداخت ناموفق'
|
||||
case 'refunded':
|
||||
return 'بازگشت وجه'
|
||||
default:
|
||||
return status || '—'
|
||||
}
|
||||
}
|
||||
|
||||
export function pickOrderPaymentTransaction(
|
||||
order: Order | null | undefined,
|
||||
transactionId?: string,
|
||||
): OrderTransaction | null {
|
||||
const rows = order?.transactions ?? []
|
||||
if (!rows.length) return null
|
||||
if (transactionId) {
|
||||
return rows.find((row) => row.id === transactionId) ?? null
|
||||
}
|
||||
return (
|
||||
rows.find((row) => row.type === 'e_payment_gate') ??
|
||||
rows[0] ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function buildPaymentInfoFromOrder(
|
||||
order: Order,
|
||||
opts: {
|
||||
status: PaymentResultStatus
|
||||
transactionId?: string
|
||||
trackId?: string
|
||||
message?: string
|
||||
},
|
||||
): CheckoutPaymentInfo {
|
||||
const tx = pickOrderPaymentTransaction(order, opts.transactionId)
|
||||
return {
|
||||
status: opts.status,
|
||||
transactionId: tx?.id ?? opts.transactionId,
|
||||
trackId: tx?.gatewayTrackId ?? opts.trackId,
|
||||
gatewayType: tx?.gatewayType ?? undefined,
|
||||
gatewayRef: tx?.gatewayRef ?? undefined,
|
||||
amount: tx?.amount ?? order.total,
|
||||
message: opts.message,
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
addBusinessDomain,
|
||||
createBusiness,
|
||||
listBusinesses,
|
||||
lookupLegacyMigrate,
|
||||
migrateBusinessFromOld,
|
||||
purgeBusinessData,
|
||||
removeBusiness,
|
||||
setBusinessActive,
|
||||
updateBusiness,
|
||||
updateBusinessDomain,
|
||||
type LegacyMigrateLookupResponse,
|
||||
} from '../services/businessService'
|
||||
import { listBusinessCategories } from '../services/categoryService'
|
||||
import { Modal } from '../components/Modal'
|
||||
@@ -111,6 +113,8 @@ const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
|
||||
customer: 'Customers',
|
||||
blog_categories: 'Blog categories',
|
||||
blog: 'Blogs',
|
||||
video_categories: 'Video categories',
|
||||
video: 'Videos',
|
||||
portfolio_categories: 'Portfolio categories',
|
||||
portfolio: 'Portfolios',
|
||||
}
|
||||
@@ -137,6 +141,13 @@ const DATA_ENTITY_GROUPS = [
|
||||
['blog', 'Blogs'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Videos',
|
||||
items: [
|
||||
['video_categories', 'Categories'],
|
||||
['video', 'Videos'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Portfolios',
|
||||
items: [
|
||||
@@ -194,6 +205,8 @@ function emptyMigrateEntities(): Record<MigrateFromOldEntity, boolean> {
|
||||
customer: false,
|
||||
blog_categories: false,
|
||||
blog: false,
|
||||
video_categories: false,
|
||||
video: false,
|
||||
portfolio_categories: false,
|
||||
portfolio: false,
|
||||
}
|
||||
@@ -273,6 +286,9 @@ export function BusinessesPage() {
|
||||
const [migrateSubmitting, setMigrateSubmitting] = useState(false)
|
||||
const [migrateError, setMigrateError] = useState('')
|
||||
const [migrateResult, setMigrateResult] = useState<MigrateFromOldResponse | null>(null)
|
||||
const [migrateLookup, setMigrateLookup] = useState<LegacyMigrateLookupResponse | null>(null)
|
||||
const [migrateLookupLoading, setMigrateLookupLoading] = useState(false)
|
||||
const [migrateLookupError, setMigrateLookupError] = useState('')
|
||||
|
||||
const [purgeOpen, setPurgeOpen] = useState(false)
|
||||
const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null)
|
||||
@@ -629,6 +645,8 @@ export function BusinessesPage() {
|
||||
setMigrateEntities(emptyMigrateEntities())
|
||||
setMigrateError('')
|
||||
setMigrateResult(null)
|
||||
setMigrateLookup(null)
|
||||
setMigrateLookupError('')
|
||||
setMigrateOpen(true)
|
||||
}
|
||||
|
||||
@@ -641,8 +659,59 @@ export function BusinessesPage() {
|
||||
setMigrateBusiness(null)
|
||||
setMigrateError('')
|
||||
setMigrateResult(null)
|
||||
setMigrateLookup(null)
|
||||
setMigrateLookupError('')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!migrateOpen || !migrateBusiness) return
|
||||
|
||||
const trimmed = String(migrateOldId).trim()
|
||||
const explicitId = Number(trimmed)
|
||||
const hasExplicitId = trimmed !== '' && Number.isInteger(explicitId) && explicitId > 0
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
setMigrateLookupLoading(true)
|
||||
setMigrateLookupError('')
|
||||
try {
|
||||
const result = await lookupLegacyMigrate(
|
||||
migrateBusiness.id,
|
||||
hasExplicitId ? explicitId : undefined,
|
||||
controller.signal,
|
||||
)
|
||||
if (controller.signal.aborted) return
|
||||
setMigrateLookup(result)
|
||||
|
||||
// Auto-fill from Meshkee domain match when the field is empty
|
||||
if (
|
||||
!hasExplicitId &&
|
||||
result.source === 'meshkee_domain' &&
|
||||
result.oldBusinessId
|
||||
) {
|
||||
setMigrateOldId(result.oldBusinessId)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setMigrateLookup(null)
|
||||
setMigrateLookupError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'Unable to look up old CMS domain.',
|
||||
)
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setMigrateLookupLoading(false)
|
||||
}
|
||||
})()
|
||||
}, hasExplicitId ? 350 : 0)
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [migrateOpen, migrateBusiness, migrateOldId])
|
||||
|
||||
function selectMigrateEntity(entity: MigrateFromOldEntity) {
|
||||
setMigrateResult(null)
|
||||
setMigrateError('')
|
||||
@@ -1593,7 +1662,7 @@ export function BusinessesPage() {
|
||||
Link this business to a legacy WillaEngine business id, then choose one
|
||||
data type to migrate. Selecting blogs also migrates news under a top-level
|
||||
News category. Selecting customers also migrates client categories when
|
||||
needed.
|
||||
needed. The old id is resolved from the Meshkee domain when possible.
|
||||
</p>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="migrate-old-id">Old business id</label>
|
||||
@@ -1612,6 +1681,57 @@ export function BusinessesPage() {
|
||||
disabled={migrateSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{migrateLookupLoading ? (
|
||||
<p className={styles.alertInfo} role="status">
|
||||
Looking up old CMS domain…
|
||||
</p>
|
||||
) : null}
|
||||
{migrateLookupError ? (
|
||||
<p className={styles.alertError} role="alert">
|
||||
{migrateLookupError}
|
||||
</p>
|
||||
) : null}
|
||||
{migrateLookup && !migrateLookupLoading ? (
|
||||
migrateLookup.oldBusinessId && migrateLookup.domains.length > 0 ? (
|
||||
<div className={styles.alertInfo} role="status">
|
||||
<strong>Old CMS match</strong>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
Domain{migrateLookup.domains.length > 1 ? 's' : ''}:{' '}
|
||||
<strong dir="ltr">{migrateLookup.domains.join(', ')}</strong>
|
||||
</div>
|
||||
{(migrateLookup.brandEn || migrateLookup.brandFa) && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
Brand:{' '}
|
||||
<strong>
|
||||
{[migrateLookup.brandEn, migrateLookup.brandFa]
|
||||
.filter(Boolean)
|
||||
.join(' / ')}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{migrateLookup.matchedHost ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
Matched from Meshkee host:{' '}
|
||||
<strong dir="ltr">{migrateLookup.matchedHost}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
{migrateLookup.meshkeeHosts.length > 0 ? (
|
||||
<div style={{ marginTop: 4, opacity: 0.85 }}>
|
||||
This business hosts:{' '}
|
||||
<span dir="ltr">{migrateLookup.meshkeeHosts.join(', ')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.alertInfo} role="status">
|
||||
{migrateOldId.trim()
|
||||
? 'No old CMS business found for this id. Check the number before migrating.'
|
||||
: migrateLookup.meshkeeHosts.length
|
||||
? `No old CMS domain match for ${migrateLookup.meshkeeHosts.join(', ')}. Enter the old business id manually.`
|
||||
: 'This Meshkee business has no domain yet. Enter the old business id manually.'}
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
<div className={styles.field} style={{ marginTop: 12 }}>
|
||||
<span className={styles.entityGroupLabel}>Data to migrate (one)</span>
|
||||
<div className={styles.entityChecks}>
|
||||
|
||||
@@ -117,6 +117,32 @@ export async function migrateBusinessFromOld(
|
||||
})
|
||||
}
|
||||
|
||||
export interface LegacyMigrateLookupResponse {
|
||||
meshkeeHosts: string[]
|
||||
meshkeeBusinessName: string
|
||||
oldBusinessId: string | null
|
||||
domains: string[]
|
||||
brandEn: string | null
|
||||
brandFa: string | null
|
||||
slug: string | null
|
||||
matchedHost: string | null
|
||||
source: 'old_business_id' | 'meshkee_domain' | 'none'
|
||||
}
|
||||
|
||||
export async function lookupLegacyMigrate(
|
||||
businessId: string,
|
||||
oldBusinessId?: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const q = new URLSearchParams()
|
||||
if (oldBusinessId != null) q.set('oldBusinessId', String(oldBusinessId))
|
||||
const query = q.toString()
|
||||
return apiRequest<LegacyMigrateLookupResponse>(
|
||||
`/businesses/${businessId}/migrate-from-old/lookup${query ? `?${query}` : ''}`,
|
||||
{ auth: true, signal },
|
||||
)
|
||||
}
|
||||
|
||||
export async function purgeBusinessData(
|
||||
businessId: string,
|
||||
payload: PurgeBusinessDataPayload,
|
||||
|
||||
@@ -46,6 +46,8 @@ export type MigrateFromOldEntity =
|
||||
| 'customer'
|
||||
| 'blog_categories'
|
||||
| 'blog'
|
||||
| 'video_categories'
|
||||
| 'video'
|
||||
| 'portfolio_categories'
|
||||
| 'portfolio'
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'blog',
|
||||
'warehouse',
|
||||
'videos',
|
||||
'instructions',
|
||||
'finance',
|
||||
] as const
|
||||
|
||||
@@ -33,6 +34,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
|
||||
blog: 'Blog',
|
||||
warehouse: 'Warehouse',
|
||||
videos: 'Videos',
|
||||
instructions: 'Instructions',
|
||||
finance: 'Finance',
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **For AI agents:** Read this file at the start of a new chat before making changes.
|
||||
> Update this document when a major feature is completed or architecture changes.
|
||||
|
||||
Last updated: August 11, 2026
|
||||
Last updated: August 13, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -158,8 +158,10 @@ Add to `/etc/hosts` (one line per tenant):
|
||||
| `/checkout/login` | Checkout sign-in step |
|
||||
| `/checkout/cart` | Cart review |
|
||||
| `/checkout/delivery` | Address or pickup |
|
||||
| `/checkout/payment` | Discount code + payment |
|
||||
| `/checkout/success` | Order confirmation |
|
||||
| `/checkout/payment` | Discount code + payment (online → bank redirect) |
|
||||
| `/checkout/result` | Bank return callback (`status`, `orderId`, payment meta) |
|
||||
| `/checkout/success` | Order confirmation (+ payment data when from gateway) |
|
||||
| `/checkout/failed` | Checkout / payment failure (+ payment data when present) |
|
||||
| `/` | Home |
|
||||
| `/profile` | Profile |
|
||||
| `/addresses` | Addresses |
|
||||
@@ -220,7 +222,7 @@ Add to `/etc/hosts` (one line per tenant):
|
||||
| `/website/subscriptions` | Subscriptions | Placeholder |
|
||||
| `/website/faq` | FAQ | Placeholder |
|
||||
| `/website/badges` | Badges | Placeholder |
|
||||
| `/website/e-payment` | E-payment | Placeholder |
|
||||
| `/website/e-payment` | E-payment | Mellat credentials + multi-gateway stubs (SEP, Snapp Pay, DigiPay, Zarinpal) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user