freshyo/apps/admin-ui/src/components/CouponForm.tsx
2026-01-24 00:13:15 +05:30

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>
);
}