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 ( <> setOpen(false)}>

{message ?? ''}

) } 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() 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(({ mode, initialValues, onSubmit, isLoading, existingVariantImages = [], existingVariantImageKeys = [], }, ref) => { const [variantImages, setVariantImages] = useState(() => 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 ( { // 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 (

Store

setFieldValue('storeId', Number(value))} className="w-full" />

Product Type

setFieldValue('productType', value as 'item' | 'combo')} className="w-full" />
{({ push, remove }) => (

Variants

{values.variants.map((variant, vIndex) => { const isExistingSku = variant.id != null const isMarkedDeleted = !!variant.isDeleted return (

Variant {vIndex + 1}

{isMarkedDeleted && (

Deleted

)}
{(values.variants.length > 1 || isExistingSku) && ( )}
{({ push: pushAttr, remove: removeAttr }) => (

Attributes

{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 (
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} />
{canDelete && ( )}
) })}
)}
{ setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable) if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, null) }} />

Flash Available

setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)} />

Offer SKU

{!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && (
setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)} />

Combo Only SKU

)} {mode === 'edit' && (
setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)} />

Suspend SKU

)} {variant.isFlashAvailable && ( )} 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' && (

Included Items

{variant.comboItems?.map((ci, cIndex) => (
({ ...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" />
))}
)}
) })}
)}
) }}
) }) ProductForm.displayName = 'ProductForm' export default ProductForm