freshyo/apps/admin-ui/src/components/ProductForm.tsx
2026-09-06 19:27:52 +05:30

632 lines
28 KiB
TypeScript

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<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 (
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}>
<MyTextInput
topLabel="Product Name"
placeholder="Enter product name"
value={values.name}
onChangeText={handleChange('name')}
style={{ marginBottom: 16 }}
/>
<MyTextInput
topLabel="Short Description"
placeholder="Enter short description"
multiline
numberOfLines={2}
value={values.shortDescription}
onChangeText={handleChange('shortDescription')}
style={{ marginBottom: 16 }}
/>
<MyTextInput
topLabel="Long Description"
placeholder="Enter detailed description"
multiline
numberOfLines={4}
value={values.longDescription}
onChangeText={handleChange('longDescription')}
style={{ marginBottom: 16 }}
/>
<BottomDropdown
topLabel="Store"
label="Store"
value={values.storeId}
options={storeOptions}
onValueChange={(value) => setFieldValue('storeId', value)}
placeholder="Select store"
style={{ marginBottom: 16 }}
/>
<BottomDropdown
topLabel="Product Type"
label="Product Type"
value={values.productType}
options={[
{ label: 'Item', value: 'item' },
{ label: 'Combo', value: 'combo' },
]}
onValueChange={(value) => setFieldValue('productType', value)}
placeholder="Select product type"
style={{ marginBottom: 16 }}
/>
<FieldArray name="variants">
{({ push, remove }) => (
<View>
<View style={tw`flex-row justify-between items-center mb-3`}>
<MyText style={tw`text-lg font-bold text-gray-800`}>Variants</MyText>
<TouchableOpacity
onPress={() => {
push(defaultVariant())
setVariantImages((prev) => [...prev, []])
}}
style={tw`bg-blue-500 px-3 py-1 rounded-lg flex-row items-center`}
>
<MaterialIcons name="add" size={18} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Add Variant</MyText>
</TouchableOpacity>
</View>
{values.variants.map((variant, vIndex) => {
const isExistingSku = variant.id != null
const isMarkedDeleted = !!variant.isDeleted
return (
<View key={vIndex} style={[tw`border border-gray-300 rounded-xl p-4 mb-4`, isMarkedDeleted && tw`border-red-300 bg-red-50/50 opacity-80`]}>
<View style={tw`flex-row justify-between items-center mb-3`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`font-bold text-gray-700`}>Variant {vIndex + 1}</MyText>
{isMarkedDeleted && (
<View style={tw`ml-2 px-2 py-0.5 rounded-full bg-red-100 border border-red-200`}>
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Deleted</MyText>
</View>
)}
</View>
{(values.variants.length > 1 || isExistingSku) && (
<TouchableOpacity
onPress={() => {
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))
}
}}
>
<MaterialIcons
name={isMarkedDeleted ? 'restore' : 'delete'}
size={20}
color={isMarkedDeleted ? '#10B981' : '#EF4444'}
/>
</TouchableOpacity>
)}
</View>
<MyTextInput
topLabel="SKU Name (optional)"
placeholder="Overrides the auto-generated name"
value={variant.name ?? ''}
onChangeText={handleChange(`variants.${vIndex}.name`)}
style={{ marginBottom: 12 }}
/>
<FieldArray name={`variants.${vIndex}.features`}>
{({ push: pushAttr, remove: removeAttr }) => (
<View style={tw`mb-3`}>
<View style={tw`flex-row justify-between items-center mb-2`}>
<MyText style={tw`font-medium text-gray-600`}>Attributes</MyText>
<TouchableOpacity
onPress={() => pushAttr(defaultAttribute())}
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`}
>
<MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add</MyText>
</TouchableOpacity>
</View>
{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 (
<View key={aIndex} style={tw`flex-row items-center mb-2 gap-2`}>
<View style={tw`flex-1`}>
<MyTextInput
placeholder="Name"
value={attr.featureName ?? ''}
onChangeText={(text) =>
setFieldValue(
`variants.${vIndex}.features.${aIndex}.featureName`,
text.trim().toLowerCase() === 'quantity' ? 'quantity' : text
)
}
editable={!isQuantityFeature(attr)}
style={isQuantityFeature(attr) ? tw`text-gray-400` : undefined}
/>
</View>
<View style={tw`flex-1`}>
<MyTextInput
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
value={attr.featureValue}
onChangeText={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
/>
</View>
{canDelete && (
<TouchableOpacity onPress={() => removeAttr(aIndex)}>
<MaterialIcons name="close" size={18} color="#EF4444" />
</TouchableOpacity>
)}
</View>
)
})}
<View style={tw`flex-row items-center self-start`}>
<TouchableOpacity
onPress={() => pushAttr(defaultAttribute())}
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`}
>
<MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add Feature</MyText>
</TouchableOpacity>
<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"
style={tw`ml-1`}
/>
</View>
</View>
)}
</FieldArray>
<View style={tw`flex-row gap-2 mb-3`}>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Market Price"
placeholder="MRP"
keyboardType="numeric"
value={variant.marketPrice?.toString() ?? ''}
onChangeText={handleChange(`variants.${vIndex}.marketPrice`)}
/>
</View>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Our Price"
placeholder="Selling price"
keyboardType="numeric"
value={variant.price?.toString() ?? ''}
onChangeText={handleChange(`variants.${vIndex}.price`)}
/>
</View>
</View>
<View style={tw`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)
}}
style={tw`mr-3`}
/>
<MyText style={tw`text-gray-700 font-medium`}>Flash Available</MyText>
</View>
<View style={tw`flex-row items-center mb-3`}>
<Checkbox
checked={variant.isOffer}
onPress={() => setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)}
style={tw`mr-3`}
/>
<MyText style={tw`text-gray-700 font-medium`}>Offer SKU</MyText>
</View>
{!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && (
<View style={tw`flex-row items-center mb-3`}>
<Checkbox
checked={variant.isComboOnly}
onPress={() => setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)}
style={tw`mr-3`}
/>
<MyText style={tw`text-gray-700 font-medium`}>Combo Only SKU</MyText>
</View>
)}
{mode === 'edit' && (
<View style={tw`flex-row items-center mb-3`}>
<Checkbox
checked={variant.isSuspended}
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
style={tw`mr-3`}
/>
<MyText style={tw`text-gray-700 font-medium`}>Suspend SKU</MyText>
</View>
)}
{variant.isFlashAvailable && (
<MyTextInput
topLabel="Flash Price"
placeholder="Enter flash price"
keyboardType="numeric"
value={variant.flashPrice?.toString() ?? ''}
onChangeText={handleChange(`variants.${vIndex}.flashPrice`)}
style={{ marginBottom: 12 }}
/>
)}
<ImageUploaderNeo
images={variantImages[vIndex] || []}
onImageAdd={(payloads) =>
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' && (
<View style={tw`mt-3 pt-3 border-t border-gray-100`}>
<View style={tw`flex-row justify-between items-center mb-2`}>
<MyText style={tw`font-medium text-gray-600`}>Included Items</MyText>
<TouchableOpacity
onPress={() => {
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`}
>
<MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add</MyText>
</TouchableOpacity>
</View>
{variant.comboItems?.map((ci, cIndex) => (
<View key={cIndex} style={tw`flex-row items-center gap-2 mb-2`}>
<View style={tw`flex-1`}>
<BottomDropdown
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) {
Alert.alert('Duplicate', 'This product is already in the combo')
return
}
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val))
}}
placeholder="Select SKU"
/>
</View>
<TouchableOpacity
onPress={() => {
const items = variant.comboItems.filter((_, i) => i !== cIndex)
setFieldValue(`variants.${vIndex}.comboItems`, items)
}}
>
<MaterialIcons name="close" size={18} color="#EF4444" />
</TouchableOpacity>
</View>
))}
</View>
)}
</View>
)
})}
<TouchableOpacity
onPress={() => {
push(defaultVariant())
setVariantImages((prev) => [...prev, []])
}}
style={tw`bg-blue-500 px-3 py-2 rounded-lg flex-row items-center justify-center mb-4`}
>
<MaterialIcons name="add" size={18} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Add Variant</MyText>
</TouchableOpacity>
</View>
)}
</FieldArray>
<TouchableOpacity
onPress={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'
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'}`}
>
<MyText style={tw`text-white text-lg font-bold`}>
{(() => {
if (mode === 'edit') {
return isLoading ? 'Saving...' : 'Save Changes'
}
return isLoading ? 'Creating...' : 'Create Product'
})()}
</MyText>
</TouchableOpacity>
</ScrollView>
)
}}
</Formik>
)
})
ProductForm.displayName = 'ProductForm'
export default ProductForm