freshyo/apps/admin-web/src/components/ProductForm.tsx
2026-09-12 09:06:33 +05:30

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