Files
dashboards/apps/business/src/pages/AddNewInstructionPage.tsx
T
Alireza HassaniandCursor cc94b929e0 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>
2026-08-13 09:51:22 +03:30

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>
)
}