Files
dashboards/apps/business/src/pages/AddNewProductPage.tsx
T
Alireza HassaniandCursor 404c008ced Normalize AI product HTML for the rich text description editor.
Sanitize model output to an allowlisted fragment and convert plain/markdown fallbacks so description text stays inside the advanced editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:44:35 +03:30

417 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { listAllBrands, mapBrandToSelectOption } from '../services/brandService'
import {
resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds,
} from '../services/mediaService'
import {
createProduct,
getProduct,
mapProductApiToFormState,
updateProduct,
} from '../services/productService'
import {
fillProductFieldByAi,
type ProductAiFillField,
} from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT'
export function AddNewProductPage() {
const { id } = useParams()
const navigate = useNavigate()
const t = useT()
const { showToast } = useToast()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
const [categoryId, setCategoryId] = useState('')
const [brandId, setBrandId] = useState('')
const [nameFa, setNameFa] = useState('')
const [nameEn, setNameEn] = useState('')
const [summary, setSummary] = useState('')
const [description, setDescription] = useState('')
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [aiField, setAiField] = useState<ProductAiFillField | null>(null)
const [isAiRunning, setIsAiRunning] = useState(false)
const categoryOptions = flattenCategories(categories)
const selectedCategoryName = useMemo(() => {
const match = categoryOptions.find((item) => item.id === categoryId)
if (!match) return ''
return match.nameFa?.trim() || match.nameEn || ''
}, [categoryOptions, categoryId])
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
function openAiFill(field: ProductAiFillField) {
if (!nameFa.trim() && !nameEn.trim()) {
setError(t('products.form.aiFillNeedName'))
return
}
setError('')
setAiField(field)
}
async function runAiFill(prompt: string) {
if (!aiField) return
setIsAiRunning(true)
try {
const result = await fillProductFieldByAi({
field: aiField,
prompt,
title: nameEn.trim() || undefined,
nameFa: nameFa.trim() || undefined,
categoryId: categoryId || undefined,
})
if (result.field === 'summary') {
setSummary(result.text)
showToast(t('products.form.aiSummaryFilled'), 'success')
} else {
setDescription(result.html)
showToast(t('products.form.aiDescriptionFilled'), 'success')
}
setAiField(null)
} finally {
setIsAiRunning(false)
}
}
useEffect(() => {
const controller = new AbortController()
async function loadFormOptions() {
try {
const [categoryItems, brandItems] = await Promise.all([
listProductCategories(controller.signal),
listAllBrands(controller.signal),
])
setCategories(categoryItems.map(mapProductCategoryToUi))
setBrandOptions(brandItems.map(mapBrandToSelectOption))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('products.form.errorOptions'))
}
}
}
void loadFormOptions()
return () => controller.abort()
}, [t])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadProduct() {
setError('')
try {
const product = await getProduct(id!, controller.signal)
const form = mapProductApiToFormState(product)
setCategoryId(form.categoryId)
setBrandId(form.brandId)
setNameFa(form.nameFa)
setNameEn(form.nameEn)
setSummary(form.summary)
setDescription(form.description)
setThumbnail(form.thumbnail)
setImages(form.images)
setTags(form.tags)
setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
navigate('/products/list', { replace: true })
}
}
void loadProduct()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) return null
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
try {
const featuredMediaId = await resolveDataUrlToMediaId(
thumbnail,
'product-thumbnail.jpg',
thumbnailMediaId,
)
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(
images,
galleryMediaIds,
)
const payload = {
title: nameEn.trim(),
nameFa: nameFa.trim(),
summary: summary.trim(),
descriptionHtml: description,
categoryId: categoryId || undefined,
brandId: brandId || undefined,
featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
status: 'published' as const,
}
if (isEdit && id) {
await updateProduct(id, {
...payload,
categoryId: categoryId || null,
brandId: brandId || null,
featuredMediaId,
})
} else {
await createProduct(payload)
}
navigate('/products/list')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError(t('products.form.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'My Products', href: '/products/list' },
{ label: 'Edit Product' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Add a New Product' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('title.editProduct') : t('products.card.new.title')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit ? t('products.form.edit.subtitle') : t('products.card.new.desc')}
</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.col2} ${styles.thumbnailField}`}>
<label>{t('products.form.thumbnail')}</label>
<ImageCropper value={thumbnail} onChange={setThumbnail} />
</div>
<div className={styles.col10}>
<div className={styles.topFields}>
<div className={`${styles.field} ${styles.fieldCategory}`}>
<label>{t('products.form.category')}</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder={t('products.form.categoryPlaceholder')}
/>
</div>
<div className={`${styles.field} ${styles.fieldBrand}`}>
<label>
{t('products.form.brand')}{' '}
<span className={styles.optional}>{t('products.form.optional')}</span>
</label>
<SearchableSelect
options={brandOptions}
value={brandId}
onChange={setBrandId}
placeholder={t('products.form.brandPlaceholder')}
/>
</div>
<div className={`${styles.field} ${styles.fieldHalf}`}>
<label htmlFor="nameFa">{t('products.form.nameFa')}</label>
<input
id="nameFa"
name="nameFa"
type="text"
dir="rtl"
className="faText"
placeholder={t('products.form.nameFaPlaceholder')}
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.fieldHalf}`}>
<label htmlFor="nameEn">{t('products.form.nameEn')}</label>
<input
id="nameEn"
name="nameEn"
type="text"
dir="ltr"
placeholder={t('products.form.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
<label htmlFor="summary">{t('products.form.summary')}</label>
<div className={styles.fieldWithAi}>
<textarea
id="summary"
name="summary"
rows={4}
placeholder={t('products.form.summaryPlaceholder')}
value={summary}
onChange={(e) => setSummary(e.target.value)}
required
disabled={isSubmitting}
/>
<span className={styles.aiFillAnchor}>
<Tooltip label={t('products.form.aiFill')}>
<button
type="button"
className={`${aiStyles.aiBtn} ${styles.aiFillBtn}`}
onClick={() => openAiFill('summary')}
disabled={!canUseAiFill || isAiRunning}
aria-label={t('products.form.aiFill')}
>
<Sparkles size={14} />
</button>
</Tooltip>
</span>
</div>
</div>
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.description')}</label>
<div className={styles.fieldWithAi}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder={t('products.form.descriptionPlaceholder')}
editorMinHeight={180}
/>
<span className={styles.aiFillAnchor}>
<Tooltip label={t('products.form.aiFill')}>
<button
type="button"
className={`${aiStyles.aiBtn} ${styles.aiFillBtn}`}
onClick={() => openAiFill('description')}
disabled={!canUseAiFill || isAiRunning}
aria-label={t('products.form.aiFill')}
>
<Sparkles size={14} />
</button>
</Tooltip>
</span>
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.images')}</label>
<ImageUploader images={images} onChange={setImages} />
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.tags')}</label>
<TagInput
tags={tags}
onChange={setTags}
placeholder={t('products.form.tagsPlaceholder')}
/>
</div>
</div>
<div className={styles.actions}>
<Link to="/products/list" className={styles.cancelBtn}>
{t('products.form.cancel')}
</Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('products.form.saving')
: isEdit
? t('products.form.update')
: t('products.form.save')}
</button>
</div>
</form>
<ProductFieldAiPromptModal
open={aiField !== null}
field={aiField ?? 'summary'}
nameFa={nameFa}
nameEn={nameEn}
categoryName={selectedCategoryName}
isRunning={isAiRunning}
onClose={() => {
if (!isAiRunning) setAiField(null)
}}
onRun={runAiFill}
/>
</main>
)
}