Add e-payment UI, checkout payment result, and legacy migrate domain lookup.

Wire Mellat settings in business dashboard, customer bank return page with payment details, and super-admin migrate modal domain confirmation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-13 09:51:22 +03:30
co-authored by Cursor
parent a11f9643bb
commit cc94b929e0
54 changed files with 6415 additions and 96 deletions
+28
View File
@@ -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>
)
}
+26 -13
View File
@@ -9,6 +9,7 @@ import {
FileText,
Briefcase,
Globe,
Layers,
HelpCircle,
LogOut,
ChevronDown,
@@ -31,6 +32,7 @@ import { getBusinessProfile } from '../services/businessProfileService'
import { useTenantBranding } from '../context/TenantBrandingContext'
import {
hasBusinessModule,
hasAnyBusinessModule,
type BusinessModuleId,
} from '../utils/businessModules'
import { Tooltip } from './Tooltip'
@@ -66,6 +68,11 @@ function isGroupActive(group: NavGroup, pathname: string) {
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
return true
}
if (group.id === 'content') {
if (pathname.startsWith('/blog') || pathname.startsWith('/videos') || pathname.startsWith('/instructions')) {
return true
}
}
return group.children.some(
(child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
)
@@ -159,17 +166,20 @@ export function Sidebar() {
},
{
type: 'group',
id: 'blog',
icon: FileText,
labelKey: 'nav.blog',
basePath: '/blog',
moduleId: 'blog',
id: 'content',
icon: Layers,
labelKey: 'nav.content',
basePath: '/content',
children: [
{ labelKey: 'nav.blog.overview', to: '/blog' },
{ labelKey: 'nav.blog.list', to: '/blog/list' },
{ labelKey: 'nav.blog.new', to: '/blog/new' },
{ labelKey: 'nav.blog.categories', to: '/blog/categories' },
{ labelKey: 'nav.blog.settings', to: '/blog/settings' },
...(hasBusinessModule(enabledModules, 'blog')
? [{ labelKey: 'nav.content.blog' as BusinessMessageKey, to: '/blog' }]
: []),
...(hasBusinessModule(enabledModules, 'videos')
? [{ labelKey: 'nav.content.videos' as BusinessMessageKey, to: '/videos' }]
: []),
...(hasBusinessModule(enabledModules, 'instructions')
? [{ labelKey: 'nav.content.instructions' as BusinessMessageKey, to: '/instructions' }]
: []),
],
},
{
@@ -209,9 +219,12 @@ export function Sidebar() {
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
]
return items.filter(
(item) => !item.moduleId || hasBusinessModule(enabledModules, item.moduleId),
)
return items.filter((item) => {
if (item.type === 'group' && item.id === 'content') {
return hasAnyBusinessModule(enabledModules, ['blog', 'videos', 'instructions'])
}
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
})
}, [enabledModules])
useEffect(() => {
+137
View File
@@ -0,0 +1,137 @@
import { Link } from 'react-router-dom'
import { Pencil, MessageSquare, Trash2, Play } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Video } from '../types/video'
import {
formatVideoAuthor,
formatVideoCardDate,
resolveVideoTitle,
} from '../services/videoService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
interface VideoCardProps {
video: Video
commentCount: number
onEdit: (id: string) => void
onComments: (id: string) => void
onRemove: (id: string) => void
}
export function VideoCard({
video,
commentCount,
onEdit,
onComments,
onRemove,
}: VideoCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const displayTitle = resolveVideoTitle(video, isFa ? 'fa' : 'en')
const publishDate = formatVideoCardDate(
video.publishedAt ?? video.createdAt,
isFa ? 'fa' : 'en',
)
const titleLocale = textLocaleAttrs(displayTitle)
const abstractLocale = textLocaleAttrs(video.abstract)
const hasAbstract = Boolean(video.abstract?.trim())
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
return (
<article className={cardStyles.card}>
<Link to={`/videos/detail/${video.id}`} className={cardStyles.clickable}>
<div className={cardStyles.imageWrap}>
{video.coverImageUrl ? (
<img
src={video.coverImageUrl}
alt={displayTitle}
className={cardStyles.image}
loading="lazy"
/>
) : (
<div className={cardStyles.imagePlaceholder} aria-hidden="true">
<Play size={28} strokeWidth={1.5} />
</div>
)}
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
{video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'}
</span>
</div>
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
lang={titleLocale.lang}
dir={titleDir}
>
{displayTitle}
</h3>
{hasAbstract ? (
<p
className={[cardStyles.abstract, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractDir}
>
{video.abstract}
</p>
) : (
<p
className={cardStyles.abstract}
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{t('video.card.noSummary')}
</p>
)}
<p className={cardStyles.meta}>
<span>{formatVideoAuthor(video.author)}</span>
<span className={cardStyles.metaDot}>·</span>
<span>{publishDate}</span>
</p>
</div>
</Link>
<div className={controlStyles.controls}>
<Tooltip label={t('video.card.edit')}>
<button
type="button"
onClick={() => onEdit(video.id)}
aria-label={t('video.card.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('video.card.comments')}>
<button
type="button"
className={controlStyles.iconBtn}
onClick={() => onComments(video.id)}
aria-label={`${t('video.card.comments')} (${commentCount})`}
>
<MessageSquare size={16} />
{commentCount > 0 && (
<span className={controlStyles.commentBadge}>{commentCount}</span>
)}
</button>
</Tooltip>
<Tooltip label={t('video.card.remove')}>
<button
type="button"
className={controlStyles.danger}
onClick={() => onRemove(video.id)}
aria-label={t('video.card.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,68 @@
import type { Category } from '../types/category'
import { BlogCategoryRow } from './BlogCategoryRow'
import { getChildren, hasChildren } from '../utils/categories'
import styles from './CategoryTree.module.css'
interface VideoCategoryTreeProps {
categories: Category[]
expandedIds: Set<string>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
}
export function VideoCategoryTree({
categories,
expandedIds,
onToggle,
onAddSub,
onEdit,
onRemove,
parentId = null,
depth = 0,
}: VideoCategoryTreeProps) {
const nodes = getChildren(categories, parentId)
return (
<>
{nodes.map((category) => {
const childCount = hasChildren(categories, category.id)
const expanded = expandedIds.has(category.id)
return (
<div key={category.id}>
<BlogCategoryRow
category={category}
depth={depth}
hasChildren={childCount}
expanded={expanded}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
/>
{childCount && (
<div className={`${styles.childrenWrap} ${expanded ? styles.expanded : ''}`}>
<div className={styles.childrenInner}>
<VideoCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={onToggle}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
/>
</div>
</div>
)}
</div>
)
})}
</>
)
}
@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
COMMENTS_PER_PAGE,
deleteComment,
listVideoComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './ProductCommentsModal.module.css'
interface VideoCommentsModalProps {
open: boolean
videoId: string
videoTitle: string
onClose: () => void
onCountChange?: (count: number) => void
}
const ANIMATION_MS = 220
export function VideoCommentsModal({
open,
videoId,
videoTitle,
onClose,
onCountChange,
}: VideoCommentsModalProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE))
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setCurrentPage(1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open || !videoId) return
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [open, videoId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listVideoComments(videoId, page, COMMENTS_PER_PAGE, signal)
setComments(data.items)
setTotalComments(data.total)
onCountChange?.(data.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load comments.')
}
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages)
}
}, [currentPage, totalPages])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted) return null
async function toggleApproval(comment: ProductComment) {
setActionId(comment.id)
setError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c)))
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setError('')
try {
await deleteComment(commentId)
const nextTotal = totalComments - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setTotalComments(nextTotal)
onCountChange?.(nextTotal)
if (nextPage !== currentPage) {
setCurrentPage(nextPage)
} else {
setComments((prev) => prev.filter((c) => c.id !== commentId))
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="video-comments-title"
>
<div className={styles.header}>
<div>
<h3 id="video-comments-title" className={styles.title}>
Comments
</h3>
{videoTitle && (
<p className={styles.subtitle}>
{videoTitle} · {totalComments}{' '}
{totalComments === 1 ? 'comment' : 'comments'}
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this video.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react'
import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
COMMENTS_PER_PAGE,
deleteComment,
listVideoComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './BlogCommentsSection.module.css'
interface VideoCommentsSectionProps {
videoId: string
onCountChange?: (count: number) => void
}
export function VideoCommentsSection({ videoId, onCountChange }: VideoCommentsSectionProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [videoId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listVideoComments(videoId, page, COMMENTS_PER_PAGE, signal)
setComments(data.items)
setTotalComments(data.total)
onCountChange?.(data.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load comments.')
}
} finally {
setIsLoading(false)
}
}
async function toggleApproval(comment: ProductComment) {
setActionId(comment.id)
setError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c)))
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setError('')
try {
await deleteComment(commentId)
const nextTotal = totalComments - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setTotalComments(nextTotal)
onCountChange?.(nextTotal)
if (nextPage !== currentPage) {
setCurrentPage(nextPage)
} else {
setComments((prev) => prev.filter((c) => c.id !== commentId))
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
return (
<section className={styles.section}>
<h3 className={styles.title}>Comments ({totalComments})</h3>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.status}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.status}>No comments yet.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</>
)}
</section>
)
}
+499 -18
View File
@@ -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,
+342
View File
@@ -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>
)
}
+1
View File
@@ -51,6 +51,7 @@ export function BlogPage() {
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Content', href: '/content' },
{ label: 'Blog' },
]}
/>
+74
View File
@@ -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>
)
}
+26 -16
View File
@@ -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>
)
}
+231
View File
@@ -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>
)
}
+79
View File
@@ -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;
}
}
+343 -8
View File
@@ -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>
)
}
+19 -1
View File
@@ -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,
}
}
+237
View File
@@ -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,
})
}
+40
View File
@@ -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' },
]
+40
View File
@@ -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'
}
+2
View File
@@ -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 />} />
+1
View File
@@ -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()}>
بازگشت به وبسایت
+48 -2
View File
@@ -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,
}
}
+121 -1
View File
@@ -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,
+2
View File
@@ -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',
}
+6 -4
View File
@@ -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) |
---