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 { PortfolioCategoriesPage } from './pages/PortfolioCategoriesPage'
import { PortfolioSettingsPage } from './pages/PortfolioSettingsPage' import { PortfolioSettingsPage } from './pages/PortfolioSettingsPage'
import { PortfolioDetailsPage } from './pages/PortfolioDetailsPage' 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 { WebsitePage } from './pages/WebsitePage'
import { WebsiteContactPage } from './pages/WebsiteContactPage' import { WebsiteContactPage } from './pages/WebsiteContactPage'
import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage' import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage'
@@ -104,6 +117,7 @@ function App() {
<Route path="customer-products/new" element={<AddCustomerProductPage />} /> <Route path="customer-products/new" element={<AddCustomerProductPage />} />
<Route path="customer-products/:id/edit" element={<AddCustomerProductPage />} /> <Route path="customer-products/:id/edit" element={<AddCustomerProductPage />} />
<Route path="customer-products/:id" element={<CustomerProductDetailsPage />} /> <Route path="customer-products/:id" element={<CustomerProductDetailsPage />} />
<Route path="content" element={<ContentPage />} />
<Route path="blog" element={<BlogPage />} /> <Route path="blog" element={<BlogPage />} />
<Route path="blog/list" element={<BlogListPage />} /> <Route path="blog/list" element={<BlogListPage />} />
<Route path="blog/detail/:id" element={<BlogDetailsPage />} /> <Route path="blog/detail/:id" element={<BlogDetailsPage />} />
@@ -118,6 +132,20 @@ function App() {
<Route path="portfolios/edit/:id" element={<AddNewPortfolioPage />} /> <Route path="portfolios/edit/:id" element={<AddNewPortfolioPage />} />
<Route path="portfolios/categories" element={<PortfolioCategoriesPage />} /> <Route path="portfolios/categories" element={<PortfolioCategoriesPage />} />
<Route path="portfolios/settings" element={<PortfolioSettingsPage />} /> <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" element={<WebsitePage />} />
<Route path="website/sliders" element={<WebsiteSlidersPage />} /> <Route path="website/sliders" element={<WebsiteSlidersPage />} />
<Route path="website/special-categories" element={<WebsiteSpecialCategoriesPage />} /> <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, FileText,
Briefcase, Briefcase,
Globe, Globe,
Layers,
HelpCircle, HelpCircle,
LogOut, LogOut,
ChevronDown, ChevronDown,
@@ -31,6 +32,7 @@ import { getBusinessProfile } from '../services/businessProfileService'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { import {
hasBusinessModule, hasBusinessModule,
hasAnyBusinessModule,
type BusinessModuleId, type BusinessModuleId,
} from '../utils/businessModules' } from '../utils/businessModules'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
@@ -66,6 +68,11 @@ function isGroupActive(group: NavGroup, pathname: string) {
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) { if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
return true return true
} }
if (group.id === 'content') {
if (pathname.startsWith('/blog') || pathname.startsWith('/videos') || pathname.startsWith('/instructions')) {
return true
}
}
return group.children.some( return group.children.some(
(child) => pathname === child.to || pathname.startsWith(`${child.to}/`), (child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
) )
@@ -159,17 +166,20 @@ export function Sidebar() {
}, },
{ {
type: 'group', type: 'group',
id: 'blog', id: 'content',
icon: FileText, icon: Layers,
labelKey: 'nav.blog', labelKey: 'nav.content',
basePath: '/blog', basePath: '/content',
moduleId: 'blog',
children: [ children: [
{ labelKey: 'nav.blog.overview', to: '/blog' }, ...(hasBusinessModule(enabledModules, 'blog')
{ labelKey: 'nav.blog.list', to: '/blog/list' }, ? [{ labelKey: 'nav.content.blog' as BusinessMessageKey, to: '/blog' }]
{ labelKey: 'nav.blog.new', to: '/blog/new' }, : []),
{ labelKey: 'nav.blog.categories', to: '/blog/categories' }, ...(hasBusinessModule(enabledModules, 'videos')
{ labelKey: 'nav.blog.settings', to: '/blog/settings' }, ? [{ 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' }, { type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
] ]
return items.filter( return items.filter((item) => {
(item) => !item.moduleId || hasBusinessModule(enabledModules, item.moduleId), if (item.type === 'group' && item.id === 'content') {
) return hasAnyBusinessModule(enabledModules, ['blog', 'videos', 'instructions'])
}
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
})
}, [enabledModules]) }, [enabledModules])
useEffect(() => { 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.invoices': 'Invoices',
'nav.finance.transactions': 'Transactions', 'nav.finance.transactions': 'Transactions',
'nav.settings': 'Settings', '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': 'Blog',
'nav.blog.overview': 'Overview', 'nav.blog.overview': 'Overview',
'nav.blog.list': 'My Blogs', 'nav.blog.list': 'My Blogs',
@@ -50,6 +55,24 @@ const en = {
'nav.portfolios.new': 'Add New Portfolio', 'nav.portfolios.new': 'Add New Portfolio',
'nav.portfolios.categories': 'Categories', 'nav.portfolios.categories': 'Categories',
'nav.portfolios.settings': 'Settings', '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': 'Website',
'nav.website.overview': 'Overview', 'nav.website.overview': 'Overview',
'nav.website.sliders': 'Sliders', 'nav.website.sliders': 'Sliders',
@@ -154,6 +177,10 @@ const en = {
'bc.blogDetails': 'Blog Details', 'bc.blogDetails': 'Blog Details',
'bc.editPortfolio': 'Edit Portfolio', 'bc.editPortfolio': 'Edit Portfolio',
'bc.portfolioDetails': 'Portfolio Details', '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.welcome': 'Welcome back, {name}!',
'home.welcomeFallback': 'there', 'home.welcomeFallback': 'there',
@@ -173,10 +200,25 @@ const en = {
'home.card.settings.title': 'Settings', 'home.card.settings.title': 'Settings',
'home.card.settings.desc': 'Configure your store preferences and system settings.', 'home.card.settings.desc': 'Configure your store preferences and system settings.',
'home.card.settings.link': 'View 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.title': 'Blog',
'home.card.blog.desc': 'Create and manage blog posts and categories.', 'home.card.blog.desc': 'Create and manage blog posts and categories.',
'home.card.blog.link': 'View blog', 'home.card.blog.link': 'View blog',
'home.card.blog.count': 'posts', '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.title': 'Portfolios',
'home.card.portfolios.desc': 'Manage your portfolio items and showcase projects.', 'home.card.portfolios.desc': 'Manage your portfolio items and showcase projects.',
'home.card.portfolios.link': 'View portfolios', 'home.card.portfolios.link': 'View portfolios',
@@ -968,6 +1010,162 @@ const en = {
'blog.details.noContent': 'No content yet.', 'blog.details.noContent': 'No content yet.',
'blog.details.author': 'Author', '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.page.subtitle': 'Manage your portfolio items and showcase projects.',
'portfolio.card.list.desc': 'View, edit and manage all your portfolio items.', 'portfolio.card.list.desc': 'View, edit and manage all your portfolio items.',
'portfolio.card.list.link': 'View portfolios', 'portfolio.card.list.link': 'View portfolios',
@@ -1266,11 +1464,32 @@ const en = {
'website.badges.feature.toggle': 'Toggle visibility per badge', 'website.badges.feature.toggle': 'Toggle visibility per badge',
'website.ePayment.subtitle': 'Configure online payment methods for your website checkout.', '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.master': 'Enable online payments',
'website.ePayment.note': 'E-payment settings will be connected here. Planned options include:', 'website.ePayment.masterDesc':
'website.ePayment.feature.enable': 'Enable or disable online payments', 'When enabled, customers can pay through a configured gateway at website checkout.',
'website.ePayment.feature.credentials': 'Configure payment provider credentials', 'website.ePayment.gateway.mellat': 'Bank Mellat (Behpardakht)',
'website.ePayment.feature.methods': 'Set supported payment methods and test mode', '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.signIn': 'Sign in',
'title.home': 'Home', 'title.home': 'Home',
@@ -1290,11 +1509,22 @@ const en = {
'title.shoppingCards': 'Shopping Cards', 'title.shoppingCards': 'Shopping Cards',
'title.customers': 'Users', 'title.customers': 'Users',
'title.customerProducts': 'Customer products', 'title.customerProducts': 'Customer products',
'title.content': 'Content',
'title.blog': 'Blog', 'title.blog': 'Blog',
'title.myBlogs': 'My Blogs', 'title.myBlogs': 'My Blogs',
'title.addBlog': 'Add New Blog', 'title.addBlog': 'Add New Blog',
'title.editBlog': 'Edit Blog', 'title.editBlog': 'Edit Blog',
'title.blogDetails': 'Blog Details', '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.portfolios': 'Portfolios',
'title.myPortfolios': 'My Portfolios', 'title.myPortfolios': 'My Portfolios',
'title.addPortfolio': 'Add New Portfolio', 'title.addPortfolio': 'Add New Portfolio',
@@ -1650,6 +1880,11 @@ const fa: Record<MessageKey, string> = {
'nav.finance.invoices': 'فاکتورها', 'nav.finance.invoices': 'فاکتورها',
'nav.finance.transactions': 'تراکنش‌ها', 'nav.finance.transactions': 'تراکنش‌ها',
'nav.settings': 'تنظیمات', 'nav.settings': 'تنظیمات',
'nav.content': 'مدیریت محتوا',
'nav.content.blog': 'اخبار و مطالب',
'nav.content.videos': 'ویدیوها',
'nav.content.instructions': 'آموزش‌ها',
'nav.content.overview': 'نمای کلی',
'nav.blog': 'بلاگ', 'nav.blog': 'بلاگ',
'nav.blog.overview': 'نمای کلی', 'nav.blog.overview': 'نمای کلی',
'nav.blog.list': 'بلاگ‌های من', 'nav.blog.list': 'بلاگ‌های من',
@@ -1662,6 +1897,18 @@ const fa: Record<MessageKey, string> = {
'nav.portfolios.new': 'افزودن نمونه کار', 'nav.portfolios.new': 'افزودن نمونه کار',
'nav.portfolios.categories': 'دسته‌بندی‌ها', 'nav.portfolios.categories': 'دسته‌بندی‌ها',
'nav.portfolios.settings': 'تنظیمات', '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': 'وب‌سایت',
'nav.website.overview': 'نمای کلی', 'nav.website.overview': 'نمای کلی',
'nav.website.sliders': 'اسلایدرها', 'nav.website.sliders': 'اسلایدرها',
@@ -1766,6 +2013,10 @@ const fa: Record<MessageKey, string> = {
'bc.blogDetails': 'جزئیات بلاگ', 'bc.blogDetails': 'جزئیات بلاگ',
'bc.editPortfolio': 'ویرایش نمونه کار', 'bc.editPortfolio': 'ویرایش نمونه کار',
'bc.portfolioDetails': 'جزئیات نمونه کار', 'bc.portfolioDetails': 'جزئیات نمونه کار',
'bc.editVideo': 'ویرایش ویدیو',
'bc.videoDetails': 'جزئیات ویدیو',
'bc.editInstruction': 'ویرایش آموزش',
'bc.instructionDetails': 'جزئیات آموزش',
'home.welcome': '{name} عزیز، خوش آمدی.', 'home.welcome': '{name} عزیز، خوش آمدی.',
'home.welcomeFallback': 'کاربر', 'home.welcomeFallback': 'کاربر',
@@ -1785,6 +2036,17 @@ const fa: Record<MessageKey, string> = {
'home.card.settings.title': 'تنظیمات', 'home.card.settings.title': 'تنظیمات',
'home.card.settings.desc': 'ترجیحات فروشگاه و تنظیمات سیستم را پیکربندی کنید.', 'home.card.settings.desc': 'ترجیحات فروشگاه و تنظیمات سیستم را پیکربندی کنید.',
'home.card.settings.link': 'مشاهده تنظیمات', '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.title': 'بلاگ',
'home.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.', 'home.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.',
'home.card.blog.link': 'مشاهده بلاگ', 'home.card.blog.link': 'مشاهده بلاگ',
@@ -1793,6 +2055,10 @@ const fa: Record<MessageKey, string> = {
'home.card.portfolios.desc': 'نمونه کارها و پروژه‌های نمایشی را مدیریت کنید.', 'home.card.portfolios.desc': 'نمونه کارها و پروژه‌های نمایشی را مدیریت کنید.',
'home.card.portfolios.link': 'مشاهده نمونه کارها', 'home.card.portfolios.link': 'مشاهده نمونه کارها',
'home.card.portfolios.count': 'نمونه کار', '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.title': 'وب‌سایت',
'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها، عضویت‌ها و پرداخت الکترونیک.', 'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها، عضویت‌ها و پرداخت الکترونیک.',
'home.card.website.link': 'مشاهده وب‌سایت', 'home.card.website.link': 'مشاهده وب‌سایت',
@@ -2578,6 +2844,162 @@ const fa: Record<MessageKey, string> = {
'blog.details.noContent': 'هنوز محتوایی نیست.', 'blog.details.noContent': 'هنوز محتوایی نیست.',
'blog.details.author': 'نویسنده', '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.page.subtitle': 'نمونه کارها را مدیریت کنید و پروژه‌ها را به نمایش بگذارید.',
'portfolio.card.list.desc': 'همه نمونه کارها را مشاهده، ویرایش و مدیریت کنید.', 'portfolio.card.list.desc': 'همه نمونه کارها را مشاهده، ویرایش و مدیریت کنید.',
'portfolio.card.list.link': 'مشاهده نمونه کارها', 'portfolio.card.list.link': 'مشاهده نمونه کارها',
@@ -2874,12 +3296,34 @@ const fa: Record<MessageKey, string> = {
'website.badges.feature.toggle': 'نمایش یا مخفی کردن هر نشان', 'website.badges.feature.toggle': 'نمایش یا مخفی کردن هر نشان',
'website.ePayment.subtitle': 'روش‌های پرداخت آنلاین برای تسویه حساب وب‌سایت را پیکربندی کنید.', 'website.ePayment.subtitle': 'روش‌های پرداخت آنلاین برای تسویه حساب وب‌سایت را پیکربندی کنید.',
'website.ePayment.lead': 'website.ePayment.master': 'فعال‌سازی پرداخت آنلاین',
'درگاه‌های پرداخت را متصل کنید تا مشتریان از طریق فروشگاه آنلاین پرداخت کنند.', 'website.ePayment.masterDesc':
'website.ePayment.note': 'تنظیمات پرداخت الکترونیک به‌زودی اینجا متصل می‌شود. گزینه‌های برنامه‌ریزی‌شده:', 'با فعال بودن این گزینه، مشتریان در تسویه حساب وب‌سایت می‌توانند از درگاه پیکربندی‌شده پرداخت کنند.',
'website.ePayment.feature.enable': 'فعال یا غیرفعال کردن پرداخت آنلاین', 'website.ePayment.gateway.mellat': 'بانک ملت (به‌پرداخت)',
'website.ePayment.feature.credentials': 'پیکربندی اطلاعات درگاه پرداخت', 'website.ePayment.gateway.mellatDesc':
'website.ePayment.feature.methods': 'تنظیم روش‌های پرداخت و حالت آزمایشی', 'اولین درگاه. شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.',
'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.signIn': 'ورود',
'title.home': 'خانه', 'title.home': 'خانه',
@@ -2899,11 +3343,22 @@ const fa: Record<MessageKey, string> = {
'title.shoppingCards': 'کارت‌های خرید', 'title.shoppingCards': 'کارت‌های خرید',
'title.customers': 'کاربران', 'title.customers': 'کاربران',
'title.customerProducts': 'محصولات مشتریان', 'title.customerProducts': 'محصولات مشتریان',
'title.content': 'مدیریت محتوا',
'title.blog': 'بلاگ', 'title.blog': 'بلاگ',
'title.myBlogs': 'بلاگ‌های من', 'title.myBlogs': 'بلاگ‌های من',
'title.addBlog': 'افزودن بلاگ', 'title.addBlog': 'افزودن بلاگ',
'title.editBlog': 'ویرایش بلاگ', 'title.editBlog': 'ویرایش بلاگ',
'title.blogDetails': 'جزئیات بلاگ', '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.portfolios': 'نمونه کارها',
'title.myPortfolios': 'نمونه کارهای من', 'title.myPortfolios': 'نمونه کارهای من',
'title.addPortfolio': 'افزودن نمونه کار', 'title.addPortfolio': 'افزودن نمونه کار',
@@ -3249,15 +3704,26 @@ const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
Customers: 'nav.customers', Customers: 'nav.customers',
'Customer products': 'nav.customerProducts', 'Customer products': 'nav.customerProducts',
Blog: 'nav.blog', Blog: 'nav.blog',
Content: 'nav.content',
'My Blogs': 'nav.blog.list', 'My Blogs': 'nav.blog.list',
'Add New Blog': 'nav.blog.new', 'Add New Blog': 'nav.blog.new',
'Edit Blog': 'bc.editBlog', 'Edit Blog': 'bc.editBlog',
'Blog Details': 'bc.blogDetails', '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', Portfolios: 'nav.portfolios',
'My Portfolios': 'nav.portfolios.list', 'My Portfolios': 'nav.portfolios.list',
'Add New Portfolio': 'nav.portfolios.new', 'Add New Portfolio': 'nav.portfolios.new',
'Edit Portfolio': 'bc.editPortfolio', 'Edit Portfolio': 'bc.editPortfolio',
'Portfolio Details': 'bc.portfolioDetails', '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', Website: 'nav.website',
Sliders: 'nav.website.sliders', Sliders: 'nav.website.sliders',
'Special Categories': 'nav.website.specialCategories', '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: '/invoices', labels: [t('title.finance'), t('title.invoices')] },
{ match: '/transactions', labels: [t('title.finance'), t('title.transactions')] }, { match: '/transactions', labels: [t('title.finance'), t('title.transactions')] },
{ match: '/blog/list', labels: [t('title.blog'), t('title.myBlogs')] }, { match: '/content', labels: [t('title.content')] },
{ match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] }, { match: '/blog/list', labels: [t('title.content'), t('title.blog'), t('title.myBlogs')] },
{ match: '/blog/new', labels: [t('title.blog'), t('title.addBlog')] }, { match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.content'), t('title.blog'), t('title.blogDetails')] },
{ match: /^\/blog\/edit\/[^/]+$/, labels: [t('title.blog'), t('title.editBlog')] }, { match: '/blog/new', labels: [t('title.content'), t('title.blog'), t('title.addBlog')] },
{ match: '/blog/categories', labels: [t('title.blog'), t('title.categories')] }, { match: /^\/blog\/edit\/[^/]+$/, labels: [t('title.content'), t('title.blog'), t('title.editBlog')] },
{ match: '/blog/settings', labels: [t('title.blog'), t('title.settings')] }, { match: '/blog/categories', labels: [t('title.content'), t('title.blog'), t('title.categories')] },
{ match: '/blog', labels: [t('title.blog')] }, { 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/list', labels: [t('title.portfolios'), t('title.myPortfolios')] },
{ {
match: /^\/portfolios\/detail\/[^/]+$/, 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); 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) { @media (max-width: 768px) {
.form { .form {
padding: 20px 16px; padding: 20px 16px;
@@ -287,6 +381,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.videoEmbedRow {
grid-template-columns: 1fr;
}
.fieldFull, .fieldFull,
.fieldHalf, .fieldHalf,
.fieldCategory, .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 <Breadcrumbs
items={[ items={[
{ label: 'Dashboard', href: '/' }, { label: 'Dashboard', href: '/' },
{ label: 'Content', href: '/content' },
{ label: 'Blog' }, { 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, ShoppingBag,
Store, Store,
Users, Users,
FileText,
Briefcase, Briefcase,
Layers,
Wallet, Wallet,
} from 'lucide-react' } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
@@ -19,15 +19,18 @@ import { listStoreItems } from '../services/storeItemService'
import { listCustomers } from '../services/customerService' import { listCustomers } from '../services/customerService'
import { listBlogs } from '../services/blogService' import { listBlogs } from '../services/blogService'
import { listPortfolios } from '../services/portfolioService' import { listPortfolios } from '../services/portfolioService'
import { listVideos } from '../services/videoService'
import { listInstructions } from '../services/instructionService'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { import {
hasBusinessModule, hasBusinessModule,
hasAnyBusinessModule,
isActiveHomeChart, isActiveHomeChart,
type BusinessModuleId, type BusinessModuleId,
} from '../utils/businessModules' } from '../utils/businessModules'
import styles from '../components/PageContent.module.css' import styles from '../components/PageContent.module.css'
type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios' type CountKey = 'products' | 'store' | 'customers' | 'content' | 'portfolios'
const sections: { const sections: {
icon: typeof ShoppingBag icon: typeof ShoppingBag
@@ -39,6 +42,8 @@ const sections: {
countKey?: CountKey countKey?: CountKey
/** When set, card is shown only if this optional module is enabled. */ /** When set, card is shown only if this optional module is enabled. */
moduleId?: BusinessModuleId moduleId?: BusinessModuleId
/** When set, card is shown if any listed module is enabled. */
anyModuleIds?: BusinessModuleId[]
}[] = [ }[] = [
{ {
icon: ShoppingBag, icon: ShoppingBag,
@@ -78,14 +83,14 @@ const sections: {
moduleId: 'finance', moduleId: 'finance',
}, },
{ {
icon: FileText, icon: Layers,
titleKey: 'home.card.blog.title', titleKey: 'home.card.content.title',
descKey: 'home.card.blog.desc', descKey: 'home.card.content.desc',
linkKey: 'home.card.blog.link', linkKey: 'home.card.content.link',
countLabelKey: 'home.card.blog.count', countLabelKey: 'home.card.content.count',
href: '/blog', href: '/content',
countKey: 'blog', countKey: 'content',
moduleId: 'blog', anyModuleIds: ['blog', 'videos', 'instructions'],
}, },
{ {
icon: Briefcase, icon: Briefcase,
@@ -102,20 +107,23 @@ const sections: {
type SectionCounts = Partial<Record<CountKey, number>> type SectionCounts = Partial<Record<CountKey, number>>
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> { 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), listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
listStoreItems(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), listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
listBlogs(1, 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), 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 = {} const counts: SectionCounts = {}
if (products !== null) counts.products = products if (products !== null) counts.products = products
if (store !== null) counts.store = store if (store !== null) counts.store = store
if (customers !== null) counts.customers = customers if (customers !== null) counts.customers = customers
if (blog !== null) counts.blog = blog
if (portfolios !== null) counts.portfolios = portfolios 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 return counts
} }
@@ -134,10 +142,12 @@ export function HomePage() {
return () => controller.abort() return () => controller.abort()
}, []) }, [])
const visibleSections = sections.filter( const visibleSections = sections.filter((section) => {
(section) => if (section.anyModuleIds) {
!section.moduleId || hasBusinessModule(enabledModules, section.moduleId), return hasAnyBusinessModule(enabledModules, section.anyModuleIds)
) }
return !section.moduleId || hasBusinessModule(enabledModules, section.moduleId)
})
const visibleCharts = homeCharts.filter(isActiveHomeChart) const visibleCharts = homeCharts.filter(isActiveHomeChart)
const firstName = 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 = { const DEFAULT_STORE_SETTINGS: StoreSettings = {
onlineSellEnabled: true, onlineSellEnabled: true,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS, 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[]) { 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 { 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 { 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() { export function WebsiteEPaymentPage() {
const t = useT() 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 ( return (
<WebsiteSectionShell <WebsiteSectionShell
@@ -11,13 +179,180 @@ export function WebsiteEPaymentPage() {
title={t('title.ePayment')} title={t('title.ePayment')}
subtitle={t('website.ePayment.subtitle')} subtitle={t('website.ePayment.subtitle')}
> >
<p className={styles.placeholderLead}>{t('website.ePayment.lead')}</p> {error ? <div className={sharedStyles.alertError}>{error}</div> : null}
<p className={styles.placeholderNote}>{t('website.ePayment.note')}</p>
<ul className={styles.featureList}> {isLoading ? (
<li>{t('website.ePayment.feature.enable')}</li> <p className={sharedStyles.status}>{t('productSettings.loading')}</p>
<li>{t('website.ePayment.feature.credentials')}</li> ) : (
<li>{t('website.ePayment.feature.methods')}</li> <div className={styles.stack}>
</ul> <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> </WebsiteSectionShell>
) )
} }
+19 -1
View File
@@ -47,7 +47,7 @@ export function mapCommentApiToUi(comment: CommentApi): ProductComment {
} }
async function listEntityComments( async function listEntityComments(
entityType: 'product' | 'blog' | 'portfolio', entityType: 'product' | 'blog' | 'portfolio' | 'video' | 'instruction',
entityId: string, entityId: string,
page = 1, page = 1,
pageSize = COMMENTS_PER_PAGE, pageSize = COMMENTS_PER_PAGE,
@@ -98,6 +98,24 @@ export async function listPortfolioComments(
return listEntityComments('portfolio', portfolioId, page, pageSize, signal) 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) { export async function updateCommentApproval(commentId: string, isApproved: boolean) {
const data = await apiRequest<{ comment: CommentApi }>(commentsPath(`/${commentId}`), { const data = await apiRequest<{ comment: CommentApi }>(commentsPath(`/${commentId}`), {
method: 'PATCH', 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 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 { export interface StoreSettings {
onlineSellEnabled: boolean onlineSellEnabled: boolean
orderProcessSteps: OrderProcessStep[] 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 { 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', 'blog',
'warehouse', 'warehouse',
'videos', 'videos',
'instructions',
'finance', 'finance',
] as const ] as const
@@ -89,6 +90,13 @@ export function hasBusinessModule(
return enabled.includes(moduleId) 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 { export function isActiveHomeChart(value: HomeChartId): boolean {
return value !== 'none' return value !== 'none'
} }
+2
View File
@@ -26,6 +26,7 @@ import { CheckoutDeliveryStep } from './pages/checkout/CheckoutDeliveryStep'
import { CheckoutPaymentStep } from './pages/checkout/CheckoutPaymentStep' import { CheckoutPaymentStep } from './pages/checkout/CheckoutPaymentStep'
import { CheckoutSuccessStep } from './pages/checkout/CheckoutSuccessStep' import { CheckoutSuccessStep } from './pages/checkout/CheckoutSuccessStep'
import { CheckoutFailedStep } from './pages/checkout/CheckoutFailedStep' import { CheckoutFailedStep } from './pages/checkout/CheckoutFailedStep'
import { CheckoutResultStep } from './pages/checkout/CheckoutResultStep'
function App() { function App() {
return ( return (
@@ -44,6 +45,7 @@ function App() {
<Route path="cart" element={<CheckoutCartStep />} /> <Route path="cart" element={<CheckoutCartStep />} />
<Route path="delivery" element={<CheckoutDeliveryStep />} /> <Route path="delivery" element={<CheckoutDeliveryStep />} />
<Route path="payment" element={<CheckoutPaymentStep />} /> <Route path="payment" element={<CheckoutPaymentStep />} />
<Route path="result" element={<CheckoutResultStep />} />
<Route path="success" element={<CheckoutSuccessStep />} /> <Route path="success" element={<CheckoutSuccessStep />} />
<Route path="failed" element={<CheckoutFailedStep />} /> <Route path="failed" element={<CheckoutFailedStep />} />
<Route index element={<CheckoutCartStep />} /> <Route index element={<CheckoutCartStep />} />
+1
View File
@@ -8,6 +8,7 @@ export const customerRouteTitleRules: RouteTitleRule[] = [
{ match: '/checkout/cart', labels: ['Checkout', 'Shopping Cart'] }, { match: '/checkout/cart', labels: ['Checkout', 'Shopping Cart'] },
{ match: '/checkout/delivery', labels: ['Checkout', 'Delivery'] }, { match: '/checkout/delivery', labels: ['Checkout', 'Delivery'] },
{ match: '/checkout/payment', labels: ['Checkout', 'Payment'] }, { match: '/checkout/payment', labels: ['Checkout', 'Payment'] },
{ match: '/checkout/result', labels: ['Checkout', 'Payment result'] },
{ match: '/checkout/success', labels: ['Checkout', 'Success'] }, { match: '/checkout/success', labels: ['Checkout', 'Success'] },
{ match: '/checkout/failed', labels: ['Checkout', 'Failed'] }, { match: '/checkout/failed', labels: ['Checkout', 'Failed'] },
{ match: '/checkout', labels: ['Checkout'] }, { match: '/checkout', labels: ['Checkout'] },
@@ -1,12 +1,40 @@
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import orderFailedImg from '../../assets/order-failed.png' 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' 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 navigate = useNavigate()
const location = useLocation() 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 = const message =
(location.state as { message?: string } | null)?.message || messageProp ||
navState?.message ||
payment?.message ||
'ثبت سفارش ممکن نشد. لطفاً دوباره تلاش کنید.' 'ثبت سفارش ممکن نشد. لطفاً دوباره تلاش کنید.'
return ( return (
@@ -20,10 +48,57 @@ export function CheckoutFailedStep() {
</div> </div>
<div className={styles.successHeader}> <div className={styles.successHeader}>
<h1 className={styles.stepTitle}>سفارش ثبت نشد</h1> <h1 className={styles.stepTitle}>
{payment ? 'پرداخت ناموفق' : 'سفارش ثبت نشد'}
</h1>
<p className={styles.stepDesc}>{message}</p> <p className={styles.stepDesc}>{message}</p>
</div> </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}> <div className={styles.successActions}>
<button <button
type="button" type="button"
@@ -4,14 +4,20 @@ import { useAuth } from '../../context/AuthContext'
import { CheckoutStepper, type CheckoutStepId } from '../../components/checkout/CheckoutStepper' import { CheckoutStepper, type CheckoutStepId } from '../../components/checkout/CheckoutStepper'
import { loginPathWithRedirect, safeRedirectPath } from '../../lib/redirect' 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']) const PUBLIC_STEPS = new Set<CheckoutStepId>(['cart', 'login'])
function stepFromPath(pathname: string): CheckoutStepId { function stepFromPath(pathname: string): CheckoutStepId {
if (pathname.endsWith('/login')) return 'login' if (pathname.endsWith('/login')) return 'login'
if (pathname.endsWith('/delivery')) return 'delivery' if (pathname.endsWith('/delivery')) return 'delivery'
if (pathname.endsWith('/payment')) return 'payment' 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' return 'cart'
} }
@@ -26,11 +32,12 @@ export function CheckoutFlow() {
} }
if (!isAuthenticated && !PUBLIC_STEPS.has(step)) { if (!isAuthenticated && !PUBLIC_STEPS.has(step)) {
const returnTo = `${location.pathname}${location.search}`
return ( return (
<Navigate <Navigate
to={loginPathWithRedirect(location.pathname, '/checkout/login')} to={loginPathWithRedirect(returnTo, '/checkout/login')}
replace replace
state={{ from: location.pathname }} state={{ from: returnTo }}
/> />
) )
} }
@@ -43,11 +50,14 @@ export function CheckoutFlow() {
return <Navigate to={destination} replace /> return <Navigate to={destination} replace />
} }
const hideStepper =
location.pathname === '/checkout/success' ||
location.pathname === '/checkout/failed' ||
location.pathname === '/checkout/result'
return ( return (
<> <>
{step !== 'login' && {step !== 'login' && !hideStepper && (
location.pathname !== '/checkout/success' &&
location.pathname !== '/checkout/failed' && (
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} /> <CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
)} )}
<Outlet /> <Outlet />
@@ -7,6 +7,7 @@ import { ApiError, isAbortError } from '../../lib/api'
import { import {
checkoutCart, checkoutCart,
getCart, getCart,
submitPaymentRedirect,
type CheckoutPaymentType, type CheckoutPaymentType,
type OnlineGatewayType, type OnlineGatewayType,
} from '../../services/cartService' } from '../../services/cartService'
@@ -16,9 +17,9 @@ import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton' import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
import styles from './CheckoutSteps.module.css' import styles from './CheckoutSteps.module.css'
const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string }[] = [ const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string; ready: boolean }[] = [
{ id: 'mellat_behpardakht', label: 'درگاه پرداخت بانک ملت - به‌پرداخت' }, { id: 'mellat', label: 'درگاه پرداخت بانک ملت - به‌پرداخت', ready: true },
{ id: 'saman_kish', label: 'درگاه پرداخت بانک سامان - سامان کیش' }, { id: 'sep', label: 'درگاه پرداخت بانک سامان - سامان کیش', ready: false },
] ]
export function CheckoutPaymentStep() { export function CheckoutPaymentStep() {
@@ -31,7 +32,7 @@ export function CheckoutPaymentStep() {
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate') 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 [transferAccount, setTransferAccount] = useState('')
const [transferRefNumber, setTransferRefNumber] = useState('') const [transferRefNumber, setTransferRefNumber] = useState('')
const [discountApplied, setDiscountApplied] = useState(false) const [discountApplied, setDiscountApplied] = useState(false)
@@ -100,7 +101,14 @@ export function CheckoutPaymentStep() {
return 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 (paymentType === 'transfer') {
if (!transferAccount.trim() || !transferRefNumber.trim()) { if (!transferAccount.trim() || !transferRefNumber.trim()) {
setError('لطفاً شماره حساب/کارت و شماره مرجع را وارد کنید.') setError('لطفاً شماره حساب/کارت و شماره مرجع را وارد کنید.')
@@ -119,9 +127,12 @@ export function CheckoutPaymentStep() {
} }
try { try {
// Both types persist via existing backend TransactionType:
// e_payment_gate (+ gatewayType) | transfer (+ account/ref)
const payment = buildPaymentPayload() const payment = buildPaymentPayload()
const returnUrl =
paymentType === 'e_payment_gate'
? `${window.location.origin}/checkout/result`
: undefined
const payload = const payload =
deliveryMode === 'pickup' deliveryMode === 'pickup'
? { ? {
@@ -132,17 +143,25 @@ export function CheckoutPaymentStep() {
postalCode: '0000000000', postalCode: '0000000000',
}, },
customerNotes: notes.join(' · ') || undefined, customerNotes: notes.join(' · ') || undefined,
returnUrl,
payment, payment,
} }
: { : {
addressId: selectedAddressId!, addressId: selectedAddressId!,
customerNotes: notes.join(' · ') || undefined, customerNotes: notes.join(' · ') || undefined,
returnUrl,
payment, payment,
} }
const result = await checkoutCart(payload) const result = await checkoutCart(payload)
setPlacedOrder(result.order) 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 } }) navigate('/checkout/success', { replace: true, state: { order: result.order } })
showToast('سفارش با موفقیت ثبت شد.', 'success') showToast('سفارش با موفقیت ثبت شد.', 'success')
} catch (err) { } catch (err) {
@@ -243,12 +262,15 @@ export function CheckoutPaymentStep() {
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
onClick={() => setGatewayType(gateway.id)} onClick={() => setGatewayType(gateway.id)}
disabled={submitting} disabled={submitting || !gateway.ready}
> >
<span className={styles.paymentSubRadio}> <span className={styles.paymentSubRadio}>
{active ? <span className={styles.paymentSubRadioInner} /> : null} {active ? <span className={styles.paymentSubRadioInner} /> : null}
</span> </span>
<span>{gateway.label}</span> <span>
{gateway.label}
{!gateway.ready ? ' (به‌زودی)' : ''}
</span>
</button> </button>
) )
})} })}
@@ -321,7 +343,15 @@ export function CheckoutPaymentStep() {
</div> </div>
<CheckoutActionBar <CheckoutActionBar
primaryLabel={submitting ? 'در حال ثبت سفارش...' : 'پرداخت و ثبت سفارش'} primaryLabel={
submitting
? paymentType === 'e_payment_gate'
? 'در حال انتقال به درگاه...'
: 'در حال ثبت سفارش...'
: paymentType === 'e_payment_gate'
? 'پرداخت آنلاین'
: 'پرداخت و ثبت سفارش'
}
onPrimary={() => void handlePlaceOrder()} onPrimary={() => void handlePlaceOrder()}
primaryDisabled={submitting} 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 type { Order } from '../../services/orderService'
import { getWebsiteUrl } from '../../services/websiteService' import { getWebsiteUrl } from '../../services/websiteService'
import { formatIrtPrice } from '../../utils/irtPrice' import { formatIrtPrice } from '../../utils/irtPrice'
import {
gatewayLabelFa,
transactionStatusLabelFa,
type CheckoutPaymentInfo,
} from '../../utils/checkoutPayment'
import orderSucceedImg from '../../assets/order-succeed.png' import orderSucceedImg from '../../assets/order-succeed.png'
import styles from './CheckoutSteps.module.css' 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 location = useLocation()
const { placedOrder, setPlacedOrder, resetCheckout } = useCheckout() const { placedOrder, setPlacedOrder, resetCheckout } = useCheckout()
const orderFromNav = (location.state as { order?: Order } | null)?.order const navState = (location.state as SuccessLocationState | null) ?? null
const order = placedOrder ?? orderFromNav ?? null const orderFromNav = navState?.order
const payment = paymentProp ?? navState?.payment ?? null
const order = orderProp ?? placedOrder ?? orderFromNav ?? null
const websiteUrl = getWebsiteUrl() const websiteUrl = getWebsiteUrl()
useEffect(() => { useEffect(() => {
@@ -35,8 +56,14 @@ export function CheckoutSuccessStep() {
</div> </div>
<div className={styles.successHeader}> <div className={styles.successHeader}>
<h1 className={styles.stepTitle}>سفارش ثبت شد</h1> <h1 className={styles.stepTitle}>
<p className={styles.stepDesc}>از خرید شما سپاسگزاریم — سفارش دریافت شد.</p> {payment ? 'پرداخت موفق' : 'سفارش ثبت شد'}
</h1>
<p className={styles.stepDesc}>
{payment
? 'پرداخت شما تأیید شد و سفارش ثبت گردید.'
: 'از خرید شما سپاسگزاریم — سفارش دریافت شد.'}
</p>
</div> </div>
<dl className={styles.successMeta}> <dl className={styles.successMeta}>
@@ -46,17 +73,50 @@ export function CheckoutSuccessStep() {
</div> </div>
<div className={styles.successMetaRow}> <div className={styles.successMetaRow}>
<dt>مبلغ</dt> <dt>مبلغ</dt>
<dd className={styles.successMetaValue}>{formatIrtPrice(order.total)}</dd> <dd className={styles.successMetaValue}>
</div> {formatIrtPrice(payment?.amount ?? order.total)}
<div className={styles.successMetaRow}> </dd>
<dt>وضعیت</dt>
<dd>در انتظار تأیید</dd>
</div> </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> </dl>
<div className={styles.successActions}> <div className={styles.successActions}>
<Link to="/" className={styles.successDarkBtn} onClick={() => resetCheckout()}> <Link to="/orders" className={styles.successDarkBtn} onClick={() => resetCheckout()}>
رفتن به داشبورد مدیریت مشاهده سفارش‌ها
</Link> </Link>
<a href={websiteUrl} className={styles.successNeutralBtn} onClick={() => resetCheckout()}> <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 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 { export interface CheckoutPaymentInput {
type: CheckoutPaymentType type: CheckoutPaymentType
@@ -55,13 +56,33 @@ export interface CheckoutPaymentInput {
transferRefNumber?: string 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 { export interface CheckoutInput {
addressId?: string addressId?: string
shippingAddress?: ShippingAddressInput shippingAddress?: ShippingAddressInput
customerNotes?: string customerNotes?: string
/** Absolute URL for bank callback return (required for e_payment_gate). */
returnUrl?: string
payment: CheckoutPaymentInput payment: CheckoutPaymentInput
} }
export interface CheckoutResponse {
message: string
order: Order
payment?: CheckoutPaymentResult
}
function cartPath(suffix = '') { function cartPath(suffix = '') {
const businessId = getActiveBusinessId() const businessId = getActiveBusinessId()
if (!businessId) { if (!businessId) {
@@ -98,9 +119,34 @@ export async function removeCartItem(itemId: string) {
} }
export async function checkoutCart(input: CheckoutInput) { export async function checkoutCart(input: CheckoutInput) {
return apiRequest<{ message: string; order: Order }>(cartPath('/checkout'), { return apiRequest<CheckoutResponse>(cartPath('/checkout'), {
method: 'POST', method: 'POST',
auth: true, auth: true,
body: input, 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 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 { export interface Order {
id: string id: string
businessId: string businessId: string
@@ -61,6 +77,7 @@ export interface Order {
updatedAt: string updatedAt: string
customer: OrderCustomer customer: OrderCustomer
items: OrderItem[] items: OrderItem[]
transactions?: OrderTransaction[]
} }
export interface OrdersListResponse { 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, addBusinessDomain,
createBusiness, createBusiness,
listBusinesses, listBusinesses,
lookupLegacyMigrate,
migrateBusinessFromOld, migrateBusinessFromOld,
purgeBusinessData, purgeBusinessData,
removeBusiness, removeBusiness,
setBusinessActive, setBusinessActive,
updateBusiness, updateBusiness,
updateBusinessDomain, updateBusinessDomain,
type LegacyMigrateLookupResponse,
} from '../services/businessService' } from '../services/businessService'
import { listBusinessCategories } from '../services/categoryService' import { listBusinessCategories } from '../services/categoryService'
import { Modal } from '../components/Modal' import { Modal } from '../components/Modal'
@@ -111,6 +113,8 @@ const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
customer: 'Customers', customer: 'Customers',
blog_categories: 'Blog categories', blog_categories: 'Blog categories',
blog: 'Blogs', blog: 'Blogs',
video_categories: 'Video categories',
video: 'Videos',
portfolio_categories: 'Portfolio categories', portfolio_categories: 'Portfolio categories',
portfolio: 'Portfolios', portfolio: 'Portfolios',
} }
@@ -137,6 +141,13 @@ const DATA_ENTITY_GROUPS = [
['blog', 'Blogs'], ['blog', 'Blogs'],
], ],
}, },
{
title: 'Videos',
items: [
['video_categories', 'Categories'],
['video', 'Videos'],
],
},
{ {
title: 'Portfolios', title: 'Portfolios',
items: [ items: [
@@ -194,6 +205,8 @@ function emptyMigrateEntities(): Record<MigrateFromOldEntity, boolean> {
customer: false, customer: false,
blog_categories: false, blog_categories: false,
blog: false, blog: false,
video_categories: false,
video: false,
portfolio_categories: false, portfolio_categories: false,
portfolio: false, portfolio: false,
} }
@@ -273,6 +286,9 @@ export function BusinessesPage() {
const [migrateSubmitting, setMigrateSubmitting] = useState(false) const [migrateSubmitting, setMigrateSubmitting] = useState(false)
const [migrateError, setMigrateError] = useState('') const [migrateError, setMigrateError] = useState('')
const [migrateResult, setMigrateResult] = useState<MigrateFromOldResponse | null>(null) 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 [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null) const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null)
@@ -629,6 +645,8 @@ export function BusinessesPage() {
setMigrateEntities(emptyMigrateEntities()) setMigrateEntities(emptyMigrateEntities())
setMigrateError('') setMigrateError('')
setMigrateResult(null) setMigrateResult(null)
setMigrateLookup(null)
setMigrateLookupError('')
setMigrateOpen(true) setMigrateOpen(true)
} }
@@ -641,8 +659,59 @@ export function BusinessesPage() {
setMigrateBusiness(null) setMigrateBusiness(null)
setMigrateError('') setMigrateError('')
setMigrateResult(null) 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) { function selectMigrateEntity(entity: MigrateFromOldEntity) {
setMigrateResult(null) setMigrateResult(null)
setMigrateError('') setMigrateError('')
@@ -1593,7 +1662,7 @@ export function BusinessesPage() {
Link this business to a legacy WillaEngine business id, then choose one 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 data type to migrate. Selecting blogs also migrates news under a top-level
News category. Selecting customers also migrates client categories when News category. Selecting customers also migrates client categories when
needed. needed. The old id is resolved from the Meshkee domain when possible.
</p> </p>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="migrate-old-id">Old business id</label> <label htmlFor="migrate-old-id">Old business id</label>
@@ -1612,6 +1681,57 @@ export function BusinessesPage() {
disabled={migrateSubmitting} disabled={migrateSubmitting}
/> />
</div> </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 }}> <div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to migrate (one)</span> <span className={styles.entityGroupLabel}>Data to migrate (one)</span>
<div className={styles.entityChecks}> <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( export async function purgeBusinessData(
businessId: string, businessId: string,
payload: PurgeBusinessDataPayload, payload: PurgeBusinessDataPayload,
+2
View File
@@ -46,6 +46,8 @@ export type MigrateFromOldEntity =
| 'customer' | 'customer'
| 'blog_categories' | 'blog_categories'
| 'blog' | 'blog'
| 'video_categories'
| 'video'
| 'portfolio_categories' | 'portfolio_categories'
| 'portfolio' | 'portfolio'
@@ -6,6 +6,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'blog', 'blog',
'warehouse', 'warehouse',
'videos', 'videos',
'instructions',
'finance', 'finance',
] as const ] as const
@@ -33,6 +34,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
blog: 'Blog', blog: 'Blog',
warehouse: 'Warehouse', warehouse: 'Warehouse',
videos: 'Videos', videos: 'Videos',
instructions: 'Instructions',
finance: 'Finance', 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. > **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. > 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/login` | Checkout sign-in step |
| `/checkout/cart` | Cart review | | `/checkout/cart` | Cart review |
| `/checkout/delivery` | Address or pickup | | `/checkout/delivery` | Address or pickup |
| `/checkout/payment` | Discount code + payment | | `/checkout/payment` | Discount code + payment (online → bank redirect) |
| `/checkout/success` | Order confirmation | | `/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 | | `/` | Home |
| `/profile` | Profile | | `/profile` | Profile |
| `/addresses` | Addresses | | `/addresses` | Addresses |
@@ -220,7 +222,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/website/subscriptions` | Subscriptions | Placeholder | | `/website/subscriptions` | Subscriptions | Placeholder |
| `/website/faq` | FAQ | Placeholder | | `/website/faq` | FAQ | Placeholder |
| `/website/badges` | Badges | 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) |
--- ---