660 lines
29 KiB
TypeScript
660 lines
29 KiB
TypeScript
import { useState, useImperativeHandle, forwardRef, useMemo } from 'react'
|
|
import { Formik, FieldArray } from 'formik'
|
|
import * as Yup from 'yup'
|
|
import { pInput as PInput, Dropdown, p as P, Checkbox, BottomDialog, ImageUploaderNeo } from 'web-components'
|
|
import type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'web-components'
|
|
import SearchableSelect from './SearchableSelect'
|
|
import { Plus, Trash2, X, RotateCcw, Info } from 'lucide-react'
|
|
import { trpc } from '@/lib/trpc-client'
|
|
import type { CreateComboItemInput, CreateSkuInput } from '@packages/shared'
|
|
import type { SkuFeatureLike } from '@packages/shared'
|
|
|
|
// Feature input shape — single source: shared SkuFeatureLike
|
|
type Attribute = SkuFeatureLike
|
|
|
|
// Variant form state — anchored to the shared CreateSkuInput (numeric prices,
|
|
// same field names). Images are managed separately via variantImages state.
|
|
type Variant = Omit<
|
|
CreateSkuInput,
|
|
'name' | 'price' | 'marketPrice' | 'flashPrice' | 'images' | 'features' | 'comboItems'
|
|
> & {
|
|
id?: number
|
|
name: string
|
|
price?: number
|
|
marketPrice: number | null
|
|
flashPrice: number | null
|
|
isFlashAvailable: boolean
|
|
isOffer: boolean
|
|
isComboOnly: boolean
|
|
isSuspended: boolean
|
|
features: Attribute[]
|
|
comboItems: CreateComboItemInput[]
|
|
}
|
|
|
|
interface ProductFormData {
|
|
name: string
|
|
shortDescription: string
|
|
longDescription: string
|
|
storeId: number
|
|
productType: 'item' | 'combo'
|
|
variants: Variant[]
|
|
}
|
|
|
|
export interface ProductFormRef {
|
|
clearImages: () => void
|
|
}
|
|
|
|
interface ProductFormProps {
|
|
mode: 'create' | 'edit'
|
|
initialValues: ProductFormData
|
|
onSubmit: (values: ProductFormData, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => void
|
|
isLoading: boolean
|
|
existingVariantImages?: ImageUploaderNeoItem[][]
|
|
existingVariantImageKeys?: string[][]
|
|
}
|
|
|
|
// Web rebuild of common-ui InfoDialog: info icon button + BottomDialog + p
|
|
function InfoDialog({ message, className }: { message?: string; className?: string }) {
|
|
const [open, setOpen] = useState(false)
|
|
return (
|
|
<>
|
|
<button type="button" onClick={() => setOpen(true)} className={className} aria-label="More info">
|
|
<Info className="h-5 w-5 text-gray-500" />
|
|
</button>
|
|
<BottomDialog open={open} onClose={() => setOpen(false)}>
|
|
<div className="py-4 px-2">
|
|
<P className="text-gray-700 text-sm leading-6">{message ?? ''}</P>
|
|
</div>
|
|
</BottomDialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' })
|
|
|
|
// The quantity feature is auto-added as the first attribute of every SKU.
|
|
const quantityAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' })
|
|
|
|
const isQuantityFeature = (attr: Attribute): boolean =>
|
|
(attr.featureName ?? '').trim().toLowerCase() === 'quantity'
|
|
|
|
const defaultVariant = (): Variant => ({
|
|
id: undefined,
|
|
name: '',
|
|
price: undefined,
|
|
marketPrice: null,
|
|
isFlashAvailable: false,
|
|
flashPrice: null,
|
|
isOffer: false,
|
|
isComboOnly: false,
|
|
isDeleted: false,
|
|
isSuspended: false,
|
|
features: [quantityAttribute()],
|
|
comboItems: [],
|
|
})
|
|
|
|
const variantSignature = (attributes: Attribute[]): string =>
|
|
attributes
|
|
.map((a) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
|
.sort()
|
|
.join('|')
|
|
|
|
const productValidationSchema = Yup.object().shape({
|
|
name: Yup.string().required('Product name is required'),
|
|
storeId: Yup.number().required('Store is required').min(1, 'Store is required'),
|
|
productType: Yup.string().oneOf(['item', 'combo'], 'Product type is required').required('Product type is required'),
|
|
variants: Yup.array()
|
|
.min(1, 'At least one variant is required')
|
|
.of(
|
|
Yup.object().shape({
|
|
price: Yup.number()
|
|
.typeError('Price must be a number')
|
|
.positive('Price must be a positive number')
|
|
.required('Price is required'),
|
|
marketPrice: Yup.number()
|
|
.typeError('Market price must be a number')
|
|
.min(0, 'Market price cannot be negative')
|
|
.nullable()
|
|
.transform((value, originalValue) => (originalValue === '' ? null : value))
|
|
.optional(),
|
|
flashPrice: Yup.number()
|
|
.typeError('Flash price must be a number')
|
|
.min(0, 'Flash price cannot be negative')
|
|
.nullable()
|
|
.transform((value, originalValue) => (originalValue === '' ? null : value))
|
|
.optional(),
|
|
features: Yup.array()
|
|
.min(1, 'At least one attribute is required')
|
|
.of(
|
|
Yup.object().shape({
|
|
featureValue: Yup.string().required('Value is required'),
|
|
})
|
|
),
|
|
})
|
|
)
|
|
.test('unique-variants', 'Two variants have the same attributes', function (variants) {
|
|
if (!Array.isArray(variants)) return true
|
|
const seen = new Set<string>()
|
|
for (const variant of variants) {
|
|
const attrs = variant?.features || []
|
|
const signature = variantSignature(attrs as Attribute[])
|
|
if (seen.has(signature)) {
|
|
return this.createError({ message: 'Two variants have the same attributes' })
|
|
}
|
|
seen.add(signature)
|
|
}
|
|
return true
|
|
})
|
|
.test('quantity-feature', 'Each SKU must have exactly one quantity feature', function (variants) {
|
|
if (!Array.isArray(variants)) return true
|
|
for (const variant of variants) {
|
|
const attrs = variant?.features || []
|
|
const quantityCount = attrs.filter(
|
|
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
|
|
).length
|
|
if (quantityCount === 0) {
|
|
return this.createError({ message: 'Every SKU must have a quantity feature' })
|
|
}
|
|
if (quantityCount > 1) {
|
|
return this.createError({ message: 'Each SKU can have only one quantity feature' })
|
|
}
|
|
}
|
|
return true
|
|
}),
|
|
})
|
|
|
|
// Collect every string message from a Formik errors tree (objects, arrays, strings).
|
|
// Variant-level errors (e.g. 'Each SKU can have only one quantity feature') can get
|
|
// hidden by Formik when index-level errors are also present, so we walk everything.
|
|
const collectErrorMessages = (errors: unknown, depth = 0): string[] => {
|
|
if (depth > 6) return []
|
|
if (typeof errors === 'string') return [errors]
|
|
if (Array.isArray(errors)) return errors.flatMap((e) => collectErrorMessages(e, depth + 1))
|
|
if (errors && typeof errors === 'object') {
|
|
return Object.values(errors).flatMap((e) => collectErrorMessages(e, depth + 1))
|
|
}
|
|
return []
|
|
}
|
|
|
|
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|
mode,
|
|
initialValues,
|
|
onSubmit,
|
|
isLoading,
|
|
existingVariantImages = [],
|
|
existingVariantImageKeys = [],
|
|
}, ref) => {
|
|
const [variantImages, setVariantImages] = useState<ImageUploaderNeoItem[][]>(() =>
|
|
initialValues.variants.length > 0
|
|
? initialValues.variants.map((_, i) => existingVariantImages[i] || [])
|
|
: [[]]
|
|
)
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
clearImages: () => setVariantImages(initialValues.variants.map(() => [])),
|
|
}), [initialValues.variants])
|
|
|
|
const { data: storesData } = trpc.common.getStoresSummary.useQuery()
|
|
const storeOptions = storesData?.stores.map((store) => ({
|
|
label: store.name,
|
|
value: store.id,
|
|
})) || []
|
|
|
|
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
|
|
const skuOptions = (skusData?.skus || []).map((sku) => ({
|
|
label: sku.label,
|
|
value: sku.id,
|
|
}))
|
|
|
|
// Make sure every SKU starts with a 'quantity' feature (auto-added at the
|
|
// beginning if the provided initial values don't have one).
|
|
const formInitialValues = useMemo(() => {
|
|
return {
|
|
...initialValues,
|
|
variants: (initialValues.variants || []).map((v) => {
|
|
const hasQuantity = (v.features || []).some(
|
|
(a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
|
)
|
|
if (hasQuantity) return v
|
|
return { ...v, features: [quantityAttribute(), ...(v.features || [])] }
|
|
}),
|
|
}
|
|
}, [initialValues])
|
|
|
|
return (
|
|
<Formik
|
|
initialValues={formInitialValues}
|
|
validationSchema={productValidationSchema}
|
|
onSubmit={(values) => {
|
|
// Normalize feature names before sending to backend: the mandatory
|
|
// 'quantity' feature must always go out lowercase (case-insensitive
|
|
// matching means 'Quantity'/'QUANTITY' are treated the same), and
|
|
// names are trimmed like the backend does.
|
|
// TextInputs emit strings — coerce price fields to the numeric types
|
|
// the backend zod schema requires (price required; market/flash nullable).
|
|
const toNumber = (value: unknown): number | null => {
|
|
if (value == null || value === '') return null
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
const normalizedValues: ProductFormData = {
|
|
...values,
|
|
variants: values.variants.map((v) => ({
|
|
...v,
|
|
price: toNumber(v.price) ?? undefined,
|
|
marketPrice: toNumber(v.marketPrice),
|
|
flashPrice: toNumber(v.flashPrice),
|
|
features: v.features.map((a) => ({
|
|
...a,
|
|
featureName: isQuantityFeature(a) ? 'quantity' : (a.featureName ?? '').trim() || null,
|
|
})),
|
|
})),
|
|
}
|
|
const images = variantImages.map((imgs) =>
|
|
imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType }))
|
|
)
|
|
const deletedKeys: string[] = []
|
|
if (mode === 'edit') {
|
|
variantImages.forEach((currentImgs, vIndex) => {
|
|
const existing = existingVariantImages[vIndex] || []
|
|
existing.forEach((existingImg) => {
|
|
if (!currentImgs.some((cur) => cur.imgUrl === existingImg.imgUrl)) {
|
|
const key = existingVariantImageKeys[vIndex]?.[existingVariantImages[vIndex]?.indexOf(existingImg)]
|
|
if (key) deletedKeys.push(key)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
onSubmit(normalizedValues, images, deletedKeys)
|
|
}}
|
|
enableReinitialize
|
|
>
|
|
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
|
|
return (
|
|
<div className="flex-1 overflow-auto pb-10">
|
|
<PInput
|
|
topLabel="Product Name"
|
|
placeholder="Enter product name"
|
|
value={values.name}
|
|
onChange={handleChange('name')}
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<PInput
|
|
topLabel="Short Description"
|
|
placeholder="Enter short description"
|
|
multiline
|
|
numberOfLines={2}
|
|
value={values.shortDescription}
|
|
onChange={handleChange('shortDescription')}
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<PInput
|
|
topLabel="Long Description"
|
|
placeholder="Enter detailed description"
|
|
multiline
|
|
numberOfLines={4}
|
|
value={values.longDescription}
|
|
onChange={handleChange('longDescription')}
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<div className="mb-4" data-testid="product-store-select">
|
|
<P className="mb-1 text-sm text-gray-500 font-medium">Store</P>
|
|
<Dropdown
|
|
label="Select store"
|
|
value={values.storeId}
|
|
options={storeOptions}
|
|
onValueChange={(value) => setFieldValue('storeId', Number(value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mb-4" data-testid="product-type-select">
|
|
<P className="mb-1 text-sm text-gray-500 font-medium">Product Type</P>
|
|
<Dropdown
|
|
label="Select product type"
|
|
value={values.productType}
|
|
options={[
|
|
{ label: 'Item', value: 'item' },
|
|
{ label: 'Combo', value: 'combo' },
|
|
]}
|
|
onValueChange={(value) => setFieldValue('productType', value as 'item' | 'combo')}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<FieldArray name="variants">
|
|
{({ push, remove }) => (
|
|
<div>
|
|
<div className="flex flex-row justify-between items-center mb-3">
|
|
<P className="text-lg font-bold text-gray-800">Variants</P>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
push(defaultVariant())
|
|
setVariantImages((prev) => [...prev, []])
|
|
}}
|
|
className="bg-blue-500 px-3 py-1 rounded-lg flex flex-row items-center text-white font-semibold"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
<span className="ml-1">Add Variant</span>
|
|
</button>
|
|
</div>
|
|
|
|
{values.variants.map((variant, vIndex) => {
|
|
const isExistingSku = variant.id != null
|
|
const isMarkedDeleted = !!variant.isDeleted
|
|
return (
|
|
<div key={vIndex} className={`border border-gray-300 rounded-xl p-4 mb-4 ${isMarkedDeleted ? 'border-red-300 bg-red-50 opacity-80' : ''}`}>
|
|
<div className="flex flex-row justify-between items-center mb-3">
|
|
<div className="flex flex-row items-center">
|
|
<P className="font-bold text-gray-700">Variant {vIndex + 1}</P>
|
|
{isMarkedDeleted && (
|
|
<div className="ml-2 px-2 py-0.5 rounded-full bg-red-100 border border-red-200">
|
|
<P className="text-xs font-bold text-red-700 uppercase">Deleted</P>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{(values.variants.length > 1 || isExistingSku) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (isExistingSku) {
|
|
// Mark existing SKU as deleted (sent to backend on save).
|
|
setFieldValue(`variants.${vIndex}.isDeleted`, !isMarkedDeleted)
|
|
} else {
|
|
remove(vIndex)
|
|
setVariantImages((prev) => prev.filter((_, i) => i !== vIndex))
|
|
}
|
|
}}
|
|
aria-label={isMarkedDeleted ? 'Restore variant' : 'Delete variant'}
|
|
>
|
|
{isMarkedDeleted ? (
|
|
<RotateCcw className="h-5 w-5 text-green-500" />
|
|
) : (
|
|
<Trash2 className="h-5 w-5 text-red-500" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<PInput
|
|
topLabel="SKU Name (optional)"
|
|
placeholder="Overrides the auto-generated name"
|
|
value={variant.name ?? ''}
|
|
onChange={handleChange(`variants.${vIndex}.name`)}
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
|
|
<FieldArray name={`variants.${vIndex}.features`}>
|
|
{({ push: pushAttr, remove: removeAttr }) => (
|
|
<div className="mb-3">
|
|
<div className="flex flex-row justify-between items-center mb-2">
|
|
<P className="font-medium text-gray-600">Attributes</P>
|
|
<button
|
|
type="button"
|
|
onClick={() => pushAttr(defaultAttribute())}
|
|
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-gray-600" />
|
|
<span className="ml-0.5">Add</span>
|
|
</button>
|
|
</div>
|
|
{variant.features.map((attr, aIndex) => {
|
|
const quantityCount = variant.features.filter(isQuantityFeature).length
|
|
// Quantity features are locked (non-editable), but if there
|
|
// are duplicates (e.g. 'quantity' + 'Quantity') the user must
|
|
// be able to delete the extras to fix the form.
|
|
const canDelete = variant.features.length > 1 && (!isQuantityFeature(attr) || quantityCount > 1)
|
|
return (
|
|
<div key={aIndex} className="flex flex-row items-center mb-2 gap-2">
|
|
<div className="flex-1">
|
|
<PInput
|
|
placeholder="Name"
|
|
value={attr.featureName ?? ''}
|
|
onChange={(e) =>
|
|
setFieldValue(
|
|
`variants.${vIndex}.features.${aIndex}.featureName`,
|
|
e.target.value.trim().toLowerCase() === 'quantity' ? 'quantity' : e.target.value
|
|
)
|
|
}
|
|
disabled={isQuantityFeature(attr)}
|
|
className={isQuantityFeature(attr) ? 'text-gray-400' : undefined}
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<PInput
|
|
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
|
|
value={attr.featureValue}
|
|
onChange={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
|
|
/>
|
|
</div>
|
|
{canDelete && (
|
|
<button type="button" onClick={() => removeAttr(aIndex)} aria-label="Remove attribute">
|
|
<X className="h-4 w-4 text-red-500" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
<div className="flex flex-row items-center self-start">
|
|
<button
|
|
type="button"
|
|
onClick={() => pushAttr(defaultAttribute())}
|
|
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-gray-600" />
|
|
<span className="ml-0.5">Add Feature</span>
|
|
</button>
|
|
<InfoDialog
|
|
message="Quantity is mandatory feature. Add other features optionally with name. Features become part of name — ex: a feature with Mini as value becomes <ItemName> Mini"
|
|
className="ml-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</FieldArray>
|
|
|
|
<div className="flex flex-row gap-2 mb-3">
|
|
<div className="flex-1">
|
|
<PInput
|
|
topLabel="Market Price"
|
|
placeholder="MRP"
|
|
type="number"
|
|
inputMode="numeric"
|
|
value={variant.marketPrice?.toString() ?? ''}
|
|
onChange={handleChange(`variants.${vIndex}.marketPrice`)}
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<PInput
|
|
topLabel="Our Price"
|
|
placeholder="Selling price"
|
|
type="number"
|
|
inputMode="numeric"
|
|
value={variant.price?.toString() ?? ''}
|
|
onChange={handleChange(`variants.${vIndex}.price`)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-row items-center mb-3">
|
|
<Checkbox
|
|
checked={variant.isFlashAvailable}
|
|
onPress={() => {
|
|
setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable)
|
|
if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, null)
|
|
}}
|
|
/>
|
|
<P className="text-gray-700 font-medium ml-3">Flash Available</P>
|
|
</div>
|
|
|
|
<div className="flex flex-row items-center mb-3">
|
|
<Checkbox
|
|
checked={variant.isOffer}
|
|
onPress={() => setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)}
|
|
/>
|
|
<P className="text-gray-700 font-medium ml-3">Offer SKU</P>
|
|
</div>
|
|
|
|
{!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && (
|
|
<div className="flex flex-row items-center mb-3">
|
|
<Checkbox
|
|
checked={variant.isComboOnly}
|
|
onPress={() => setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)}
|
|
/>
|
|
<P className="text-gray-700 font-medium ml-3">Combo Only SKU</P>
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'edit' && (
|
|
<div className="flex flex-row items-center mb-3" data-testid={`variant-suspend-row-${vIndex}`}>
|
|
<Checkbox
|
|
checked={variant.isSuspended}
|
|
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
|
|
/>
|
|
<P className="text-gray-700 font-medium ml-3">Suspend SKU</P>
|
|
</div>
|
|
)}
|
|
|
|
{variant.isFlashAvailable && (
|
|
<PInput
|
|
topLabel="Flash Price"
|
|
placeholder="Enter flash price"
|
|
type="number"
|
|
inputMode="numeric"
|
|
value={variant.flashPrice?.toString() ?? ''}
|
|
onChange={handleChange(`variants.${vIndex}.flashPrice`)}
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
)}
|
|
|
|
<ImageUploaderNeo
|
|
images={variantImages[vIndex] || []}
|
|
onImageAdd={(payloads) =>
|
|
setVariantImages((prev) => {
|
|
const next = [...prev]
|
|
next[vIndex] = [...(next[vIndex] || []), ...payloads.map((pl) => ({ imgUrl: pl.url, mimeType: pl.mimeType }))]
|
|
return next
|
|
})
|
|
}
|
|
onImageRemove={(payload) =>
|
|
setVariantImages((prev) => {
|
|
const next = [...prev]
|
|
next[vIndex] = (next[vIndex] || []).filter((img) => img.imgUrl !== payload.url)
|
|
return next
|
|
})
|
|
}
|
|
allowMultiple={true}
|
|
/>
|
|
|
|
{values.productType === 'combo' && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
<div className="flex flex-row justify-between items-center mb-2">
|
|
<P className="font-medium text-gray-600">Included Items</P>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const items = variant.comboItems || []
|
|
items.push({ skuId: 0 })
|
|
setFieldValue(`variants.${vIndex}.comboItems`, items)
|
|
}}
|
|
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-gray-600" />
|
|
<span className="ml-0.5">Add</span>
|
|
</button>
|
|
</div>
|
|
{variant.comboItems?.map((ci, cIndex) => (
|
|
<div key={cIndex} className="flex flex-row items-center gap-2 mb-2">
|
|
<div className="flex-1">
|
|
<SearchableSelect
|
|
label="SKU"
|
|
value={ci.skuId}
|
|
options={skuOptions.map((opt: { label: string; value: number }) => ({
|
|
...opt,
|
|
// Disable SKUs already picked in another row of this combo.
|
|
disabled: (variant.comboItems || []).some(
|
|
(item, idx) => idx !== cIndex && String(item.skuId) === String(opt.value)
|
|
),
|
|
}))}
|
|
onValueChange={(val) => {
|
|
const current = variant.comboItems || []
|
|
const isDuplicate = current.some(
|
|
(item, idx) => idx !== cIndex && String(item.skuId) === String(val)
|
|
)
|
|
if (isDuplicate) {
|
|
window.alert('Duplicate: This product is already in the combo')
|
|
return
|
|
}
|
|
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val))
|
|
}}
|
|
placeholder="Select SKU"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const items = variant.comboItems.filter((_, i) => i !== cIndex)
|
|
setFieldValue(`variants.${vIndex}.comboItems`, items)
|
|
}}
|
|
aria-label="Remove combo item"
|
|
>
|
|
<X className="h-4 w-4 text-red-500" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
push(defaultVariant())
|
|
setVariantImages((prev) => [...prev, []])
|
|
}}
|
|
className="bg-blue-500 px-3 py-2 rounded-lg flex flex-row items-center justify-center mb-4 text-white font-semibold w-full"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
<span className="ml-1">Add Variant</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</FieldArray>
|
|
|
|
<button
|
|
type="button"
|
|
data-testid="product-submit-button"
|
|
onClick={async () => {
|
|
const validationErrors = await validateForm()
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
const variantsMessages = collectErrorMessages(validationErrors.variants)
|
|
const allMessages = collectErrorMessages(validationErrors)
|
|
const message = variantsMessages[0] || allMessages[0] || 'Please fix the highlighted fields'
|
|
window.alert(`Check your form: ${String(message)}`)
|
|
return
|
|
}
|
|
handleSubmit()
|
|
}}
|
|
disabled={isLoading}
|
|
className={`px-4 py-3 rounded-lg shadow-lg items-center mt-4 text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
|
>
|
|
{(() => {
|
|
if (mode === 'edit') {
|
|
return isLoading ? 'Saving...' : 'Save Changes'
|
|
}
|
|
return isLoading ? 'Creating...' : 'Create Product'
|
|
})()}
|
|
</button>
|
|
</div>
|
|
)
|
|
}}
|
|
</Formik>
|
|
)
|
|
})
|
|
|
|
ProductForm.displayName = 'ProductForm'
|
|
|
|
export default ProductForm
|