312 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|