632 lines
28 KiB
TypeScript
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
|