import React, { useState, useImperativeHandle, forwardRef, useMemo } from 'react' import { View, TouchableOpacity, ScrollView, Alert } from 'react-native' import { Formik, FieldArray } from 'formik' import * as Yup from 'yup' import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox, InfoDialog } from 'common-ui' import MaterialIcons from '@expo/vector-icons/MaterialIcons' import { trpc } from '../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[][] } 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 ( setFieldValue('storeId', value)} placeholder="Select store" style={{ marginBottom: 16 }} /> setFieldValue('productType', value)} placeholder="Select product type" style={{ marginBottom: 16 }} /> {({ push, remove }) => ( Variants { push(defaultVariant()) setVariantImages((prev) => [...prev, []]) }} style={tw`bg-blue-500 px-3 py-1 rounded-lg flex-row items-center`} > Add Variant {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) && ( { 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)) } }} > )} {({ push: pushAttr, remove: removeAttr }) => ( Attributes pushAttr(defaultAttribute())} style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`} > Add {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`, text.trim().toLowerCase() === 'quantity' ? 'quantity' : text ) } editable={!isQuantityFeature(attr)} style={isQuantityFeature(attr) ? tw`text-gray-400` : undefined} /> {canDelete && ( removeAttr(aIndex)}> )} ) })} pushAttr(defaultAttribute())} style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`} > Add Feature )} { setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable) if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, null) }} style={tw`mr-3`} /> Flash Available setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)} style={tw`mr-3`} /> Offer SKU {!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && ( setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)} style={tw`mr-3`} /> Combo Only SKU )} {mode === 'edit' && ( setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)} style={tw`mr-3`} /> Suspend SKU )} {variant.isFlashAvailable && ( )} setVariantImages((prev) => { const next = [...prev] next[vIndex] = [...(next[vIndex] || []), ...payloads.map((p) => ({ imgUrl: p.url, mimeType: p.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 { const items = variant.comboItems || [] items.push({ skuId: 0 }) setFieldValue(`variants.${vIndex}.comboItems`, items) }} style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`} > Add {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) { Alert.alert('Duplicate', 'This product is already in the combo') return } setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val)) }} placeholder="Select SKU" /> { const items = variant.comboItems.filter((_, i) => i !== cIndex) setFieldValue(`variants.${vIndex}.comboItems`, items) }} > ))} )} ) })} { push(defaultVariant()) setVariantImages((prev) => [...prev, []]) }} style={tw`bg-blue-500 px-3 py-2 rounded-lg flex-row items-center justify-center mb-4`} > Add Variant )} { 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' Alert.alert('Check your form', String(message)) return } handleSubmit() }} disabled={isLoading} style={tw`px-4 py-3 rounded-lg shadow-lg items-center mt-4 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`} > {(() => { if (mode === 'edit') { return isLoading ? 'Saving...' : 'Save Changes' } return isLoading ? 'Creating...' : 'Create Product' })()} ) }} ) }) ProductForm.displayName = 'ProductForm' export default ProductForm