Files
dashboards/apps/customer/src/pages/AddMyProductPage.tsx
T
Alireza HassaniandCursor f8a9a28346 Add shared empty states, customer product filters, and SSO handoff across dashboards.
Standardize list empty UI, searchable country fields, customer-products filter row, password/login polish, and one-time business dashboard handoff from the customer app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:46:57 +03:30

866 lines
30 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react'
import {
useToast,
type CityOption,
} from '@meshkee/dashboard-ui'
import { CitySearchSelect } from '../components/CitySearchSelect'
import { CategorySearchSelect } from '../components/CategorySearchSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { useT } from '../i18n/useT'
import { translate } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
import {
listCitiesByCountrySlug,
listCountries,
} from '../services/citiesService'
import {
buildTechnicalValuesPayload,
createMyUserProduct,
getMyUserProduct,
getMyUserProductCategoryTechnicalForm,
listMyUserProductCategories,
updateMyUserProduct,
type TechnicalFormField,
type TechnicalFormValues,
type UserProductCategoryOption,
type UserProductCondition,
type UserProductPriceCurrency,
type UserProductTechnicalValueInput,
} from '../services/userProductsService'
import { resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './AddMyProductPage.module.css'
type StepId = 1 | 2 | 3
const PRICE_UNITS: UserProductPriceCurrency[] = ['IRT', 'USD', 'EUR', 'AED']
const CONDITIONS: UserProductCondition[] = [
'new',
'stock',
'needs_repair',
'scrap',
]
function fieldLabel(field: TechnicalFormField) {
return field.label
}
function optionLabel(option: TechnicalFormField['options'][number]) {
return option.label
}
function mapTechnicalValues(
items: UserProductTechnicalValueInput[],
): TechnicalFormValues {
const values: TechnicalFormValues = {}
for (const item of items) {
if (item.textValue != null) {
values[item.fieldId] = item.textValue
continue
}
if (item.optionId) {
values[item.fieldId] = item.optionId
continue
}
if (item.optionIds?.length) {
values[item.fieldId] = item.optionIds
}
}
return values
}
function isCondition(value: string | null | undefined): value is UserProductCondition {
return (
value === 'new' ||
value === 'stock' ||
value === 'needs_repair' ||
value === 'scrap'
)
}
function isPriceCurrency(
value: string | null | undefined,
): value is UserProductPriceCurrency {
return (
value === 'IRT' || value === 'USD' || value === 'EUR' || value === 'AED'
)
}
export function AddMyProductPage() {
const t = useT()
const navigate = useNavigate()
const { id } = useParams<{ id?: string }>()
const isEdit = Boolean(id)
const { showToast } = useToast()
const [loaded, setLoaded] = useState(!isEdit)
const [step, setStep] = useState<StepId>(1)
const [stepError, setStepError] = useState('')
const [submitting, setSubmitting] = useState(false)
const pendingTechnicalValuesRef = useRef<TechnicalFormValues | null>(null)
const [categories, setCategories] = useState<UserProductCategoryOption[]>([])
const [loadingCategories, setLoadingCategories] = useState(false)
const [categoryId, setCategoryId] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [description, setDescription] = useState('')
const [priceInput, setPriceInput] = useState('')
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
const [priceByExpert, setPriceByExpert] = useState(false)
const [countries, setCountries] = useState<CityOption[]>([])
const [cities, setCities] = useState<CityOption[]>([])
const [countryId, setCountryId] = useState('')
const [cityId, setCityId] = useState('')
const [deliveryNote, setDeliveryNote] = useState('')
const [loadingLocations, setLoadingLocations] = useState(false)
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [gallery, setGallery] = useState<string[]>([])
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [condition, setCondition] = useState<UserProductCondition>('new')
const [technicalNotes, setTechnicalNotes] = useState('')
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
const [technicalValues, setTechnicalValues] = useState<TechnicalFormValues>({})
const [loadingTechnicalForm, setLoadingTechnicalForm] = useState(false)
const steps: { id: StepId; label: string }[] = [
{ id: 1, label: t('myProducts.step.basics') },
{ id: 2, label: t('myProducts.step.images') },
{ id: 3, label: t('myProducts.step.technical') },
]
const stepIcon =
step === 1 ? (
<ClipboardList size={40} strokeWidth={1.4} />
) : step === 2 ? (
<Images size={40} strokeWidth={1.4} />
) : (
<SlidersHorizontal size={40} strokeWidth={1.4} />
)
const stepLabelKey =
step === 1
? 'myProducts.step.basics'
: step === 2
? 'myProducts.step.images'
: 'myProducts.step.technical'
const stepNameFa = translate('fa', stepLabelKey)
const stepNameEn = translate('en', stepLabelKey)
useEffect(() => {
const controller = new AbortController()
setLoadingLocations(true)
void listCountries(controller.signal)
.then((items) => {
if (!controller.signal.aborted) setCountries(items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('myProducts.error.loadLocations'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingLocations(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
const controller = new AbortController()
setLoadingCategories(true)
void listMyUserProductCategories(controller.signal)
.then((response) => {
if (!controller.signal.aborted) setCategories(response.items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('myProducts.error.loadCategories'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingCategories(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadProduct() {
setStepError('')
try {
const response = await getMyUserProduct(id!, controller.signal)
if (controller.signal.aborted) return
const product = response.product
pendingTechnicalValuesRef.current = mapTechnicalValues(
product.technicalValues ?? [],
)
setCategoryId(product.categoryId ?? '')
setTitleFa(product.titleFa ?? product.title ?? '')
setTitleEn(product.titleEn ?? '')
setDescription(product.description ?? '')
setPriceInput(
product.price != null ? formatIrtInput(String(product.price)) : '',
)
setPriceUnit(
isPriceCurrency(product.priceCurrency)
? product.priceCurrency
: 'IRT',
)
setPriceByExpert(product.priceByExpert === true)
setCountryId(product.countryId)
setCityId(product.cityId)
setDeliveryNote(product.deliveryNote ?? '')
setThumbnail(product.imageUrl)
setFeaturedMediaId(product.featuredMediaId)
setGallery((product.images ?? []).map((item) => item.url))
setGalleryMediaIds(product.galleryMediaIds ?? [])
setCondition(isCondition(product.condition) ? product.condition : 'new')
setTechnicalNotes(product.technicalNotes ?? '')
if (product.countrySlug) {
setCities(await listCitiesByCountrySlug(product.countrySlug))
}
if (!controller.signal.aborted) setLoaded(true)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
showToast(
err instanceof ApiError ? err.message : t('myProducts.error.load'),
'error',
)
navigate('/my-products', { replace: true })
}
}
void loadProduct()
return () => controller.abort()
}, [isEdit, id, navigate, showToast, t])
useEffect(() => {
if (!pendingTechnicalValuesRef.current) {
setTechnicalValues({})
}
setTechnicalFields([])
if (!categoryId) return
const controller = new AbortController()
setLoadingTechnicalForm(true)
void getMyUserProductCategoryTechnicalForm(categoryId, controller.signal)
.then((response) => {
if (controller.signal.aborted) return
setTechnicalFields(response.form?.fields ?? [])
if (pendingTechnicalValuesRef.current) {
setTechnicalValues(pendingTechnicalValuesRef.current)
pendingTechnicalValuesRef.current = null
}
})
.catch((err) => {
if (!isAbortError(err)) {
setTechnicalFields([])
setStepError(t('myProducts.error.loadTechnicalForm'))
}
})
.finally(() => {
if (!controller.signal.aborted) setLoadingTechnicalForm(false)
})
return () => controller.abort()
}, [categoryId, t])
async function handleCountryChange(nextCountryId: string) {
setCountryId(nextCountryId)
const country = countries.find((item) => item.id === nextCountryId)
const nextSlug = country?.slug ?? ''
setCityId('')
setCities([])
if (!nextSlug) return
setLoadingLocations(true)
try {
setCities(await listCitiesByCountrySlug(nextSlug))
} catch {
setStepError(t('myProducts.error.loadLocations'))
} finally {
setLoadingLocations(false)
}
}
function validateStep1() {
if (!categoryId) return t('myProducts.error.categoryRequired')
if (!titleFa.trim()) return t('myProducts.error.titleFaRequired')
if (!countryId || !cityId) {
return t('myProducts.error.locationRequired')
}
const price = parseIrtInput(priceInput)
if (priceInput.trim() && (price === null || price < 0)) {
return t('myProducts.error.priceInvalid')
}
return ''
}
function validateStep3() {
if (!condition) return t('myProducts.error.conditionRequired')
for (const field of technicalFields) {
if (!field.isRequired) continue
const value = technicalValues[field.id]
const ok =
field.type === 'multi_select'
? Array.isArray(value) && value.length > 0
: typeof value === 'string' && value.trim().length > 0
if (!ok) {
return t('myProducts.error.technicalRequired')
}
}
return ''
}
function goNext() {
setStepError('')
if (step === 1) {
const error = validateStep1()
if (error) {
setStepError(error)
return
}
setStep(2)
return
}
if (step === 2) setStep(3)
}
function goBack() {
setStepError('')
if (step === 2) setStep(1)
if (step === 3) setStep(2)
}
async function handleSubmit() {
setStepError('')
const validationError = validateStep3()
if (validationError) {
setStepError(validationError)
return
}
const price = parseIrtInput(priceInput)
setSubmitting(true)
try {
let nextFeaturedMediaId = featuredMediaId
if (thumbnail?.startsWith('data:')) {
nextFeaturedMediaId = await resolveDataUrlToMediaId(
thumbnail,
'user-product-thumbnail.jpg',
featuredMediaId,
)
} else if (!thumbnail) {
nextFeaturedMediaId = null
}
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(
gallery,
galleryMediaIds,
)
const payload = {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
description: description.trim() || undefined,
categoryId,
price: price ?? undefined,
priceCurrency: priceUnit,
priceByExpert,
countryId,
cityId,
deliveryNote: deliveryNote.trim() || undefined,
condition,
technicalNotes: technicalNotes.trim() || undefined,
technicalValues: buildTechnicalValuesPayload(technicalFields, technicalValues),
featuredMediaId: nextFeaturedMediaId || undefined,
galleryMediaIds: nextGalleryMediaIds,
}
if (isEdit && id) {
await updateMyUserProduct(id, payload)
showToast(t('myProducts.updateSuccess'), 'success')
} else {
await createMyUserProduct(payload)
showToast(t('myProducts.submitSuccess'), 'success')
}
navigate('/my-products')
} catch (err) {
setStepError(
err instanceof ApiError
? err.message
: isEdit
? t('myProducts.error.update')
: t('myProducts.error.submit'),
)
} finally {
setSubmitting(false)
}
}
function setTechnicalField(fieldId: string, value: string | string[]) {
setTechnicalValues((prev) => ({ ...prev, [fieldId]: value }))
}
function toggleMultiOption(fieldId: string, optionId: string) {
setTechnicalValues((prev) => {
const current = prev[fieldId]
const selected = Array.isArray(current) ? current : []
const next = selected.includes(optionId)
? selected.filter((item) => item !== optionId)
: [...selected, optionId]
return { ...prev, [fieldId]: next }
})
}
function renderTechnicalField(field: TechnicalFormField) {
const value = technicalValues[field.id]
const id = `tech-${field.id}`
if (field.type === 'textarea') {
return (
<textarea
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
rows={4}
/>
)
}
if (field.type === 'select') {
return (
<select
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
>
<option value="">{t('myProducts.technical.select')}</option>
{field.options.map((option) => (
<option key={option.id} value={option.id}>
{optionLabel(option)}
</option>
))}
</select>
)
}
if (field.type === 'multi_select') {
const selected = Array.isArray(value) ? value : []
return (
<div className={styles.chipGrid}>
{field.options.map((option) => (
<button
key={option.id}
type="button"
className={`${styles.chip} ${selected.includes(option.id) ? styles.chipSelected : ''}`}
onClick={() => toggleMultiOption(field.id, option.id)}
>
{optionLabel(option)}
</button>
))}
</div>
)
}
return (
<input
id={id}
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
/>
)
}
if (!loaded) {
return <p className={styles.inlineStatus}>{t('myProducts.loading')}</p>
}
return (
<div className={styles.shell}>
<aside className={styles.iconRail} aria-hidden>
<div className={styles.iconRailInner}>
<span className={styles.iconRailGlyph}>{stepIcon}</span>
<span className={styles.iconRailLabelFa}>{stepNameFa}</span>
<span className={styles.iconRailLabelEn}>{stepNameEn}</span>
</div>
</aside>
<div className={styles.card}>
<div className={styles.root}>
<nav className={styles.stepper} aria-label={t('myProducts.stepperLabel')}>
{steps.map((item, index) => {
const isDone = step > item.id
const isActive = step === item.id
const connectorDone = step > item.id
return (
<div key={item.id} className={styles.stepGroup}>
<div
className={[
styles.stepUnit,
isActive ? styles.stepActive : '',
isDone ? styles.stepDone : '',
]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepLabel}>{item.label}</span>
<span className={styles.stepDot} aria-hidden>
{isDone ? <Check size={14} /> : index + 1}
</span>
</div>
{index < steps.length - 1 && (
<div
className={[
styles.connector,
connectorDone ? styles.connectorDone : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden
/>
)}
</div>
)
})}
</nav>
<div className={styles.body} key={step}>
{step === 1 && (
<>
<h2 className={styles.stepTitle}>
{isEdit ? t('myProducts.editTitle') : t('myProducts.step.basics')}
</h2>
<p className={styles.stepDesc}>
{isEdit
? t('myProducts.editSubtitle')
: t('myProducts.step.basicsHint')}
</p>
<div className={styles.form}>
<div className={styles.field}>
<label htmlFor="add-category">{t('myProducts.fields.category')}</label>
<CategorySearchSelect
id="add-category"
options={categories}
value={categoryId}
onChange={setCategoryId}
disabled={loadingCategories}
placeholder={t('myProducts.fields.searchCategory')}
/>
</div>
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="add-title-fa">{t('myProducts.fields.titleFa')}</label>
<input
id="add-title-fa"
type="text"
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder={t('myProducts.fields.titleFaPlaceholder')}
/>
</div>
<div className={styles.field}>
<label htmlFor="add-title-en">{t('myProducts.fields.titleEn')}</label>
<input
id="add-title-en"
type="text"
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
placeholder={t('myProducts.fields.titleEnPlaceholder')}
dir="ltr"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="add-description">{t('myProducts.fields.description')}</label>
<textarea
id="add-description"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('myProducts.fields.descriptionPlaceholder')}
/>
</div>
<div className={styles.priceRow}>
<label
className={`${styles.checkRow} ${styles.col6}`}
htmlFor="add-price-by-expert"
>
<input
id="add-price-by-expert"
type="checkbox"
checked={priceByExpert}
onChange={(e) => setPriceByExpert(e.target.checked)}
/>
<span>{t('myProducts.fields.priceByExpert')}</span>
</label>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-price">
{priceByExpert
? t('myProducts.fields.priceSuggested')
: t('myProducts.fields.price')}
</label>
<input
id="add-price"
type="text"
inputMode="numeric"
value={priceInput}
onChange={(e) => setPriceInput(formatIrtInput(e.target.value))}
placeholder={t('myProducts.fields.pricePlaceholder')}
dir="ltr"
/>
</div>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-price-unit">{t('myProducts.fields.priceUnit')}</label>
<select
id="add-price-unit"
value={priceUnit}
onChange={(e) =>
setPriceUnit(e.target.value as UserProductPriceCurrency)
}
>
{PRICE_UNITS.map((unit) => (
<option key={unit} value={unit}>
{t(`myProducts.fields.priceUnit.${unit}`)}
</option>
))}
</select>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.fields.location')}</span>
</div>
<div className={styles.locationRow}>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-country">{t('myProducts.fields.country')}</label>
<CitySearchSelect
id="add-country"
options={countries}
value={countryId}
onChange={(nextId) => void handleCountryChange(nextId)}
disabled={loadingLocations}
placeholder={t('myProducts.fields.searchCountry')}
noResultsLabel={t('myProducts.fields.noCountries')}
clearLabel={t('myProducts.fields.clearCountry')}
/>
</div>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-city">{t('myProducts.fields.city')}</label>
<CitySearchSelect
id="add-city"
options={cities}
value={cityId}
onChange={setCityId}
disabled={!countryId || loadingLocations}
placeholder={t('myProducts.fields.searchCity')}
/>
</div>
<div className={`${styles.field} ${styles.col6}`}>
<label htmlFor="add-delivery-note">
{t('myProducts.fields.deliveryNote')}
<span className={styles.optional}> {t('myProducts.optional')}</span>
</label>
<input
id="add-delivery-note"
type="text"
value={deliveryNote}
onChange={(e) => setDeliveryNote(e.target.value)}
placeholder={t('myProducts.fields.deliveryNotePlaceholder')}
/>
</div>
</div>
</div>
</>
)}
{step === 2 && (
<>
<h2 className={styles.stepTitle}>{t('myProducts.step.images')}</h2>
<p className={styles.stepDesc}>{t('myProducts.step.imagesHint')}</p>
<div className={styles.form}>
<div className={styles.thumbnailBlock}>
<div className={styles.field}>
<label>{t('myProducts.images.thumbnail')}</label>
<ImageCropper
value={thumbnail}
onChange={(value) => {
setThumbnail(value)
if (!value || value.startsWith('data:')) {
setFeaturedMediaId(null)
}
}}
aspect={1}
hint={t('myProducts.images.thumbnailHint')}
/>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.images.gallery')}</span>
</div>
<div className={styles.field}>
<ImageUploader
images={gallery}
onChange={(next) => {
const prevUrlToId = new Map(
gallery.map((url, index) => [
url,
galleryMediaIds[index] ?? '',
]),
)
setGallery(next)
setGalleryMediaIds(
next.map((url) =>
url.startsWith('data:')
? ''
: prevUrlToId.get(url) ?? '',
),
)
}}
/>
</div>
</div>
</>
)}
{step === 3 && (
<>
<h2 className={styles.stepTitle}>{t('myProducts.step.technical')}</h2>
<p className={styles.stepDesc}>{t('myProducts.step.technicalHint')}</p>
<div className={styles.form}>
<fieldset className={styles.conditionFieldset}>
<legend>{t('myProducts.fields.condition')}</legend>
<div className={styles.radioGrid} role="radiogroup">
{CONDITIONS.map((value) => (
<label key={value} className={styles.radioCard}>
<input
type="radio"
name="product-condition"
value={value}
checked={condition === value}
onChange={() => setCondition(value)}
/>
<span>{t(`myProducts.condition.${value}`)}</span>
</label>
))}
</div>
</fieldset>
<div className={styles.field}>
<label htmlFor="add-technical-notes">
{t('myProducts.fields.technicalNotes')}
<span className={styles.optional}> {t('myProducts.optional')}</span>
</label>
<textarea
id="add-technical-notes"
rows={4}
value={technicalNotes}
onChange={(e) => setTechnicalNotes(e.target.value)}
placeholder={t('myProducts.fields.technicalNotesPlaceholder')}
/>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.technical.categoryForm')}</span>
</div>
{!categoryId ? (
<div className={styles.placeholder}>
{t('myProducts.technical.needCategory')}
</div>
) : loadingTechnicalForm ? (
<p className={styles.inlineStatus}>{t('myProducts.technical.loading')}</p>
) : technicalFields.length === 0 ? (
<div className={styles.placeholder}>{t('myProducts.technical.empty')}</div>
) : (
technicalFields.map((field) => (
<div key={field.id} className={styles.field}>
<label htmlFor={`tech-${field.id}`}>
{fieldLabel(field)}
{field.isRequired ? (
' *'
) : (
<span className={styles.optional}> {t('myProducts.optional')}</span>
)}
</label>
{renderTechnicalField(field)}
</div>
))
)}
</div>
</>
)}
{stepError ? (
<div className={styles.error} role="alert">
{stepError}
</div>
) : null}
</div>
<div className={styles.actions}>
{step === 1 ? (
<Link to="/my-products" className={styles.ghostBtn}>
{t('myProducts.cancel')}
</Link>
) : (
<button
type="button"
className={styles.secondaryBtn}
onClick={goBack}
disabled={submitting}
>
{t('myProducts.back')}
</button>
)}
{step < 3 ? (
<button type="button" className={styles.primaryBtn} onClick={goNext}>
{t('myProducts.next')}
</button>
) : (
<button
type="button"
className={styles.primaryBtn}
onClick={() => void handleSubmit()}
disabled={submitting || loadingTechnicalForm}
>
{submitting
? t('myProducts.submitting')
: isEdit
? t('myProducts.save')
: t('myProducts.submit')}
</button>
)}
</div>
</div>
</div>
</div>
)
}