262 lines
No EOL
8.9 KiB
TypeScript
262 lines
No EOL
8.9 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { View, ScrollView, TouchableOpacity, Alert } from 'react-native';
|
|
import { Formik, FormikHelpers } from 'formik';
|
|
import * as Yup from 'yup';
|
|
import { MyText, tw, MyTextInput, MyTouchableOpacity, ImageUploader, BottomDropdown } from 'common-ui';
|
|
import { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
|
|
import { trpc } from '../src/trpc-client';
|
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
|
|
export interface BannerFormData {
|
|
name: string;
|
|
imageUrl: string;
|
|
description: string;
|
|
productIds: number[];
|
|
redirectUrl: string;
|
|
// serialNum removed - will be assigned automatically by backend
|
|
}
|
|
|
|
interface BannerFormProps {
|
|
initialValues: BannerFormData;
|
|
onSubmit: (values: BannerFormData, imageUrl?: string) => Promise<void> | void;
|
|
onCancel: () => void;
|
|
submitButtonText?: string;
|
|
isSubmitting?: boolean;
|
|
existingImageUrl?: string;
|
|
mode?: 'create' | 'edit';
|
|
}
|
|
|
|
const validationSchema = Yup.object().shape({
|
|
name: Yup.string().trim().required('Banner name is required').max(255),
|
|
description: Yup.string().max(500),
|
|
productIds: Yup.array()
|
|
.of(Yup.number())
|
|
.optional(),
|
|
redirectUrl: Yup.string()
|
|
.url('Please enter a valid URL')
|
|
.optional(),
|
|
// serialNum validation removed - assigned automatically by backend
|
|
});
|
|
|
|
export default function BannerForm({
|
|
initialValues,
|
|
onSubmit,
|
|
onCancel,
|
|
submitButtonText = 'Create Banner',
|
|
isSubmitting = false,
|
|
existingImageUrl,
|
|
mode = 'create',
|
|
}: BannerFormProps) {
|
|
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
|
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
|
|
|
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
|
|
|
// Fetch products for dropdown
|
|
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
|
const products = productsData?.products || [];
|
|
|
|
// Create product options for dropdown
|
|
const productOptions: DropdownOption[] = products.map(product => ({
|
|
label: `${product.name} (${product.price})`,
|
|
value: product.id,
|
|
}));
|
|
|
|
|
|
const handleImagePick = usePickImage({
|
|
setFile: async (assets: any) => {
|
|
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
|
setSelectedImages([]);
|
|
setDisplayImages([]);
|
|
return;
|
|
}
|
|
|
|
const files = Array.isArray(assets) ? assets : [assets];
|
|
const blobPromises = files.map(async (asset) => {
|
|
const response = await fetch(asset.uri);
|
|
const blob = await response.blob();
|
|
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
|
|
});
|
|
|
|
const blobArray = await Promise.all(blobPromises);
|
|
setSelectedImages(blobArray);
|
|
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
|
|
},
|
|
multiple: false, // Single image for banners
|
|
});
|
|
|
|
const handleRemoveImage = (uri: string) => {
|
|
const index = displayImages.findIndex(img => img.uri === uri);
|
|
if (index !== -1) {
|
|
const newDisplay = displayImages.filter((_, i) => i !== index);
|
|
const newFiles = selectedImages.filter((_, i) => i !== index);
|
|
setDisplayImages(newDisplay);
|
|
setSelectedImages(newFiles);
|
|
}
|
|
};
|
|
const handleFormikSubmit = async (values: BannerFormData) => {
|
|
try {
|
|
let imageUrl: string | undefined;
|
|
|
|
if (selectedImages.length > 0) {
|
|
// Generate upload URLs
|
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
|
contextString: 'store', // Using 'store' for now
|
|
mimeTypes,
|
|
});
|
|
|
|
// Upload image
|
|
const uploadUrl = uploadUrls[0];
|
|
const { blob, mimeType } = selectedImages[0];
|
|
|
|
const uploadResponse = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
body: blob,
|
|
headers: {
|
|
'Content-Type': mimeType,
|
|
},
|
|
});
|
|
|
|
if (!uploadResponse.ok) {
|
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
}
|
|
|
|
imageUrl = uploadUrl;
|
|
}
|
|
|
|
// Call onSubmit with form values and imageUrl
|
|
await onSubmit(values, imageUrl);
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
Alert.alert('Error', 'Failed to upload image');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Formik
|
|
initialValues={initialValues}
|
|
validationSchema={validationSchema}
|
|
onSubmit={handleFormikSubmit}
|
|
>
|
|
{({
|
|
handleChange,
|
|
handleBlur,
|
|
handleSubmit,
|
|
values,
|
|
errors,
|
|
touched,
|
|
isValid,
|
|
dirty,
|
|
setFieldValue,
|
|
}) => (
|
|
<View style={{ flex: 1 }}>
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{ paddingHorizontal: 24, paddingVertical: 24 }}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<MyTextInput
|
|
topLabel="Banner Name"
|
|
placeholder="Enter banner name"
|
|
value={values.name}
|
|
onChangeText={handleChange('name')}
|
|
onBlur={handleBlur('name')}
|
|
style={{ marginBottom: errors.name && touched.name ? 8 : 16 }}
|
|
/>
|
|
{errors.name && touched.name && (
|
|
<MyText style={tw`text-red-500 text-xs mb-2 -mt-2`}>{errors.name}</MyText>
|
|
)}
|
|
|
|
<View style={{ marginBottom: 16 }}>
|
|
<MyText style={{ fontSize: 14, fontWeight: 'bold', color: '#374151', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 0.05 }}>Banner Image</MyText>
|
|
<ImageUploader
|
|
images={displayImages}
|
|
existingImageUrls={existingImageUrl ? [existingImageUrl] : []}
|
|
onAddImage={handleImagePick}
|
|
onRemoveImage={handleRemoveImage}
|
|
onRemoveExistingImage={() => {
|
|
// Handle removing existing image in edit mode
|
|
}}
|
|
allowMultiple={false}
|
|
/>
|
|
</View>
|
|
|
|
<MyTextInput
|
|
topLabel="Description"
|
|
placeholder="Enter banner description (optional)"
|
|
value={values.description}
|
|
onChangeText={handleChange('description')}
|
|
onBlur={handleBlur('description')}
|
|
multiline
|
|
numberOfLines={3}
|
|
textAlignVertical="top"
|
|
style={{ marginBottom: errors.description && touched.description ? 8 : 16 }}
|
|
/>
|
|
{errors.description && touched.description && (
|
|
<MyText style={tw`text-red-500 text-xs mb-2 -mt-2`}>{errors.description}</MyText>
|
|
)}
|
|
|
|
<View style={{ marginBottom: 16 }}>
|
|
<BottomDropdown
|
|
label="Select Products"
|
|
options={productOptions}
|
|
value={values.productIds}
|
|
onValueChange={(value) => {
|
|
const selectedValues = Array.isArray(value) ? value : [value];
|
|
setFieldValue('productIds', selectedValues.map(v => Number(v)));
|
|
}}
|
|
placeholder="Select products for banner (optional)"
|
|
multiple={true}
|
|
/>
|
|
</View>
|
|
|
|
<MyTextInput
|
|
topLabel="Redirect URL (Optional)"
|
|
placeholder="https://example.com/redirect"
|
|
value={values.redirectUrl}
|
|
onChangeText={handleChange('redirectUrl')}
|
|
onBlur={handleBlur('redirectUrl')}
|
|
keyboardType="url"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
style={{ marginBottom: errors.redirectUrl && touched.redirectUrl ? 8 : 16 }}
|
|
/>
|
|
{errors.redirectUrl && touched.redirectUrl && (
|
|
<MyText style={tw`text-red-500 text-xs mb-2 -mt-2`}>{errors.redirectUrl}</MyText>
|
|
)}
|
|
|
|
|
|
|
|
{/* Action Buttons */}
|
|
<View style={tw`flex-row gap-4 mb-8`}>
|
|
<MyTouchableOpacity
|
|
onPress={onCancel}
|
|
disabled={isSubmitting}
|
|
style={tw`flex-1 bg-gray-100 rounded-lg py-4 items-center`}
|
|
>
|
|
<MyText style={tw`text-gray-700 font-semibold`}>Cancel</MyText>
|
|
</MyTouchableOpacity>
|
|
|
|
<MyTouchableOpacity
|
|
onPress={() => handleSubmit()}
|
|
disabled={isSubmitting || !isValid || !dirty}
|
|
style={tw`flex-1 rounded-lg py-4 items-center ${
|
|
isSubmitting || !isValid || !dirty
|
|
? 'bg-blue-400'
|
|
: 'bg-blue-600'
|
|
}`}
|
|
>
|
|
<MyText style={tw`text-white font-semibold`}>
|
|
{isSubmitting ? 'Saving...' : submitButtonText}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
</Formik>
|
|
);
|
|
} |