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