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 {
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user