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>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|