PNG/WebP uploads no longer bake to black on JPEG; product croppers can zoom out with white padding, and SEO tools move into the product overflow menu. Co-authored-by: Cursor <cursoragent@cursor.com>
895 lines
32 KiB
TypeScript
895 lines
32 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 {
|
|
type CityOption,
|
|
} from '@meshkee/dashboard-ui'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { useToast } from '../context/ToastContext'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import { CitySearchSelect } from '../components/CitySearchSelect'
|
|
import { CategorySearchSelect } from '../components/CategorySearchSelect'
|
|
import { ImageCropper } from '../components/ImageCropper'
|
|
import { ImageUploader } from '../components/ImageUploader'
|
|
import { SeoMetaFields } from '../components/SeoMetaFields'
|
|
import { useT } from '../i18n/useT'
|
|
import { translate } from '../i18n/messages'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import {
|
|
listCitiesByCountrySlug,
|
|
listCountries,
|
|
} from '../services/citiesService'
|
|
import {
|
|
buildTechnicalValuesPayload,
|
|
createUserProduct,
|
|
getUserProduct,
|
|
getUserProductCategoryTechnicalForm,
|
|
listUserProductCategories,
|
|
updateUserProduct,
|
|
type TechnicalFormField,
|
|
type TechnicalFormValues,
|
|
type UserProductCategoryOption,
|
|
type UserProductCondition,
|
|
type UserProductPriceCurrency,
|
|
type UserProductTechnicalValueInput,
|
|
} from '../services/userProductsService'
|
|
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
|
|
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
|
import { seoMetaPayload } from '../utils/seoMeta'
|
|
import styles from './AddCustomerProductPage.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 AddCustomerProductPage() {
|
|
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 [seoMetaTitle, setSeoMetaTitle] = useState('')
|
|
const [seoMetaDescription, setSeoMetaDescription] = 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('customerProducts.form.step.basics') },
|
|
{ id: 2, label: t('customerProducts.form.step.images') },
|
|
{ id: 3, label: t('customerProducts.form.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
|
|
? 'customerProducts.form.step.basics'
|
|
: step === 2
|
|
? 'customerProducts.form.step.images'
|
|
: 'customerProducts.form.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('customerProducts.form.error.loadLocations'))
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) setLoadingLocations(false)
|
|
})
|
|
return () => controller.abort()
|
|
}, [t])
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
setLoadingCategories(true)
|
|
void listUserProductCategories(controller.signal)
|
|
.then((response) => {
|
|
if (!controller.signal.aborted) setCategories(response.items)
|
|
})
|
|
.catch((err) => {
|
|
if (!isAbortError(err)) setStepError(t('customerProducts.form.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 getUserProduct(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 ?? '')
|
|
setSeoMetaTitle(product.seoMetaTitle ?? '')
|
|
setSeoMetaDescription(product.seoMetaDescription ?? '')
|
|
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('customerProducts.form.error.load'),
|
|
'error',
|
|
)
|
|
navigate('/customer-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 getUserProductCategoryTechnicalForm(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('customerProducts.form.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('customerProducts.form.error.loadLocations'))
|
|
} finally {
|
|
setLoadingLocations(false)
|
|
}
|
|
}
|
|
|
|
function validateStep1() {
|
|
if (!categoryId) return t('customerProducts.form.error.categoryRequired')
|
|
if (!titleFa.trim()) return t('customerProducts.form.error.titleFaRequired')
|
|
if (!countryId || !cityId) {
|
|
return t('customerProducts.form.error.locationRequired')
|
|
}
|
|
const price = parseIrtInput(priceInput)
|
|
if (priceInput.trim() && (price === null || price < 0)) {
|
|
return t('customerProducts.form.error.priceInvalid')
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function validateStep3() {
|
|
if (!condition) return t('customerProducts.form.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('customerProducts.form.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,
|
|
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
|
|
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 updateUserProduct(id, payload)
|
|
showToast(t('customerProducts.form.updateSuccess'), 'success')
|
|
} else {
|
|
await createUserProduct(payload)
|
|
showToast(t('customerProducts.form.submitSuccess'), 'success')
|
|
}
|
|
navigate('/customer-products')
|
|
} catch (err) {
|
|
setStepError(
|
|
err instanceof ApiError
|
|
? err.message
|
|
: isEdit
|
|
? t('customerProducts.form.error.update')
|
|
: t('customerProducts.form.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('customerProducts.form.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 (
|
|
<main className={pageStyles.content}>
|
|
<p className={styles.inlineStatus}>{t('customerProducts.form.loading')}</p>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: t('nav.home'), href: '/' },
|
|
{ label: t('customerProducts.title'), href: '/customer-products' },
|
|
{
|
|
label: isEdit
|
|
? t('customerProducts.edit')
|
|
: t('customerProducts.add'),
|
|
},
|
|
]}
|
|
/>
|
|
<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('customerProducts.form.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('customerProducts.form.editTitle') : t('customerProducts.form.step.basics')}
|
|
</h2>
|
|
<p className={styles.stepDesc}>
|
|
{isEdit
|
|
? t('customerProducts.form.editSubtitle')
|
|
: t('customerProducts.form.step.basicsHint')}
|
|
</p>
|
|
|
|
<div className={styles.form}>
|
|
<div className={styles.field}>
|
|
<label htmlFor="add-category">{t('customerProducts.form.fields.category')}</label>
|
|
<CategorySearchSelect
|
|
id="add-category"
|
|
options={categories}
|
|
value={categoryId}
|
|
onChange={setCategoryId}
|
|
disabled={loadingCategories}
|
|
placeholder={t('customerProducts.form.fields.searchCategory')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.fieldRow}>
|
|
<div className={styles.field}>
|
|
<label htmlFor="add-title-fa">{t('customerProducts.form.fields.titleFa')}</label>
|
|
<input
|
|
id="add-title-fa"
|
|
type="text"
|
|
value={titleFa}
|
|
onChange={(e) => setTitleFa(e.target.value)}
|
|
placeholder={t('customerProducts.form.fields.titleFaPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label htmlFor="add-title-en">{t('customerProducts.form.fields.titleEn')}</label>
|
|
<input
|
|
id="add-title-en"
|
|
type="text"
|
|
value={titleEn}
|
|
onChange={(e) => setTitleEn(e.target.value)}
|
|
placeholder={t('customerProducts.form.fields.titleEnPlaceholder')}
|
|
dir="ltr"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="add-description">{t('customerProducts.form.fields.description')}</label>
|
|
<textarea
|
|
id="add-description"
|
|
rows={4}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t('customerProducts.form.fields.descriptionPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
<SeoMetaFields
|
|
seoMetaTitle={seoMetaTitle}
|
|
seoMetaDescription={seoMetaDescription}
|
|
onTitleChange={setSeoMetaTitle}
|
|
onDescriptionChange={setSeoMetaDescription}
|
|
disabled={submitting}
|
|
hint={t('seo.meta.hint')}
|
|
titleLabel={t('seo.meta.title')}
|
|
descriptionLabel={t('seo.meta.description')}
|
|
titlePlaceholder={t('seo.meta.titlePlaceholder')}
|
|
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
|
|
/>
|
|
|
|
<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('customerProducts.form.fields.priceByExpert')}</span>
|
|
</label>
|
|
<div className={`${styles.field} ${styles.col4}`}>
|
|
<label htmlFor="add-price">
|
|
{priceByExpert
|
|
? t('customerProducts.form.fields.priceSuggested')
|
|
: t('customerProducts.form.fields.price')}
|
|
</label>
|
|
<input
|
|
id="add-price"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={priceInput}
|
|
onChange={(e) => setPriceInput(formatIrtInput(e.target.value))}
|
|
placeholder={t('customerProducts.form.fields.pricePlaceholder')}
|
|
dir="ltr"
|
|
/>
|
|
</div>
|
|
<div className={`${styles.field} ${styles.col2}`}>
|
|
<label htmlFor="add-price-unit">{t('customerProducts.form.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(`customerProducts.form.fields.priceUnit.${unit}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.sectionDivider}>
|
|
<span>{t('customerProducts.form.fields.location')}</span>
|
|
</div>
|
|
|
|
<div className={styles.locationRow}>
|
|
<div className={`${styles.field} ${styles.col2}`}>
|
|
<label htmlFor="add-country">{t('customerProducts.form.fields.country')}</label>
|
|
<CitySearchSelect
|
|
id="add-country"
|
|
options={countries}
|
|
value={countryId}
|
|
onChange={(nextId) => void handleCountryChange(nextId)}
|
|
disabled={loadingLocations}
|
|
placeholder={t('customerProducts.form.fields.searchCountry')}
|
|
noResultsLabel={t('customerProducts.form.fields.noCountries')}
|
|
clearLabel={t('customerProducts.form.fields.clearCountry')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.col4}`}>
|
|
<label htmlFor="add-city">{t('customerProducts.form.fields.city')}</label>
|
|
<CitySearchSelect
|
|
id="add-city"
|
|
options={cities}
|
|
value={cityId}
|
|
onChange={setCityId}
|
|
disabled={!countryId || loadingLocations}
|
|
placeholder={t('customerProducts.form.fields.searchCity')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.col6}`}>
|
|
<label htmlFor="add-delivery-note">
|
|
{t('customerProducts.form.fields.deliveryNote')}
|
|
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
|
|
</label>
|
|
<input
|
|
id="add-delivery-note"
|
|
type="text"
|
|
value={deliveryNote}
|
|
onChange={(e) => setDeliveryNote(e.target.value)}
|
|
placeholder={t('customerProducts.form.fields.deliveryNotePlaceholder')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<>
|
|
<h2 className={styles.stepTitle}>{t('customerProducts.form.step.images')}</h2>
|
|
<p className={styles.stepDesc}>{t('customerProducts.form.step.imagesHint')}</p>
|
|
|
|
<div className={styles.form}>
|
|
<div className={styles.thumbnailBlock}>
|
|
<div className={styles.field}>
|
|
<label>{t('customerProducts.form.images.thumbnail')}</label>
|
|
<ImageCropper
|
|
value={thumbnail}
|
|
onChange={(value) => {
|
|
setThumbnail(value)
|
|
if (!value || value.startsWith('data:')) {
|
|
setFeaturedMediaId(null)
|
|
}
|
|
}}
|
|
aspect={1}
|
|
minZoom={0.4}
|
|
hint={t('customerProducts.form.images.thumbnailHint')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.sectionDivider}>
|
|
<span>{t('customerProducts.form.images.gallery')}</span>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<ImageUploader
|
|
images={gallery}
|
|
onChange={(next) => {
|
|
setGalleryMediaIds(
|
|
galleryMediaIdsAfterChange(gallery, galleryMediaIds, next),
|
|
)
|
|
setGallery(next)
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<>
|
|
<h2 className={styles.stepTitle}>{t('customerProducts.form.step.technical')}</h2>
|
|
<p className={styles.stepDesc}>{t('customerProducts.form.step.technicalHint')}</p>
|
|
|
|
<div className={styles.form}>
|
|
<fieldset className={styles.conditionFieldset}>
|
|
<legend>{t('customerProducts.form.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(`customerProducts.form.condition.${value}`)}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="add-technical-notes">
|
|
{t('customerProducts.form.fields.technicalNotes')}
|
|
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
|
|
</label>
|
|
<textarea
|
|
id="add-technical-notes"
|
|
rows={4}
|
|
value={technicalNotes}
|
|
onChange={(e) => setTechnicalNotes(e.target.value)}
|
|
placeholder={t('customerProducts.form.fields.technicalNotesPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.sectionDivider}>
|
|
<span>{t('customerProducts.form.technical.categoryForm')}</span>
|
|
</div>
|
|
|
|
{!categoryId ? (
|
|
<div className={styles.placeholder}>
|
|
{t('customerProducts.form.technical.needCategory')}
|
|
</div>
|
|
) : loadingTechnicalForm ? (
|
|
<p className={styles.inlineStatus}>{t('customerProducts.form.technical.loading')}</p>
|
|
) : technicalFields.length === 0 ? (
|
|
<div className={styles.placeholder}>{t('customerProducts.form.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('customerProducts.form.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="/customer-products" className={styles.ghostBtn}>
|
|
{t('customerProducts.form.cancel')}
|
|
</Link>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={styles.secondaryBtn}
|
|
onClick={goBack}
|
|
disabled={submitting}
|
|
>
|
|
{t('customerProducts.form.back')}
|
|
</button>
|
|
)}
|
|
|
|
{step < 3 ? (
|
|
<button type="button" className={styles.primaryBtn} onClick={goNext}>
|
|
{t('customerProducts.form.next')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={styles.primaryBtn}
|
|
onClick={() => void handleSubmit()}
|
|
disabled={submitting || loadingTechnicalForm}
|
|
>
|
|
{submitting
|
|
? t('customerProducts.form.submitting')
|
|
: isEdit
|
|
? t('customerProducts.form.save')
|
|
: t('customerProducts.form.submit')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
} |