freshyo/apps/admin-ui/components/OfferForm.tsx
2026-07-28 01:47:38 +05:30

312 lines
11 KiB
TypeScript

import React, { useState } from 'react'
import { View, Text, TouchableOpacity, Alert, ScrollView } from 'react-native'
import { Formik, FieldArray } from 'formik'
import { MyTextInput, tw, ImageUploaderNeo, Checkbox, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui'
import { trpc } from '../src/trpc-client'
import ProductsSelector from '../components/ProductsSelector'
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore'
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
interface OfferItem {
productId: number
quantity: string
product?: { id: number; name: string; images: string[]; unitShortNotation: string }
}
interface OfferFormProps {
onOfferAdded?: () => void
offerId?: number
initialName?: string
initialShortDescription?: string
initialLongDescription?: string
initialPrice?: string
initialMarketPrice?: string
initialImages?: string[]
initialImageKeys?: string[]
initialIsSuspended?: boolean
initialIsFlashEnabled?: boolean
initialItems?: OfferItem[]
}
export default function OfferForm({
onOfferAdded,
offerId,
initialName = '',
initialShortDescription = '',
initialLongDescription = '',
initialPrice = '',
initialMarketPrice = '',
initialImages = [],
initialImageKeys = [],
initialIsSuspended = false,
initialIsFlashEnabled = false,
initialItems = [],
}: OfferFormProps) {
const isEditMode = !!offerId
const { mutate: createOffer, isPending: isCreating } = trpc.admin.offers.create.useMutation()
const { mutate: updateOffer, isPending: isUpdating } = trpc.admin.offers.update.useMutation()
const { upload, isUploading } = useUploadToObjectStorage()
const isPending = isCreating || isUpdating || isUploading
const [images, setImages] = useState<ImageUploaderNeoItem[]>(
initialImages.map((url) => ({ imgUrl: url, mimeType: 'image/jpeg' }))
)
const initialValues = {
name: initialName,
shortDescription: initialShortDescription,
longDescription: initialLongDescription,
price: initialPrice,
marketPrice: initialMarketPrice,
isFlashEnabled: initialIsFlashEnabled,
items: initialItems.length > 0
? initialItems.map(item => ({ productId: item.productId, quantity: item.quantity }))
: [{ productId: 0, quantity: '1' }],
}
const handleSubmit = async (values: typeof initialValues) => {
if (!values.name.trim()) {
Alert.alert('Error', 'Offer name is required')
return
}
if (!values.price || Number(values.price) <= 0) {
Alert.alert('Error', 'Valid price is required')
return
}
const validItems = values.items.filter(item => item.productId > 0)
if (validItems.length === 0) {
Alert.alert('Error', 'At least one product is required')
return
}
let imageUrls: string[] = []
// Build map of signed URL → raw S3 key for existing images
const signedUrlToKey: Record<string, string> = {}
initialImages.forEach((url, i) => {
if (initialImageKeys[i]) {
signedUrlToKey[url] = initialImageKeys[i]
}
})
// Existing images that are still present → use their raw S3 keys
const keptImageKeys = images
.filter(img => img.imgUrl.startsWith('http'))
.map(img => signedUrlToKey[img.imgUrl])
.filter(Boolean)
// New images (local file:// URIs) → upload to S3
const newImages = images.filter(img => !img.imgUrl.startsWith('http'))
if (newImages.length > 0) {
try {
const blobs = await Promise.all(
newImages.map(async (img) => {
const response = await fetch(img.imgUrl)
const blob = await response.blob()
return { blob, mimeType: img.mimeType || 'image/jpeg' }
})
)
const result = await upload({ images: blobs, contextString: 'product_info' as any })
imageUrls = [...keptImageKeys, ...result.keys]
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to upload images')
return
}
} else {
imageUrls = keptImageKeys
}
const offerData = {
name: values.name.trim(),
shortDescription: values.shortDescription.trim() || undefined,
longDescription: values.longDescription.trim() || undefined,
price: values.price,
marketPrice: values.marketPrice || undefined,
images: imageUrls,
isFlashEnabled: values.isFlashEnabled,
items: validItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
})),
}
if (isEditMode && offerId) {
updateOffer(
{ id: offerId, ...offerData } as any,
{
onSuccess: () => {
Alert.alert('Success', 'Offer updated successfully!')
onOfferAdded?.()
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to update offer')
},
}
)
} else {
createOffer(
offerData as any,
{
onSuccess: () => {
Alert.alert('Success', 'Offer created successfully!')
onOfferAdded?.()
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to create offer')
},
}
)
}
}
return (
<ScrollView style={tw`flex-1 bg-white`} contentContainerStyle={tw`p-4 pb-12`}>
<Text style={tw`text-xl font-bold mb-6 text-center`}>
{isEditMode ? 'Edit Offer' : 'Create New Offer'}
</Text>
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize>
{({ handleSubmit, values, setFieldValue }) => (
<View>
<View style={tw`mb-4`}>
<MyTextInput
testID="offer-name-input"
topLabel="Offer Name *"
placeholder="e.g. Weekend Combo Pack"
value={values.name}
onChangeText={(text) => setFieldValue('name', text)}
/>
</View>
<View style={tw`mb-4`}>
<MyTextInput
testID="offer-short-desc-input"
topLabel="Short Description"
placeholder="Brief description of the offer"
value={values.shortDescription}
onChangeText={(text) => setFieldValue('shortDescription', text)}
multiline
/>
</View>
<View style={tw`mb-4`}>
<MyTextInput
testID="offer-long-desc-input"
topLabel="Long Description"
placeholder="Detailed description"
value={values.longDescription}
onChangeText={(text) => setFieldValue('longDescription', text)}
multiline
/>
</View>
<View style={tw`mb-4`}>
<Text style={tw`text-sm font-medium text-gray-700 mb-2`}>Offer Images</Text>
<ImageUploaderNeo
images={images}
onImageAdd={(newImages) => setImages(prev => [...prev, ...newImages.map((img: ImageUploaderNeoPayload) => ({
imgUrl: img.url,
mimeType: img.mimeType,
}))])}
onImageRemove={(img) => setImages(prev => prev.filter(i => i.imgUrl !== img.url))}
/>
</View>
<View style={tw`mb-4 flex-row gap-2`}>
<View style={tw`flex-1`}>
<MyTextInput
testID="offer-price-input"
topLabel="Price *"
placeholder="₹"
value={values.price}
onChangeText={(text) => setFieldValue('price', text)}
keyboardType="numeric"
/>
</View>
<View style={tw`flex-1`}>
<MyTextInput
testID="offer-market-price-input"
topLabel="Market Price"
placeholder="₹ (optional)"
value={values.marketPrice}
onChangeText={(text) => setFieldValue('marketPrice', text)}
keyboardType="numeric"
/>
</View>
</View>
<View style={tw`flex-row items-center mb-4`}>
<Checkbox
checked={values.isFlashEnabled}
onPress={() => setFieldValue('isFlashEnabled', !values.isFlashEnabled)}
/>
<Text style={tw`text-sm font-medium text-gray-700 ml-2`}>Flash Enabled</Text>
</View>
<View style={tw`mb-4`}>
<Text style={tw`text-lg font-semibold mb-3`}>Products in Offer</Text>
<FieldArray name="items">
{({ push, remove }) => (
<View>
{values.items.map((item, index) => (
<View key={index} style={tw`bg-gray-50 p-4 rounded-xl mb-3 border border-gray-100`}>
<View style={tw`flex-row items-center justify-between mb-3`}>
<Text style={tw`text-sm font-bold text-gray-700`}>Product {index + 1}</Text>
{values.items.length > 1 && (
<TouchableOpacity onPress={() => remove(index)}>
<MaterialIcons name="close" size={20} color="#EF4444" />
</TouchableOpacity>
)}
</View>
<View style={tw`mb-3`}>
<ProductsSelector
value={item.productId || []}
onChange={(value) => setFieldValue(`items.${index}.productId`, Array.isArray(value) ? value[0] : value)}
multiple={false}
placeholder="Select a product"
/>
</View>
<MyTextInput
topLabel="Quantity"
placeholder="e.g. 1"
value={item.quantity}
onChangeText={(text) => setFieldValue(`items.${index}.quantity`, text)}
keyboardType="numeric"
/>
</View>
))}
<TouchableOpacity
onPress={() => push({ productId: 0, quantity: '1' })}
style={tw`bg-blue-500 px-4 py-3 rounded-xl items-center mb-4`}
>
<Text style={tw`text-white font-medium`}>Add Product</Text>
</TouchableOpacity>
</View>
)}
</FieldArray>
</View>
<TouchableOpacity
testID="offer-create-button"
accessibilityLabel="offer-create-button"
onPress={() => handleSubmit()}
disabled={isPending}
style={tw`${isPending ? 'bg-pink-300' : 'bg-pink-500'} p-4 rounded-xl items-center mt-4`}
>
<Text style={tw`text-white text-base font-bold`}>
{isPending ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Offer' : 'Create Offer')}
</Text>
</TouchableOpacity>
</View>
)}
</Formik>
</ScrollView>
)
}