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
@@ -0,0 +1,342 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
listInstructionCategories,
mapInstructionCategoryToUi,
} from '../services/instructionCategoryService'
import {
resolveDataUrlToMediaId,
resolveHtmlEmbeddedDataUrls,
} from '../services/mediaService'
import {
createInstruction,
getInstruction,
mapInstructionApiToFormState,
updateInstruction,
} from '../services/instructionService'
import type { Category } from '../types/category'
import type { VideoProvider } from '../types/instruction'
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
export function AddNewInstructionPage() {
const { id } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [abstract, setAbstract] = useState('')
const [descriptionHtml, setDescriptionHtml] = useState('')
const [videoProvider, setInstructionProvider] = useState<VideoProvider>('youtube')
const [embedCode, setEmbedCode] = useState('')
const [coverImage, setCoverImage] = useState<string | null>(null)
const [tags, setTags] = useState<string[]>([])
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
async function loadCategories() {
try {
const items = await listInstructionCategories(controller.signal)
setCategories(items.map(mapInstructionCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('instruction.categories.errorLoad'))
}
}
}
void loadCategories()
return () => controller.abort()
}, [])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadVideo() {
setError('')
try {
const video = await getInstruction(id!, controller.signal)
const form = mapInstructionApiToFormState(video)
setCategoryId(form.categoryId)
setTitleFa(form.titleFa)
setTitleEn(form.titleEn)
setAbstract(form.abstract)
setDescriptionHtml(form.descriptionHtml)
setInstructionProvider(form.videoProvider)
setEmbedCode(form.embedCode)
setCoverImage(form.coverImage)
setTags(form.tags)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
navigate('/instructions/list', { replace: true })
}
}
void loadVideo()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) return null
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
try {
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
coverImage,
'instruction-cover-image.jpg',
featuredMediaId,
)
const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml)
const trimmedEmbed = embedCode.trim()
const payload = {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
abstract: abstract.trim(),
descriptionHtml: resolvedDescriptionHtml,
videoProvider: trimmedEmbed ? videoProvider : undefined,
embedCode: trimmedEmbed || undefined,
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
status: 'published' as const,
}
if (isEdit && id) {
await updateInstruction(id, {
...payload,
categoryId: categoryId || null,
featuredMediaId: nextFeaturedMediaId,
})
showToast(t('instruction.form.toast.updated'), 'success')
} else {
await createInstruction(payload)
showToast(t('instruction.form.toast.created'), 'success')
}
navigate('/instructions/list')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError(t('instruction.form.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Instructions', href: '/instructions' },
{ label: 'My Instructions', href: '/instructions/list' },
{ label: 'Edit Instruction' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Instructions', href: '/instructions' },
{ label: 'Add New Instruction' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('title.editInstruction') : t('title.addInstruction')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit ? t('instruction.form.edit.subtitle') : t('instruction.form.add.subtitle')}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4}`}>
<label>{t('instruction.form.coverImage')}</label>
<ImageCropper
value={coverImage}
onChange={setCoverImage}
aspect={3 / 2}
uploadLabel={t('instruction.form.uploadCoverImage')}
hint={t('instruction.form.cropHint')}
changeLabel={t('instruction.form.changeCoverImage')}
/>
</div>
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('instruction.form.category')}</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder={t('instruction.form.categoryPlaceholder')}
/>
</div>
<div className={styles.titleRow}>
<div className={styles.field}>
<label htmlFor="instruction-title-fa">{t('instruction.form.titleFa')}</label>
<input
id="instruction-title-fa"
name="titleFa"
type="text"
dir="rtl"
lang="fa"
className="faText"
placeholder={t('instruction.form.titleFaPlaceholder')}
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="instruction-title-en">{t('instruction.form.titleEn')}</label>
<input
id="instruction-title-en"
name="titleEn"
type="text"
dir="ltr"
placeholder={t('instruction.form.titleEnPlaceholder')}
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="instruction-abstract">{t('instruction.form.abstract')}</label>
<textarea
id="instruction-abstract"
name="abstract"
rows={4}
placeholder={t('instruction.form.abstractPlaceholder')}
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.videoEmbedRow}>
<div className={styles.providerPanel}>
<span className={styles.providerLabel}>{t('instruction.form.provider')}</span>
<div
className={styles.providerRadioGroup}
role="radiogroup"
aria-label={t('instruction.form.provider')}
>
{VIDEO_PROVIDER_OPTIONS.map((option) => (
<label key={option.value} className={styles.providerRadioOption}>
<input
type="radio"
name="videoProvider"
value={option.value}
checked={videoProvider === option.value}
onChange={() => setInstructionProvider(option.value)}
disabled={isSubmitting}
/>
<span>{option.label}</span>
</label>
))}
</div>
</div>
<div className={styles.embedPanel}>
<label htmlFor="instruction-embed">{t('instruction.form.embedCode')}</label>
<textarea
id="instruction-embed"
name="embedCode"
rows={5}
dir="ltr"
placeholder={t('instruction.form.embedCodePlaceholder')}
value={embedCode}
onChange={(e) => setEmbedCode(e.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('instruction.form.description')}</label>
<RichTextEditor
value={descriptionHtml}
onChange={setDescriptionHtml}
placeholder={t('instruction.form.descriptionPlaceholder')}
allowImages
editorMinHeight={320}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('instruction.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
</div>
<div className={styles.actions}>
<Link to="/instructions/list" className={styles.cancelBtn}>
{t('instruction.form.cancel')}
</Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('instruction.form.saving')
: isEdit
? t('instruction.form.update')
: t('instruction.form.save')}
</button>
</div>
</form>
</main>
)
}
@@ -258,6 +258,100 @@
color: var(--primary);
}
.videoEmbedRow {
grid-column: span 12;
display: grid;
grid-template-columns: minmax(148px, 180px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.providerPanel {
display: flex;
flex-direction: column;
gap: 4px;
}
.providerLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.providerRadioGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.providerRadioOption {
display: flex;
align-items: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.providerRadioOption:has(input:checked) {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.providerRadioOption:has(input:disabled) {
opacity: 0.6;
cursor: not-allowed;
}
.providerRadioOption input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
flex-shrink: 0;
}
.embedPanel {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.embedPanel label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.embedPanel textarea {
width: 100%;
min-height: calc(var(--field-height) * 2 + 24px);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
resize: vertical;
box-sizing: border-box;
}
.embedPanel textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
@media (max-width: 768px) {
.form {
padding: 20px 16px;
@@ -287,6 +381,10 @@
grid-template-columns: 1fr;
}
.videoEmbedRow {
grid-template-columns: 1fr;
}
.fieldFull,
.fieldHalf,
.fieldCategory,
+342
View File
@@ -0,0 +1,342 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
listVideoCategories,
mapVideoCategoryToUi,
} from '../services/videoCategoryService'
import {
resolveDataUrlToMediaId,
resolveHtmlEmbeddedDataUrls,
} from '../services/mediaService'
import {
createVideo,
getVideo,
mapVideoApiToFormState,
updateVideo,
} from '../services/videoService'
import type { Category } from '../types/category'
import type { VideoProvider } from '../types/video'
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
export function AddNewVideoPage() {
const { id } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [abstract, setAbstract] = useState('')
const [descriptionHtml, setDescriptionHtml] = useState('')
const [videoProvider, setVideoProvider] = useState<VideoProvider>('youtube')
const [embedCode, setEmbedCode] = useState('')
const [coverImage, setCoverImage] = useState<string | null>(null)
const [tags, setTags] = useState<string[]>([])
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
async function loadCategories() {
try {
const items = await listVideoCategories(controller.signal)
setCategories(items.map(mapVideoCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('video.categories.errorLoad'))
}
}
}
void loadCategories()
return () => controller.abort()
}, [])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadVideo() {
setError('')
try {
const video = await getVideo(id!, controller.signal)
const form = mapVideoApiToFormState(video)
setCategoryId(form.categoryId)
setTitleFa(form.titleFa)
setTitleEn(form.titleEn)
setAbstract(form.abstract)
setDescriptionHtml(form.descriptionHtml)
setVideoProvider(form.videoProvider)
setEmbedCode(form.embedCode)
setCoverImage(form.coverImage)
setTags(form.tags)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
navigate('/videos/list', { replace: true })
}
}
void loadVideo()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) return null
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
try {
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
coverImage,
'video-cover-image.jpg',
featuredMediaId,
)
const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml)
const payload = {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
abstract: abstract.trim(),
descriptionHtml: resolvedDescriptionHtml,
videoProvider,
embedCode: embedCode.trim(),
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
status: 'published' as const,
}
if (isEdit && id) {
await updateVideo(id, {
...payload,
categoryId: categoryId || null,
featuredMediaId: nextFeaturedMediaId,
})
showToast(t('video.form.toast.updated'), 'success')
} else {
await createVideo(payload)
showToast(t('video.form.toast.created'), 'success')
}
navigate('/videos/list')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError(t('video.form.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Videos', href: '/videos' },
{ label: 'My Videos', href: '/videos/list' },
{ label: 'Edit Video' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Videos', href: '/videos' },
{ label: 'Add New Video' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('title.editVideo') : t('title.addVideo')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit ? t('video.form.edit.subtitle') : t('video.form.add.subtitle')}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4}`}>
<label>{t('video.form.coverImage')}</label>
<ImageCropper
value={coverImage}
onChange={setCoverImage}
aspect={3 / 2}
uploadLabel={t('video.form.uploadCoverImage')}
hint={t('video.form.cropHint')}
changeLabel={t('video.form.changeCoverImage')}
/>
</div>
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('video.form.category')}</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder={t('video.form.categoryPlaceholder')}
/>
</div>
<div className={styles.titleRow}>
<div className={styles.field}>
<label htmlFor="video-title-fa">{t('video.form.titleFa')}</label>
<input
id="video-title-fa"
name="titleFa"
type="text"
dir="rtl"
lang="fa"
className="faText"
placeholder={t('video.form.titleFaPlaceholder')}
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="video-title-en">{t('video.form.titleEn')}</label>
<input
id="video-title-en"
name="titleEn"
type="text"
dir="ltr"
placeholder={t('video.form.titleEnPlaceholder')}
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="video-abstract">{t('video.form.abstract')}</label>
<textarea
id="video-abstract"
name="abstract"
rows={4}
placeholder={t('video.form.abstractPlaceholder')}
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.videoEmbedRow}>
<div className={styles.providerPanel}>
<span className={styles.providerLabel}>{t('video.form.provider')}</span>
<div
className={styles.providerRadioGroup}
role="radiogroup"
aria-label={t('video.form.provider')}
>
{VIDEO_PROVIDER_OPTIONS.map((option) => (
<label key={option.value} className={styles.providerRadioOption}>
<input
type="radio"
name="videoProvider"
value={option.value}
checked={videoProvider === option.value}
onChange={() => setVideoProvider(option.value)}
disabled={isSubmitting}
/>
<span>{option.label}</span>
</label>
))}
</div>
</div>
<div className={styles.embedPanel}>
<label htmlFor="video-embed">{t('video.form.embedCode')}</label>
<textarea
id="video-embed"
name="embedCode"
rows={5}
dir="ltr"
placeholder={t('video.form.embedCodePlaceholder')}
value={embedCode}
onChange={(e) => setEmbedCode(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('video.form.description')}</label>
<RichTextEditor
value={descriptionHtml}
onChange={setDescriptionHtml}
placeholder={t('video.form.descriptionPlaceholder')}
allowImages
editorMinHeight={320}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('video.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
</div>
<div className={styles.actions}>
<Link to="/videos/list" className={styles.cancelBtn}>
{t('video.form.cancel')}
</Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('video.form.saving')
: isEdit
? t('video.form.update')
: t('video.form.save')}
</button>
</div>
</form>
</main>
)
}
+1
View File
@@ -51,6 +51,7 @@ export function BlogPage() {
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Content', href: '/content' },
{ label: 'Blog' },
]}
/>
+74
View File
@@ -0,0 +1,74 @@
import { FileText, Play, GraduationCap } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { useT } from '../i18n/useT'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { hasBusinessModule } from '../utils/businessModules'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
const contentSections: {
icon: typeof FileText
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
href: string
moduleId: 'blog' | 'videos' | 'instructions'
}[] = [
{
icon: FileText,
titleKey: 'nav.content.blog',
descKey: 'content.card.blog.desc',
linkKey: 'content.card.blog.link',
href: '/blog',
moduleId: 'blog',
},
{
icon: GraduationCap,
titleKey: 'nav.content.instructions',
descKey: 'content.card.instructions.desc',
linkKey: 'content.card.instructions.link',
href: '/instructions',
moduleId: 'instructions',
},
]
export function ContentPage() {
const t = useT()
const { enabledModules } = useTenantBranding()
const visibleSections = contentSections.filter((section) =>
hasBusinessModule(enabledModules, section.moduleId),
)
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Content' },
]}
/>
<div className={styles.pageHeader}>
<div>
<PageTitle en="CONTENT">{t('title.content')}</PageTitle>
<p className={styles.pageSubtitle}>{t('content.page.subtitle')}</p>
</div>
</div>
<div className={styles.gridHome}>
{visibleSections.map((section) => (
<SectionCard
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
linkText={t(section.linkKey)}
href={section.href}
/>
))}
</div>
</main>
)
}
+26 -16
View File
@@ -4,8 +4,8 @@ import {
ShoppingBag,
Store,
Users,
FileText,
Briefcase,
Layers,
Wallet,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
@@ -19,15 +19,18 @@ import { listStoreItems } from '../services/storeItemService'
import { listCustomers } from '../services/customerService'
import { listBlogs } from '../services/blogService'
import { listPortfolios } from '../services/portfolioService'
import { listVideos } from '../services/videoService'
import { listInstructions } from '../services/instructionService'
import { useTenantBranding } from '../context/TenantBrandingContext'
import {
hasBusinessModule,
hasAnyBusinessModule,
isActiveHomeChart,
type BusinessModuleId,
} from '../utils/businessModules'
import styles from '../components/PageContent.module.css'
type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios'
type CountKey = 'products' | 'store' | 'customers' | 'content' | 'portfolios'
const sections: {
icon: typeof ShoppingBag
@@ -39,6 +42,8 @@ const sections: {
countKey?: CountKey
/** When set, card is shown only if this optional module is enabled. */
moduleId?: BusinessModuleId
/** When set, card is shown if any listed module is enabled. */
anyModuleIds?: BusinessModuleId[]
}[] = [
{
icon: ShoppingBag,
@@ -78,14 +83,14 @@ const sections: {
moduleId: 'finance',
},
{
icon: FileText,
titleKey: 'home.card.blog.title',
descKey: 'home.card.blog.desc',
linkKey: 'home.card.blog.link',
countLabelKey: 'home.card.blog.count',
href: '/blog',
countKey: 'blog',
moduleId: 'blog',
icon: Layers,
titleKey: 'home.card.content.title',
descKey: 'home.card.content.desc',
linkKey: 'home.card.content.link',
countLabelKey: 'home.card.content.count',
href: '/content',
countKey: 'content',
anyModuleIds: ['blog', 'videos', 'instructions'],
},
{
icon: Briefcase,
@@ -102,20 +107,23 @@ const sections: {
type SectionCounts = Partial<Record<CountKey, number>>
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
const [products, store, customers, blog, portfolios] = await Promise.all([
const [products, store, customers, blog, portfolios, videos, instructions] = await Promise.all([
listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
listStoreItems(1, 1, signal).then((r) => r.total).catch(() => null),
listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
listBlogs(1, 1, signal).then((r) => r.total).catch(() => null),
listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
listVideos(1, 1, signal).then((r) => r.total).catch(() => null),
listInstructions(1, 1, signal).then((r) => r.total).catch(() => null),
])
const counts: SectionCounts = {}
if (products !== null) counts.products = products
if (store !== null) counts.store = store
if (customers !== null) counts.customers = customers
if (blog !== null) counts.blog = blog
if (portfolios !== null) counts.portfolios = portfolios
const contentTotal = (blog ?? 0) + (videos ?? 0) + (instructions ?? 0)
if (blog !== null || videos !== null || instructions !== null) counts.content = contentTotal
return counts
}
@@ -134,10 +142,12 @@ export function HomePage() {
return () => controller.abort()
}, [])
const visibleSections = sections.filter(
(section) =>
!section.moduleId || hasBusinessModule(enabledModules, section.moduleId),
)
const visibleSections = sections.filter((section) => {
if (section.anyModuleIds) {
return hasAnyBusinessModule(enabledModules, section.anyModuleIds)
}
return !section.moduleId || hasBusinessModule(enabledModules, section.moduleId)
})
const visibleCharts = homeCharts.filter(isActiveHomeChart)
const firstName =
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { CategoryModal } from '../components/CategoryModal'
import { InstructionCategoryTree } from '../components/InstructionCategoryTree'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import {
createInstructionCategory,
deleteInstructionCategory,
listInstructionCategories,
mapInstructionCategoryToUi,
toInstructionCategoryFormPayload,
updateInstructionCategory,
} from '../services/instructionCategoryService'
import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css'
export function InstructionCategoriesPage() {
const t = useT()
const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('')
const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listInstructionCategories(signal)
setCategories(items.map(mapInstructionCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('instruction.categories.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function openCreateModal(parentId = '', title?: string) {
setEditingCategory(null)
setDefaultParentId(parentId)
setModalTitle(title ?? t('categories.add'))
setModalOpen(true)
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId))
}
}
function openEditModal(category: Category) {
setEditingCategory(category)
setDefaultParentId(category.parentId ?? '')
setModalTitle(t('categories.edit'))
setModalOpen(true)
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleSubmit(data: CategoryFormData) {
setIsSubmitting(true)
setError('')
try {
const payload = toInstructionCategoryFormPayload(data)
if (editingCategory) {
const result = await updateInstructionCategory(editingCategory.id, {
...payload,
parentId: data.parentId || null,
})
setCategories((prev) =>
prev.map((category) =>
category.id === editingCategory.id
? mapInstructionCategoryToUi(result.category)
: category,
),
)
} else {
const result = await createInstructionCategory(payload)
setCategories((prev) => [...prev, mapInstructionCategoryToUi(result.category)])
if (data.parentId) {
setExpandedIds((prev) => new Set(prev).add(data.parentId))
}
}
setModalOpen(false)
setEditingCategory(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('instruction.categories.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
const result = await deleteInstructionCategory(deleteTarget.id)
const removed = new Set(result.deletedIds)
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
setExpandedIds((prev) => {
const next = new Set(prev)
removed.forEach((id) => next.delete(id))
return next
})
setDeleteTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('instruction.categories.errorDelete'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Instructions', href: '/instructions' },
{ label: 'Categories' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="INSTRUCTION CATEGORIES">{t('instruction.categories.title')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('instruction.categories.subtitle')}</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.list}>
{isLoading ? (
<p className={styles.empty}>{t('categories.loading')}</p>
) : categories.length === 0 ? (
<p className={styles.empty}>{t('instruction.categories.empty')}</p>
) : (
<InstructionCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
}}
/>
)}
</div>
<CategoryModal
open={modalOpen}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingCategory(null)
}
}}
title={modalTitle}
defaultParentId={defaultParentId}
categories={categories}
editingCategory={editingCategory}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('categories.deleteTitle')}
message={
deleteTarget
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
<div className={styles.fabDock}>
<button
type="button"
className={styles.addFab}
onClick={() => openCreateModal()}
aria-label={t('categories.add')}
>
<Plus size={24} />
</button>
</div>
</main>
)
}
@@ -0,0 +1,191 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { CalendarDays, User } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { InstructionCommentsSection } from '../components/InstructionCommentsSection'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
formatInstructionAuthor,
formatInstructionDate,
getInstructionDetail,
resolveInstructionTitle,
} from '../services/instructionService'
import type { InstructionDetail } from '../types/instruction'
import { textLocaleAttrs } from '../utils/textLocale'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogDetailsPage.module.css'
export function InstructionDetailsPage() {
const t = useT()
const { locale } = useLocale()
const { id } = useParams()
const [video, setInstruction] = useState<InstructionDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function loadVideo() {
setIsLoading(true)
setError('')
try {
const data = await getInstructionDetail(id!, controller.signal)
setInstruction(data)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('instruction.details.errorLoad'))
}
setInstruction(null)
} finally {
setIsLoading(false)
}
}
void loadVideo()
return () => controller.abort()
}, [id])
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('instruction.details.loading')}</p>
</main>
)
}
if (error || !video || !id) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || t('instruction.details.notFound')}</p>
<Link to="/instructions/list" className={styles.backLink}>
{t('instruction.details.back')}
</Link>
</main>
)
}
const displayTitle = resolveInstructionTitle(video, locale === 'fa' ? 'fa' : 'en')
const displayDate = formatInstructionDate(
video.publishedAt ?? video.createdAt,
locale === 'fa' ? 'fa' : 'en',
)
const titleLocale = textLocaleAttrs(displayTitle)
const abstractLocale = textLocaleAttrs(video.abstract)
const contentLocale = textLocaleAttrs(
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
)
const providerLabel =
video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Instructions', href: '/instructions' },
{ label: 'My Instructions', href: '/instructions/list' },
{ label: displayTitle },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2
className={[pageStyles.pageTitle, titleLocale.className]
.filter(Boolean)
.join(' ')}
lang={titleLocale.lang}
dir={titleLocale.dir}
>
{displayTitle}
</h2>
{video.abstract ? (
<p
className={[pageStyles.pageSubtitleContent, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractLocale.dir}
>
{video.abstract}
</p>
) : null}
</div>
</div>
<article className={styles.blogDetail}>
<div className={styles.heroImageWrap}>
{video.coverImageUrl ? (
<img
src={video.coverImageUrl}
alt={displayTitle}
className={styles.heroImage}
/>
) : (
<div className={styles.heroPlaceholder} aria-hidden="true" />
)}
</div>
<div className={styles.metaRow}>
<span className={styles.metaItem}>
<CalendarDays size={15} />
{displayDate}
</span>
{(providerLabel || video.categoryName) && (
<>
<span className={styles.metaDot}>·</span>
<div className={styles.metaChips}>
{providerLabel && (
<span className={styles.typeChip}>{providerLabel}</span>
)}
{video.categoryName && (
<span className={styles.categoryChip}>{video.categoryName}</span>
)}
</div>
</>
)}
</div>
{video.embedCode ? (
<div
className={styles.contentPanel}
dir="ltr"
dangerouslySetInnerHTML={{ __html: video.embedCode }}
/>
) : null}
<div className={styles.contentPanel}>
{video.descriptionHtml ? (
<div
className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
lang={contentLocale.lang}
dir={contentLocale.dir}
dangerouslySetInnerHTML={{ __html: video.descriptionHtml }}
/>
) : (
<p className={styles.content} lang="en" dir="ltr">
{t('instruction.details.noDescription')}
</p>
)}
<div className={styles.authorRow}>
<User size={16} />
<span className={styles.authorLabel}>{t('instruction.details.author')}</span>
<span className={styles.authorName}>{formatInstructionAuthor(video.author)}</span>
</div>
</div>
<InstructionCommentsSection instructionId={id} />
</article>
</main>
)
}
@@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Play, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { InstructionCard } from '../components/InstructionCard'
import { InstructionCommentsModal } from '../components/InstructionCommentsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import {
INSTRUCTIONS_PER_PAGE,
deleteInstruction,
listInstructions,
resolveInstructionTitle,
} from '../services/instructionService'
import type { Video } from '../types/instruction'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogPage.module.css'
export function InstructionListPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const { locale } = useLocale()
const [videos, setInstructions] = useState<Video[]>([])
const [totalInstructions, setTotalVideos] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>(null)
const totalPages = Math.max(1, Math.ceil(totalInstructions / INSTRUCTIONS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadVideos(currentPage, controller.signal)
return () => controller.abort()
}, [currentPage])
async function loadVideos(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listInstructions(page, INSTRUCTIONS_PER_PAGE, signal)
setInstructions(data.items)
setTotalVideos(data.total)
setCommentCounts(
Object.fromEntries(data.items.map((video) => [video.id, video.commentCount ?? 0])),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('instruction.list.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function handleEdit(id: string) {
navigate(`/instructions/edit/${id}`)
}
function handleComments(id: string) {
const video = videos.find((item) => item.id === id)
if (video) {
setCommentsTarget({
id,
title: resolveInstructionTitle(video, locale === 'fa' ? 'fa' : 'en'),
})
}
}
function handleRemoveRequest(id: string) {
const video = videos.find((item) => item.id === id)
if (video) {
setDeleteTarget({
id,
title: resolveInstructionTitle(video, locale === 'fa' ? 'fa' : 'en'),
})
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deleteInstruction(deleteTarget.id)
showToast(t('instruction.list.toast.removed'), 'success')
const nextTotal = totalInstructions - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / INSTRUCTIONS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setDeleteTarget(null)
setCurrentPage(nextPage)
await loadVideos(nextPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('instruction.list.errorDelete'))
}
} finally {
setIsDeleting(false)
}
}
function handlePageChange(page: number) {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function handleCommentCountChange(count: number) {
if (!commentsTarget) return
setCommentCounts((prev) => ({
...prev,
[commentsTarget.id]: count,
}))
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Instructions', href: '/instructions' },
{ label: 'My Instructions' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="MY INSTRUCTIONS">{t('title.myInstructions')}</PageTitle>
<p className={pageStyles.pageSubtitle}>
{t('instruction.list.subtitle', { count: totalInstructions })}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{isLoading ? (
<p className={styles.empty}>{t('instruction.list.loading')}</p>
) : videos.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon} aria-hidden="true">
<Play size={28} strokeWidth={1.75} />
</div>
<h3 className={styles.emptyTitle}>{t('instruction.list.empty')}</h3>
<p className={styles.emptyHint}>{t('instruction.list.emptyHint')}</p>
<button
type="button"
className={styles.emptyCta}
onClick={() => navigate('/instructions/new')}
>
<Plus size={18} strokeWidth={2.25} />
{t('instruction.list.emptyCta')}
</button>
</div>
) : (
<>
<div className={pageStyles.grid}>
{videos.map((video) => (
<InstructionCard
key={video.id}
video={video}
commentCount={commentCounts[video.id] ?? video.commentCount}
onEdit={handleEdit}
onComments={handleComments}
onRemove={handleRemoveRequest}
/>
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
)}
<button
type="button"
className={styles.addFab}
onClick={() => navigate('/instructions/new')}
aria-label={t('instruction.list.addNew')}
>
<Plus size={26} strokeWidth={2.5} />
</button>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('instruction.list.deleteTitle')}
message={
deleteTarget
? t('instruction.list.deleteMessage', { name: deleteTarget.title })
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
{commentsTarget && (
<InstructionCommentsModal
open={!!commentsTarget}
instructionId={commentsTarget.id}
instructionTitle={commentsTarget.title}
onClose={() => setCommentsTarget(null)}
onCountChange={handleCommentCountChange}
/>
)}
</main>
)
}
@@ -0,0 +1,79 @@
import { GraduationCap, PlusCircle, FolderTree, Settings } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
const instructionSections: {
icon: typeof GraduationCap
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
href: string
}[] = [
{
icon: GraduationCap,
titleKey: 'nav.instructions.list',
descKey: 'instruction.card.list.desc',
linkKey: 'instruction.card.list.link',
href: '/instructions/list',
},
{
icon: PlusCircle,
titleKey: 'nav.instructions.new',
descKey: 'instruction.card.new.desc',
linkKey: 'instruction.card.new.link',
href: '/instructions/new',
},
{
icon: FolderTree,
titleKey: 'instruction.card.categories.title',
descKey: 'instruction.card.categories.desc',
linkKey: 'instruction.card.categories.link',
href: '/instructions/categories',
},
{
icon: Settings,
titleKey: 'nav.instructions.settings',
descKey: 'instruction.card.settings.desc',
linkKey: 'instruction.card.settings.link',
href: '/instructions/settings',
},
]
export function InstructionPage() {
const t = useT()
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Content', href: '/content' },
{ label: 'Instructions' },
]}
/>
<div className={styles.pageHeader}>
<div>
<PageTitle en="INSTRUCTIONS">{t('title.instructions')}</PageTitle>
<p className={styles.pageSubtitle}>{t('instruction.page.subtitle')}</p>
</div>
</div>
<div className={styles.gridHome}>
{instructionSections.map((section) => (
<SectionCard
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
linkText={t(section.linkKey)}
href={section.href}
/>
))}
</div>
</main>
)
}
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
getSettings,
updateDashboardSettings,
type DashboardSettings,
} from '../services/settingsService'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductSettingsPage.module.css'
const DEFAULT_SETTINGS: DashboardSettings = {
comments: { autoApprove: false },
expertReviews: { autoApprove: false },
}
export function InstructionSettingsPage() {
const t = useT()
const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setSettings(data.settings.dashboard)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('productSettings.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function handleCommentsAutoApprove(checked: boolean) {
setSavingKey('comments')
setError('')
try {
const data = await updateDashboardSettings({
comments: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Instructions', href: '/instructions' },
{ label: 'Settings' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="INSTRUCTION SETTINGS">{t('instruction.settings.title')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('instruction.settings.subtitle')}</p>
</div>
</div>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
<section className={styles.panel}>
<h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
{isLoading ? (
<p className={styles.status}>{t('productSettings.loading')}</p>
) : (
<div className={styles.list}>
<div className={styles.row}>
<div className={styles.rowText}>
<label htmlFor="video-comments-auto-approve" className={styles.rowLabel}>
{t('instruction.settings.commentsAuto')}
</label>
<p className={styles.rowDescription}>{t('instruction.settings.commentsAutoDesc')}</p>
</div>
<Switch
id="video-comments-auto-approve"
checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'}
aria-label={t('instruction.settings.commentsAutoAria')}
onChange={(checked) => void handleCommentsAutoApprove(checked)}
/>
</div>
</div>
)}
</section>
</main>
)
}
@@ -30,6 +30,23 @@ import styles from './StoreSettingsPage.module.css'
const DEFAULT_STORE_SETTINGS: StoreSettings = {
onlineSellEnabled: true,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: {
enabled: false,
defaultGateway: null,
gateways: {
mellat: {
enabled: false,
terminalId: '',
username: '',
password: '',
passwordSet: false,
},
sep: { enabled: false },
snappay: { enabled: false },
digipay: { enabled: false },
zarinpal: { enabled: false },
},
},
}
function normalizeSteps(steps: OrderProcessStep[]) {
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { CategoryModal } from '../components/CategoryModal'
import { VideoCategoryTree } from '../components/VideoCategoryTree'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import {
createVideoCategory,
deleteVideoCategory,
listVideoCategories,
mapVideoCategoryToUi,
toVideoCategoryFormPayload,
updateVideoCategory,
} from '../services/videoCategoryService'
import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css'
export function VideoCategoriesPage() {
const t = useT()
const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('')
const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listVideoCategories(signal)
setCategories(items.map(mapVideoCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('video.categories.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function openCreateModal(parentId = '', title?: string) {
setEditingCategory(null)
setDefaultParentId(parentId)
setModalTitle(title ?? t('categories.add'))
setModalOpen(true)
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId))
}
}
function openEditModal(category: Category) {
setEditingCategory(category)
setDefaultParentId(category.parentId ?? '')
setModalTitle(t('categories.edit'))
setModalOpen(true)
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleSubmit(data: CategoryFormData) {
setIsSubmitting(true)
setError('')
try {
const payload = toVideoCategoryFormPayload(data)
if (editingCategory) {
const result = await updateVideoCategory(editingCategory.id, {
...payload,
parentId: data.parentId || null,
})
setCategories((prev) =>
prev.map((category) =>
category.id === editingCategory.id
? mapVideoCategoryToUi(result.category)
: category,
),
)
} else {
const result = await createVideoCategory(payload)
setCategories((prev) => [...prev, mapVideoCategoryToUi(result.category)])
if (data.parentId) {
setExpandedIds((prev) => new Set(prev).add(data.parentId))
}
}
setModalOpen(false)
setEditingCategory(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('video.categories.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
const result = await deleteVideoCategory(deleteTarget.id)
const removed = new Set(result.deletedIds)
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
setExpandedIds((prev) => {
const next = new Set(prev)
removed.forEach((id) => next.delete(id))
return next
})
setDeleteTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('video.categories.errorDelete'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Videos', href: '/videos' },
{ label: 'Categories' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="VIDEO CATEGORIES">{t('video.categories.title')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('video.categories.subtitle')}</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.list}>
{isLoading ? (
<p className={styles.empty}>{t('categories.loading')}</p>
) : categories.length === 0 ? (
<p className={styles.empty}>{t('video.categories.empty')}</p>
) : (
<VideoCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
}}
/>
)}
</div>
<CategoryModal
open={modalOpen}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingCategory(null)
}
}}
title={modalTitle}
defaultParentId={defaultParentId}
categories={categories}
editingCategory={editingCategory}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('categories.deleteTitle')}
message={
deleteTarget
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
<div className={styles.fabDock}>
<button
type="button"
className={styles.addFab}
onClick={() => openCreateModal()}
aria-label={t('categories.add')}
>
<Plus size={24} />
</button>
</div>
</main>
)
}
@@ -0,0 +1,191 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { CalendarDays, User } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { VideoCommentsSection } from '../components/VideoCommentsSection'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
formatVideoAuthor,
formatVideoDate,
getVideoDetail,
resolveVideoTitle,
} from '../services/videoService'
import type { VideoDetail } from '../types/video'
import { textLocaleAttrs } from '../utils/textLocale'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogDetailsPage.module.css'
export function VideoDetailsPage() {
const t = useT()
const { locale } = useLocale()
const { id } = useParams()
const [video, setVideo] = useState<VideoDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function loadVideo() {
setIsLoading(true)
setError('')
try {
const data = await getVideoDetail(id!, controller.signal)
setVideo(data)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('video.details.errorLoad'))
}
setVideo(null)
} finally {
setIsLoading(false)
}
}
void loadVideo()
return () => controller.abort()
}, [id])
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('video.details.loading')}</p>
</main>
)
}
if (error || !video || !id) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || t('video.details.notFound')}</p>
<Link to="/videos/list" className={styles.backLink}>
{t('video.details.back')}
</Link>
</main>
)
}
const displayTitle = resolveVideoTitle(video, locale === 'fa' ? 'fa' : 'en')
const displayDate = formatVideoDate(
video.publishedAt ?? video.createdAt,
locale === 'fa' ? 'fa' : 'en',
)
const titleLocale = textLocaleAttrs(displayTitle)
const abstractLocale = textLocaleAttrs(video.abstract)
const contentLocale = textLocaleAttrs(
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
)
const providerLabel =
video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Videos', href: '/videos' },
{ label: 'My Videos', href: '/videos/list' },
{ label: displayTitle },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2
className={[pageStyles.pageTitle, titleLocale.className]
.filter(Boolean)
.join(' ')}
lang={titleLocale.lang}
dir={titleLocale.dir}
>
{displayTitle}
</h2>
{video.abstract ? (
<p
className={[pageStyles.pageSubtitleContent, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractLocale.dir}
>
{video.abstract}
</p>
) : null}
</div>
</div>
<article className={styles.blogDetail}>
<div className={styles.heroImageWrap}>
{video.coverImageUrl ? (
<img
src={video.coverImageUrl}
alt={displayTitle}
className={styles.heroImage}
/>
) : (
<div className={styles.heroPlaceholder} aria-hidden="true" />
)}
</div>
<div className={styles.metaRow}>
<span className={styles.metaItem}>
<CalendarDays size={15} />
{displayDate}
</span>
{(providerLabel || video.categoryName) && (
<>
<span className={styles.metaDot}>·</span>
<div className={styles.metaChips}>
{providerLabel && (
<span className={styles.typeChip}>{providerLabel}</span>
)}
{video.categoryName && (
<span className={styles.categoryChip}>{video.categoryName}</span>
)}
</div>
</>
)}
</div>
{video.embedCode ? (
<div
className={styles.contentPanel}
dir="ltr"
dangerouslySetInnerHTML={{ __html: video.embedCode }}
/>
) : null}
<div className={styles.contentPanel}>
{video.descriptionHtml ? (
<div
className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
lang={contentLocale.lang}
dir={contentLocale.dir}
dangerouslySetInnerHTML={{ __html: video.descriptionHtml }}
/>
) : (
<p className={styles.content} lang="en" dir="ltr">
{t('video.details.noDescription')}
</p>
)}
<div className={styles.authorRow}>
<User size={16} />
<span className={styles.authorLabel}>{t('video.details.author')}</span>
<span className={styles.authorName}>{formatVideoAuthor(video.author)}</span>
</div>
</div>
<VideoCommentsSection videoId={id} />
</article>
</main>
)
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Play, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { VideoCard } from '../components/VideoCard'
import { VideoCommentsModal } from '../components/VideoCommentsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import {
VIDEOS_PER_PAGE,
deleteVideo,
listVideos,
resolveVideoTitle,
} from '../services/videoService'
import type { Video } from '../types/video'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogPage.module.css'
export function VideoListPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const { locale } = useLocale()
const [videos, setVideos] = useState<Video[]>([])
const [totalVideos, setTotalVideos] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>(null)
const totalPages = Math.max(1, Math.ceil(totalVideos / VIDEOS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadVideos(currentPage, controller.signal)
return () => controller.abort()
}, [currentPage])
async function loadVideos(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listVideos(page, VIDEOS_PER_PAGE, signal)
setVideos(data.items)
setTotalVideos(data.total)
setCommentCounts(
Object.fromEntries(data.items.map((video) => [video.id, video.commentCount ?? 0])),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('video.list.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function handleEdit(id: string) {
navigate(`/videos/edit/${id}`)
}
function handleComments(id: string) {
const video = videos.find((item) => item.id === id)
if (video) {
setCommentsTarget({
id,
title: resolveVideoTitle(video, locale === 'fa' ? 'fa' : 'en'),
})
}
}
function handleRemoveRequest(id: string) {
const video = videos.find((item) => item.id === id)
if (video) {
setDeleteTarget({
id,
title: resolveVideoTitle(video, locale === 'fa' ? 'fa' : 'en'),
})
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deleteVideo(deleteTarget.id)
showToast(t('video.list.toast.removed'), 'success')
const nextTotal = totalVideos - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / VIDEOS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setDeleteTarget(null)
setCurrentPage(nextPage)
await loadVideos(nextPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('video.list.errorDelete'))
}
} finally {
setIsDeleting(false)
}
}
function handlePageChange(page: number) {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function handleCommentCountChange(count: number) {
if (!commentsTarget) return
setCommentCounts((prev) => ({
...prev,
[commentsTarget.id]: count,
}))
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Videos', href: '/videos' },
{ label: 'My Videos' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="VIDEOS">{t('title.myVideos')}</PageTitle>
<p className={pageStyles.pageSubtitle}>
{t('video.list.subtitle', { count: totalVideos })}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{isLoading ? (
<p className={styles.empty}>{t('video.list.loading')}</p>
) : videos.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon} aria-hidden="true">
<Play size={28} strokeWidth={1.75} />
</div>
<h3 className={styles.emptyTitle}>{t('video.list.empty')}</h3>
<p className={styles.emptyHint}>{t('video.list.emptyHint')}</p>
<button
type="button"
className={styles.emptyCta}
onClick={() => navigate('/videos/new')}
>
<Plus size={18} strokeWidth={2.25} />
{t('video.list.emptyCta')}
</button>
</div>
) : (
<>
<div className={pageStyles.grid}>
{videos.map((video) => (
<VideoCard
key={video.id}
video={video}
commentCount={commentCounts[video.id] ?? video.commentCount}
onEdit={handleEdit}
onComments={handleComments}
onRemove={handleRemoveRequest}
/>
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
)}
<button
type="button"
className={styles.addFab}
onClick={() => navigate('/videos/new')}
aria-label={t('video.list.addNew')}
>
<Plus size={26} strokeWidth={2.5} />
</button>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('video.list.deleteTitle')}
message={
deleteTarget
? t('video.list.deleteMessage', { name: deleteTarget.title })
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
{commentsTarget && (
<VideoCommentsModal
open={!!commentsTarget}
videoId={commentsTarget.id}
videoTitle={commentsTarget.title}
onClose={() => setCommentsTarget(null)}
onCountChange={handleCommentCountChange}
/>
)}
</main>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { Play, PlusCircle, FolderTree, Settings } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
const videoSections: {
icon: typeof Play
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
href: string
}[] = [
{
icon: Play,
titleKey: 'nav.videos.list',
descKey: 'video.card.list.desc',
linkKey: 'video.card.list.link',
href: '/videos/list',
},
{
icon: PlusCircle,
titleKey: 'nav.videos.new',
descKey: 'video.card.new.desc',
linkKey: 'video.card.new.link',
href: '/videos/new',
},
{
icon: FolderTree,
titleKey: 'video.card.categories.title',
descKey: 'video.card.categories.desc',
linkKey: 'video.card.categories.link',
href: '/videos/categories',
},
{
icon: Settings,
titleKey: 'nav.videos.settings',
descKey: 'video.card.settings.desc',
linkKey: 'video.card.settings.link',
href: '/videos/settings',
},
]
export function VideoPage() {
const t = useT()
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Content', href: '/content' },
{ label: 'Videos' },
]}
/>
<div className={styles.pageHeader}>
<div>
<PageTitle en="VIDEOS">{t('title.videos')}</PageTitle>
<p className={styles.pageSubtitle}>{t('video.page.subtitle')}</p>
</div>
</div>
<div className={styles.gridHome}>
{videoSections.map((section) => (
<SectionCard
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
linkText={t(section.linkKey)}
href={section.href}
/>
))}
</div>
</main>
)
}
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
getSettings,
updateDashboardSettings,
type DashboardSettings,
} from '../services/settingsService'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductSettingsPage.module.css'
const DEFAULT_SETTINGS: DashboardSettings = {
comments: { autoApprove: false },
expertReviews: { autoApprove: false },
}
export function VideoSettingsPage() {
const t = useT()
const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setSettings(data.settings.dashboard)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('productSettings.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function handleCommentsAutoApprove(checked: boolean) {
setSavingKey('comments')
setError('')
try {
const data = await updateDashboardSettings({
comments: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Videos', href: '/videos' },
{ label: 'Settings' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="VIDEO SETTINGS">{t('video.settings.title')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('video.settings.subtitle')}</p>
</div>
</div>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
<section className={styles.panel}>
<h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
{isLoading ? (
<p className={styles.status}>{t('productSettings.loading')}</p>
) : (
<div className={styles.list}>
<div className={styles.row}>
<div className={styles.rowText}>
<label htmlFor="video-comments-auto-approve" className={styles.rowLabel}>
{t('video.settings.commentsAuto')}
</label>
<p className={styles.rowDescription}>{t('video.settings.commentsAutoDesc')}</p>
</div>
<Switch
id="video-comments-auto-approve"
checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'}
aria-label={t('video.settings.commentsAutoAria')}
onChange={(checked) => void handleCommentsAutoApprove(checked)}
/>
</div>
</div>
)}
</section>
</main>
)
}
@@ -0,0 +1,148 @@
.stack {
display: flex;
flex-direction: column;
gap: 24px;
}
.gatewayCard,
.comingSoonCard {
background: rgba(255, 255, 255, 0.45);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--glass-shadow);
transition: var(--card-hover-transition);
}
.gatewayCard[data-card-hover]:hover {
transform: translateY(var(--card-hover-lift));
box-shadow: var(--card-hover-shadow);
}
.gatewayHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.gatewayTitle {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.gatewayDesc {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.credentials {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.field {
min-width: 0;
}
.field:last-of-type {
grid-column: 1 / -1;
}
.hint {
font-weight: 400;
color: var(--text-muted);
font-size: 12px;
}
.passwordWrap {
position: relative;
}
.passwordWrap input {
padding-inline-end: 40px;
width: 100%;
}
.passwordToggle {
position: absolute;
inset-inline-end: 10px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
}
.passwordToggle:hover {
color: var(--text-primary);
background: rgba(148, 163, 184, 0.15);
}
.actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
}
.saveBtn {
min-height: var(--field-height);
padding: 0 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
font-size: var(--field-font-size);
font-weight: 600;
font-family: var(--font-ui);
cursor: pointer;
}
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.comingSoonGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted);
background: rgba(148, 163, 184, 0.18);
border-radius: 999px;
padding: 4px 10px;
}
@media (max-width: 768px) {
.credentials,
.comingSoonGrid {
grid-template-columns: 1fr;
}
.gatewayHeader {
flex-direction: column;
}
}
+343 -8
View File
@@ -1,9 +1,177 @@
import { useEffect, useState, type FormEvent } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import { Switch } from '../components/Switch'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css'
import { ApiError } from '../lib/api'
import {
DEFAULT_EPAYMENT_SETTINGS,
getSettings,
updateStoreSettings,
type EPaymentSettings,
type PaymentGatewayId,
} from '../services/settingsService'
import sharedStyles from './ProductSettingsPage.module.css'
import fieldStyles from './AddNewProductPage.module.css'
import styles from './WebsiteEPaymentPage.module.css'
const COMING_SOON_GATEWAYS: {
id: Exclude<PaymentGatewayId, 'mellat'>
nameKey: string
descKey: string
}[] = [
{
id: 'sep',
nameKey: 'website.ePayment.gateway.sep',
descKey: 'website.ePayment.gateway.sepDesc',
},
{
id: 'snappay',
nameKey: 'website.ePayment.gateway.snappay',
descKey: 'website.ePayment.gateway.snappayDesc',
},
{
id: 'digipay',
nameKey: 'website.ePayment.gateway.digipay',
descKey: 'website.ePayment.gateway.digipayDesc',
},
{
id: 'zarinpal',
nameKey: 'website.ePayment.gateway.zarinpal',
descKey: 'website.ePayment.gateway.zarinpalDesc',
},
]
export function WebsiteEPaymentPage() {
const t = useT()
const { showToast } = useToast()
const [ePayment, setEPayment] = useState<EPaymentSettings>(DEFAULT_EPAYMENT_SETTINGS)
const [passwordDraft, setPasswordDraft] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
setPasswordDraft('')
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('productSettings.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function patchEPayment(
saving: string,
next: EPaymentSettings,
password?: string,
) {
setSavingKey(saving)
setError('')
try {
const payload: EPaymentSettings = {
...next,
gateways: {
...next.gateways,
mellat: {
...next.gateways.mellat,
password: password ?? '',
},
},
}
const data = await updateStoreSettings({ ePayment: payload })
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
if (password) setPasswordDraft('')
showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
}
}
async function handleMasterToggle(checked: boolean) {
await patchEPayment('master', {
...ePayment,
enabled: checked,
defaultGateway: checked
? ePayment.defaultGateway ?? 'mellat'
: ePayment.defaultGateway,
})
}
async function handleMellatToggle(checked: boolean) {
await patchEPayment('mellat-enabled', {
...ePayment,
gateways: {
...ePayment.gateways,
mellat: { ...ePayment.gateways.mellat, enabled: checked },
},
defaultGateway:
checked && !ePayment.defaultGateway ? 'mellat' : ePayment.defaultGateway,
})
}
async function handleSaveMellatCredentials(event: FormEvent) {
event.preventDefault()
const terminalId = ePayment.gateways.mellat.terminalId.trim()
const username = ePayment.gateways.mellat.username.trim()
if (ePayment.gateways.mellat.enabled && (!terminalId || !username)) {
setError(t('website.ePayment.error.credentials'))
return
}
if (
ePayment.gateways.mellat.enabled &&
!ePayment.gateways.mellat.passwordSet &&
!passwordDraft.trim()
) {
setError(t('website.ePayment.error.passwordRequired'))
return
}
await patchEPayment(
'mellat-credentials',
{
...ePayment,
gateways: {
...ePayment.gateways,
mellat: {
...ePayment.gateways.mellat,
terminalId,
username,
},
},
},
passwordDraft.trim() || undefined,
)
}
return (
<WebsiteSectionShell
@@ -11,13 +179,180 @@ export function WebsiteEPaymentPage() {
title={t('title.ePayment')}
subtitle={t('website.ePayment.subtitle')}
>
<p className={styles.placeholderLead}>{t('website.ePayment.lead')}</p>
<p className={styles.placeholderNote}>{t('website.ePayment.note')}</p>
<ul className={styles.featureList}>
<li>{t('website.ePayment.feature.enable')}</li>
<li>{t('website.ePayment.feature.credentials')}</li>
<li>{t('website.ePayment.feature.methods')}</li>
</ul>
{error ? <div className={sharedStyles.alertError}>{error}</div> : null}
{isLoading ? (
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
) : (
<div className={styles.stack}>
<div className={sharedStyles.list}>
<div className={sharedStyles.row}>
<div className={sharedStyles.rowText}>
<label className={sharedStyles.rowLabel} htmlFor="epayment-master">
{t('website.ePayment.master')}
</label>
<p className={sharedStyles.rowDescription}>
{t('website.ePayment.masterDesc')}
</p>
</div>
<Switch
id="epayment-master"
checked={ePayment.enabled}
disabled={savingKey !== null}
onCheckedChange={(checked) => void handleMasterToggle(checked)}
/>
</div>
</div>
<section className={styles.gatewayCard} data-card-hover>
<div className={styles.gatewayHeader}>
<div>
<h3 className={styles.gatewayTitle}>
{t('website.ePayment.gateway.mellat')}
</h3>
<p className={styles.gatewayDesc}>
{t('website.ePayment.gateway.mellatDesc')}
</p>
</div>
<Switch
id="epayment-mellat"
checked={ePayment.gateways.mellat.enabled}
disabled={savingKey !== null || !ePayment.enabled}
onCheckedChange={(checked) => void handleMellatToggle(checked)}
/>
</div>
<form className={styles.credentials} onSubmit={handleSaveMellatCredentials}>
<div className={`${fieldStyles.field} ${styles.field}`}>
<label htmlFor="mellat-terminal">
{t('website.ePayment.terminalId')}
</label>
<input
id="mellat-terminal"
type="text"
inputMode="numeric"
autoComplete="off"
dir="ltr"
value={ePayment.gateways.mellat.terminalId}
disabled={savingKey !== null}
onChange={(e) =>
setEPayment((current) => ({
...current,
gateways: {
...current.gateways,
mellat: {
...current.gateways.mellat,
terminalId: e.target.value,
},
},
}))
}
/>
</div>
<div className={`${fieldStyles.field} ${styles.field}`}>
<label htmlFor="mellat-username">
{t('website.ePayment.username')}
</label>
<input
id="mellat-username"
type="text"
autoComplete="off"
dir="ltr"
value={ePayment.gateways.mellat.username}
disabled={savingKey !== null}
onChange={(e) =>
setEPayment((current) => ({
...current,
gateways: {
...current.gateways,
mellat: {
...current.gateways.mellat,
username: e.target.value,
},
},
}))
}
/>
</div>
<div className={`${fieldStyles.field} ${styles.field}`}>
<label htmlFor="mellat-password">
{t('website.ePayment.password')}
{ePayment.gateways.mellat.passwordSet ? (
<span className={styles.hint}>
{' '}
({t('website.ePayment.passwordSet')})
</span>
) : null}
</label>
<div className={styles.passwordWrap}>
<input
id="mellat-password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
dir="ltr"
placeholder={
ePayment.gateways.mellat.passwordSet
? t('website.ePayment.passwordPlaceholderSet')
: t('website.ePayment.passwordPlaceholder')
}
value={passwordDraft}
disabled={savingKey !== null}
onChange={(e) => setPasswordDraft(e.target.value)}
/>
<Tooltip
label={
showPassword
? t('website.ePayment.hidePassword')
: t('website.ePayment.showPassword')
}
>
<button
type="button"
className={styles.passwordToggle}
onClick={() => setShowPassword((v) => !v)}
aria-label={
showPassword
? t('website.ePayment.hidePassword')
: t('website.ePayment.showPassword')
}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</Tooltip>
</div>
</div>
<div className={styles.actions}>
<button
type="submit"
className={styles.saveBtn}
disabled={savingKey !== null}
>
{savingKey === 'mellat-credentials'
? t('storeSettings.saving')
: t('website.ePayment.saveCredentials')}
</button>
</div>
</form>
</section>
<div className={styles.comingSoonGrid}>
{COMING_SOON_GATEWAYS.map((gateway) => (
<section key={gateway.id} className={styles.comingSoonCard}>
<div className={styles.gatewayHeader}>
<div>
<h3 className={styles.gatewayTitle}>{t(gateway.nameKey)}</h3>
<p className={styles.gatewayDesc}>{t(gateway.descKey)}</p>
</div>
<span className={styles.badge}>{t('website.ePayment.comingSoon')}</span>
</div>
</section>
))}
</div>
</div>
)}
</WebsiteSectionShell>
)
}