mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||
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 { 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 type { Category, FlatCategory } from '../types/category'
|
||||
import { flattenCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
|
||||
export function AddNewProductPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
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 categoryOptions = flattenCategories(categories)
|
||||
|
||||
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('Unable to load form options.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFormOptions()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
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('Unable to save product.')
|
||||
}
|
||||
} 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 ? 'Edit Product' : 'Add a New Product'}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit
|
||||
? 'Update product details and save changes.'
|
||||
: 'Create and publish a new product to your store.'}
|
||||
</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>Thumbnail Image</label>
|
||||
<ImageCropper value={thumbnail} onChange={setThumbnail} />
|
||||
</div>
|
||||
|
||||
<div className={styles.col10}>
|
||||
<div className={styles.topFields}>
|
||||
<div className={`${styles.field} ${styles.fieldCategory}`}>
|
||||
<label>Category</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="Search and select category..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldBrand}`}>
|
||||
<label>
|
||||
Brand <span className={styles.optional}>(optional)</span>
|
||||
</label>
|
||||
<SearchableSelect
|
||||
options={brandOptions}
|
||||
value={brandId}
|
||||
onChange={setBrandId}
|
||||
placeholder="Search and select brand..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldHalf}`}>
|
||||
<label htmlFor="nameFa">Name (FA)</label>
|
||||
<input
|
||||
id="nameFa"
|
||||
name="nameFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
className="faText"
|
||||
placeholder="نام محصول"
|
||||
value={nameFa}
|
||||
onChange={(e) => setNameFa(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldHalf}`}>
|
||||
<label htmlFor="nameEn">Name (EN)</label>
|
||||
<input
|
||||
id="nameEn"
|
||||
name="nameEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="Product name"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
|
||||
<label htmlFor="summary">Summary</label>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows={4}
|
||||
placeholder="Short summary shown in product listings"
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Description</label>
|
||||
<RichTextEditor
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Full product description with formatting..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Product Images</label>
|
||||
<ImageUploader images={images} onChange={setImages} />
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Tags</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/products/list" className={styles.cancelBtn}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : isEdit ? 'Update Product' : 'Save Product'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user