385 lines
No EOL
15 KiB
TypeScript
385 lines
No EOL
15 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { View, Text, TouchableOpacity, FlatList } from 'react-native';
|
|
import { Formik } from 'formik';
|
|
import * as Yup from 'yup';
|
|
import { MyTextInput, MyButton, tw, AppContainer , DateTimePickerMod, Checkbox, BottomDialog, MyText } from 'common-ui';
|
|
import { trpc } from '../trpc-client';
|
|
|
|
import { CreateCouponPayload } from 'common-ui/shared-types';
|
|
|
|
const USERS_PAGE_SIZE = 10;
|
|
|
|
interface CouponFormProps {
|
|
onSubmit: (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => void;
|
|
isLoading: boolean;
|
|
initialValues?: Partial<CreateCouponPayload & { isReservedCoupon?: boolean }>;
|
|
}
|
|
|
|
const couponValidationSchema = Yup.object().shape({
|
|
isReservedCoupon: Yup.boolean().optional(),
|
|
couponCode: Yup.string()
|
|
.required('Coupon code is required')
|
|
.min(3, 'Coupon code must be at least 3 characters')
|
|
.max(50, 'Coupon code cannot exceed 50 characters')
|
|
.matches(/^[A-Z0-9_-]+$/, 'Coupon code can only contain uppercase letters, numbers, underscores, and hyphens'),
|
|
discountPercent: Yup.number()
|
|
.min(0, 'Must be positive')
|
|
.max(100, 'Cannot exceed 100%')
|
|
.optional(),
|
|
flatDiscount: Yup.number()
|
|
.min(0, 'Must be positive')
|
|
.optional(),
|
|
minOrder: Yup.number().min(0, 'Must be positive').optional(),
|
|
maxValue: Yup.number().min(0, 'Must be positive').optional(),
|
|
validTill: Yup.date().optional(),
|
|
maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(),
|
|
exclusiveApply: Yup.boolean().optional(),
|
|
isUserBased: Yup.boolean(),
|
|
isApplyForAll: Yup.boolean(),
|
|
applicableUsers: Yup.array().of(Yup.number()).optional(),
|
|
}).test('discount-validation', 'Must provide exactly one discount type with valid value', function(value) {
|
|
const { discountPercent, flatDiscount } = value;
|
|
const hasPercent = discountPercent !== undefined && discountPercent > 0;
|
|
const hasFlat = flatDiscount !== undefined && flatDiscount > 0;
|
|
|
|
if (hasPercent && hasFlat) {
|
|
return this.createError({ message: 'Cannot have both percentage and flat discount' });
|
|
}
|
|
if (!hasPercent && !hasFlat) {
|
|
return this.createError({ message: 'Must provide either percentage or flat discount' });
|
|
}
|
|
return true;
|
|
});
|
|
|
|
export default function CouponForm({ onSubmit, isLoading, initialValues }: CouponFormProps) {
|
|
|
|
// User dropdown states
|
|
const [userSearchQuery, setUserSearchQuery] = useState('');
|
|
const [userOffset, setUserOffset] = useState(0);
|
|
const [allUsers, setAllUsers] = useState<{ id: number; name: string; mobile: string | null }[]>([]);
|
|
const [hasMoreUsers, setHasMoreUsers] = useState(true);
|
|
const [usersDropdownOpen, setUsersDropdownOpen] = useState(false);
|
|
|
|
const { data: usersData, isFetching: isFetchingUsers } = trpc.admin.coupon.getUsersMiniInfo.useQuery(
|
|
{ search: userSearchQuery, limit: USERS_PAGE_SIZE, offset: userOffset },
|
|
{ enabled: usersDropdownOpen }
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (usersData?.users) {
|
|
if (userOffset === 0) {
|
|
setAllUsers(usersData.users);
|
|
} else {
|
|
setAllUsers(prev => [...prev, ...usersData.users]);
|
|
}
|
|
setHasMoreUsers(usersData.users.length === USERS_PAGE_SIZE);
|
|
}
|
|
}, [usersData, userOffset]);
|
|
|
|
useEffect(() => {
|
|
setUserOffset(0);
|
|
setHasMoreUsers(true);
|
|
}, [userSearchQuery]);
|
|
|
|
useEffect(() => {
|
|
if (usersDropdownOpen) {
|
|
setUserOffset(0);
|
|
setAllUsers([]);
|
|
setHasMoreUsers(true);
|
|
setUserSearchQuery('');
|
|
}
|
|
}, [usersDropdownOpen]);
|
|
|
|
|
|
|
|
// User search functionality will be inside Formik
|
|
|
|
const defaultValues: CreateCouponPayload & { isReservedCoupon?: boolean } = {
|
|
couponCode: '',
|
|
isUserBased: false,
|
|
isApplyForAll: false,
|
|
targetUsers: [],
|
|
discountPercent: undefined,
|
|
flatDiscount: undefined,
|
|
minOrder: undefined,
|
|
maxValue: undefined,
|
|
validTill: undefined,
|
|
maxLimitForUser: undefined,
|
|
productIds: undefined,
|
|
applicableUsers: [],
|
|
applicableProducts: [],
|
|
exclusiveApply: false,
|
|
isReservedCoupon: false,
|
|
};
|
|
|
|
return (
|
|
<Formik
|
|
initialValues={(initialValues || defaultValues) as CreateCouponPayload}
|
|
validationSchema={couponValidationSchema}
|
|
onSubmit={onSubmit}
|
|
>
|
|
{({ values, errors, touched, setFieldValue, handleSubmit }) => {
|
|
|
|
|
|
const toggleUserSelection = (userId: number) => {
|
|
const current = values.applicableUsers || [];
|
|
const newSelection = current.includes(userId)
|
|
? current.filter(id => id !== userId)
|
|
: [...current, userId];
|
|
console.log('Toggling user:', userId, 'New selection:', newSelection);
|
|
setFieldValue('applicableUsers', newSelection);
|
|
};
|
|
|
|
const isReserved = (values as any).isReservedCoupon;
|
|
|
|
return (
|
|
<AppContainer>
|
|
{/* Is Reserved Coupon Checkbox */}
|
|
<View style={tw`mb-4 flex-row items-center`}>
|
|
<Checkbox
|
|
checked={(values as any).isReservedCoupon || false}
|
|
onPress={() => setFieldValue('isReservedCoupon', !(values as any).isReservedCoupon)}
|
|
/>
|
|
<MyText style={tw`ml-2 text-sm font-medium text-gray-700`}>Is Reserved Coupon</MyText>
|
|
</View>
|
|
|
|
{/* Coupon Code */}
|
|
<View style={tw`mb-4`}>
|
|
<MyTextInput
|
|
topLabel="Coupon Code"
|
|
placeholder="e.g., SAVE20"
|
|
value={values.couponCode || ''}
|
|
onChangeText={(text: string) => setFieldValue('couponCode', text.toUpperCase())}
|
|
keyboardType="default"
|
|
autoCapitalize="characters"
|
|
error={!!(touched.couponCode && errors.couponCode)}
|
|
/>
|
|
</View>
|
|
|
|
{/* Discount Type Selection */}
|
|
<Text style={tw`text-base font-bold mb-2`}>
|
|
Discount Type *
|
|
</Text>
|
|
|
|
<View style={tw`flex-row mb-4`}>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
setFieldValue('discountPercent', values.discountPercent || 0);
|
|
setFieldValue('flatDiscount', undefined);
|
|
}}
|
|
style={tw`flex-1 p-3 border rounded-lg mr-2 ${
|
|
values.discountPercent !== undefined ? 'border-blue-500' : 'border-gray-300'
|
|
}`}
|
|
>
|
|
<Text style={{ textAlign: 'center' }}>Percentage</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
setFieldValue('flatDiscount', values.flatDiscount || 0);
|
|
setFieldValue('discountPercent', undefined);
|
|
}}
|
|
style={tw`flex-1 p-3 border rounded-lg ${
|
|
values.flatDiscount !== undefined ? 'border-blue-500' : 'border-gray-300'
|
|
}`}
|
|
>
|
|
<Text style={{ textAlign: 'center' }}>Flat Amount</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Discount Value */}
|
|
{values.discountPercent !== undefined && (
|
|
<View style={tw`mb-4`}>
|
|
<MyTextInput
|
|
topLabel="Discount Percentage *"
|
|
placeholder="e.g., 10"
|
|
value={values.discountPercent?.toString() || ''}
|
|
onChangeText={(text: string) => setFieldValue('discountPercent', parseFloat(text) || 0)}
|
|
keyboardType="numeric"
|
|
error={!!(touched.discountPercent && errors.discountPercent)}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{values.flatDiscount !== undefined && (
|
|
<View style={tw`mb-4`}>
|
|
<MyTextInput
|
|
topLabel="Flat Discount Amount *"
|
|
placeholder="e.g., 50"
|
|
value={values.flatDiscount?.toString() || ''}
|
|
onChangeText={(text: string) => setFieldValue('flatDiscount', parseFloat(text) || 0)}
|
|
keyboardType="numeric"
|
|
error={!!(touched.flatDiscount && errors.flatDiscount)}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Minimum Order */}
|
|
<View style={tw`mb-4`}>
|
|
<MyTextInput
|
|
topLabel="Minimum Order Amount"
|
|
placeholder="e.g., 100"
|
|
value={values.minOrder?.toString() || ''}
|
|
onChangeText={(text: string) => setFieldValue('minOrder', parseFloat(text) || undefined)}
|
|
keyboardType="numeric"
|
|
error={!!(touched.minOrder && errors.minOrder)}
|
|
/>
|
|
</View>
|
|
|
|
{/* Maximum Discount Value */}
|
|
<View style={tw`mb-4`}>
|
|
<MyTextInput
|
|
topLabel="Maximum Discount Value"
|
|
placeholder="e.g., 200"
|
|
value={values.maxValue?.toString() || ''}
|
|
onChangeText={(text: string) => setFieldValue('maxValue', parseFloat(text) || undefined)}
|
|
keyboardType="numeric"
|
|
error={!!(touched.maxValue && errors.maxValue)}
|
|
/>
|
|
</View>
|
|
|
|
{/* Validity Period */}
|
|
<View style={tw`mb-4`}>
|
|
<Text style={tw`text-base mb-2`}>Valid Till</Text>
|
|
<DateTimePickerMod
|
|
value={values.validTill ? new Date(values.validTill) : null}
|
|
setValue={(date) => {
|
|
|
|
setFieldValue('validTill', date?.toISOString())
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Usage Limit */}
|
|
<View style={tw`mb-4`}>
|
|
<MyTextInput
|
|
topLabel="Max Uses Per User"
|
|
placeholder="e.g., 5"
|
|
value={values.maxLimitForUser?.toString() || ''}
|
|
onChangeText={(text: string) => setFieldValue('maxLimitForUser', parseInt(text) || undefined)}
|
|
keyboardType="numeric"
|
|
error={!!(touched.maxLimitForUser && errors.maxLimitForUser)}
|
|
/>
|
|
</View>
|
|
|
|
{/* Exclusive Apply */}
|
|
<View style={tw`mb-4`}>
|
|
<Text style={tw`text-base mb-2`}>Exclusive Apply</Text>
|
|
<TouchableOpacity
|
|
onPress={() => setFieldValue('exclusiveApply', !values.exclusiveApply)}
|
|
style={tw`flex-row items-center`}
|
|
>
|
|
<View style={tw`w-5 h-5 border-2 border-gray-300 rounded mr-3 ${values.exclusiveApply ? 'bg-blue-500 border-blue-500' : ''}`}>
|
|
{values.exclusiveApply && <Text style={tw`text-white text-center`}>✓</Text>}
|
|
</View>
|
|
<Text style={tw`text-gray-700`}>Exclusive coupon (cannot be combined with other coupons)</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Target Audience */}
|
|
<Text style={tw`text-base font-bold mb-2 ${isReserved ? 'text-gray-400' : ''}`}>
|
|
Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''}
|
|
</Text>
|
|
|
|
<View style={tw`flex-row mb-4`}>
|
|
<TouchableOpacity
|
|
disabled={isReserved}
|
|
onPress={isReserved ? undefined : () => {
|
|
setFieldValue('isApplyForAll', true);
|
|
setFieldValue('isUserBased', false);
|
|
setFieldValue('targetUsers', []);
|
|
}}
|
|
style={tw`flex-1 p-3 border rounded-lg mr-2 ${
|
|
values.isApplyForAll ? 'border-blue-500' : 'border-gray-300'
|
|
} ${isReserved ? 'opacity-50' : ''}`}
|
|
>
|
|
<Text style={{ textAlign: 'center', color: isReserved ? '#9CA3AF' : '#000' }}>All Users</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
disabled={isReserved}
|
|
onPress={isReserved ? undefined : () => {
|
|
setFieldValue('isUserBased', true);
|
|
setFieldValue('isApplyForAll', false);
|
|
}}
|
|
style={tw`flex-1 p-3 border rounded-lg ${
|
|
values.isUserBased ? 'border-blue-500' : 'border-gray-300'
|
|
} ${isReserved ? 'opacity-50' : ''}`}
|
|
>
|
|
<Text style={{ textAlign: 'center', color: isReserved ? '#9CA3AF' : '#000' }}>Specific User</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Applicable User Selection */}
|
|
<View style={tw`mb-4`}>
|
|
<Text style={tw`text-base mb-2 ${isReserved ? 'text-gray-400' : ''}`}>Applicable Users (Optional)</Text>
|
|
<TouchableOpacity
|
|
disabled={isReserved}
|
|
onPress={isReserved ? undefined : () => setUsersDropdownOpen(true)}
|
|
style={tw`border border-gray-300 rounded p-3 bg-white ${isReserved ? 'opacity-50' : ''}`}
|
|
>
|
|
<Text style={tw`text-gray-700 ${isReserved ? 'text-gray-400' : ''}`}>
|
|
{values.applicableUsers?.length ? `${values.applicableUsers.length} users selected` : 'Select users...'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<BottomDialog open={usersDropdownOpen} onClose={() => setUsersDropdownOpen(false)}>
|
|
<View style={tw`p-4`}>
|
|
<Text style={tw`text-lg font-semibold mb-4`}>Select Applicable Users</Text>
|
|
<MyTextInput
|
|
placeholder="Search users by name or mobile..."
|
|
value={userSearchQuery}
|
|
onChangeText={setUserSearchQuery}
|
|
/>
|
|
<FlatList
|
|
data={allUsers}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
renderItem={({ item }) => (
|
|
<TouchableOpacity
|
|
onPress={() => toggleUserSelection(item.id)}
|
|
style={tw`flex-row items-center p-3 border-b border-gray-200`}
|
|
>
|
|
<Checkbox
|
|
checked={values.applicableUsers?.includes(item.id) || false}
|
|
onPress={() => toggleUserSelection(item.id)}
|
|
/>
|
|
<Text style={tw`ml-3 text-base`}>{item.mobile} - {item.name}</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
onEndReached={() => {
|
|
if (hasMoreUsers && !isFetchingUsers) {
|
|
setUserOffset(prev => prev + USERS_PAGE_SIZE);
|
|
}
|
|
}}
|
|
onEndReachedThreshold={0.5}
|
|
ListFooterComponent={isFetchingUsers ? <Text style={tw`text-center p-3`}>Loading...</Text> : null}
|
|
style={tw`max-h-60`}
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={() => setUsersDropdownOpen(false)}
|
|
style={tw`mt-4 bg-blue-500 p-3 rounded`}
|
|
>
|
|
<Text style={tw`text-white text-center font-semibold`}>Done</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</BottomDialog>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Submit Button */}
|
|
<MyButton
|
|
onPress={() => handleSubmit()}
|
|
loading={isLoading}
|
|
disabled={isLoading}
|
|
>
|
|
Create Coupon
|
|
</MyButton>
|
|
</AppContainer>
|
|
);
|
|
}}
|
|
</Formik>
|
|
);
|
|
} |