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

535 lines
No EOL
22 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 CouponFormValues extends CreateCouponPayload {
isReservedCoupon?: boolean;
couponCodes: string[];
skuIds?: number[];
}
interface CouponFormProps {
onSubmit: (values: CouponFormValues) => void;
isLoading: boolean;
initialValues?: Partial<CouponFormValues>;
}
const couponValidationSchema = Yup.object().shape({
isReservedCoupon: Yup.boolean().optional(),
couponCodes: Yup.array().of(
Yup.string()
.required('Code is required')
.min(3, 'Code must be at least 3 characters')
.max(50, 'Code cannot exceed 50 characters')
.matches(/^[A-Z0-9_-]+$/, '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(),
isFirstOrderOnly: 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;
}).test('codes-required', 'At least one coupon code is required', function(value: any) {
const codes = (value.couponCodes || []).filter((c: string) => c && c.trim());
if (codes.length === 0) {
return this.createError({ path: 'couponCodes', message: 'Add at least one coupon code' });
}
return true;
}).test('codes-unique', 'Coupon codes must be unique', function(value: any) {
const codes = (value.couponCodes || []).map((c: string) => c.trim()).filter(Boolean);
if (new Set(codes).size !== codes.length) {
return this.createError({ path: 'couponCodes', message: 'Coupon codes must be unique' });
}
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);
// Add-multiple dialog states (for pattern codes like ABC12)
const [multiDialogOpen, setMultiDialogOpen] = useState(false);
const [multiCount, setMultiCount] = useState('');
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: CouponFormValues = {
couponCodes: [''],
isUserBased: false,
isApplyForAll: false,
targetUsers: [],
discountPercent: undefined,
flatDiscount: undefined,
minOrder: undefined,
maxValue: undefined,
validTill: undefined,
maxLimitForUser: undefined,
skuIds: undefined,
applicableUsers: [],
applicableProducts: [],
exclusiveApply: false,
isFirstOrderOnly: false,
isReservedCoupon: false,
};
return (
<Formik<CouponFormValues>
initialValues={(initialValues || defaultValues) as CouponFormValues}
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];
setFieldValue('applicableUsers', newSelection);
};
const isReserved = (values as any).isReservedCoupon;
// Detect pattern codes like ABC12 (ends in digits) for bulk generation.
// Continue numbering from the highest code with the same prefix.
const patternCode = (values.couponCodes || []).find(
(c: string) => /^[A-Z0-9_-]*\d+$/.test(c.trim())
);
const patternPrefix = patternCode ? patternCode.replace(/\d+$/, '') : '';
const patternStart = (values.couponCodes || []).reduce((max, c) => {
const trimmed = c.trim();
if (!patternPrefix || !trimmed.startsWith(patternPrefix)) return max;
const m = trimmed.match(/\d+$/);
if (!m) return max;
return Math.max(max, parseInt(m[0], 10));
}, patternCode ? parseInt((patternCode.match(/\d+$/) || ['0'])[0], 10) : 0);
const handleAddMultiple = () => {
const count = parseInt(multiCount, 10);
if (!count || count < 1 || count > 100) return;
// Start from the next number after the highest existing code
const newCodes = Array.from(
{ length: count },
(_, i) => `${patternPrefix}${patternStart + 1 + i}`
);
const existing = (values.couponCodes || []).filter((c: string) => c && c.trim());
setFieldValue('couponCodes', [...existing, ...newCodes]);
setMultiDialogOpen(false);
setMultiCount('');
};
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 Codes */}
<View style={tw`mb-4`}>
<Text style={tw`text-base font-bold mb-1`}>Coupon Codes</Text>
<Text style={tw`text-xs text-gray-500 mb-2`}>
Each code creates a separate coupon with the same settings.
</Text>
{(values.couponCodes || []).map((code: string, index: number) => {
const codeError = Array.isArray(errors.couponCodes)
? (errors.couponCodes as any)[index]
: errors.couponCodes;
return (
<View key={index} style={tw`flex-row items-center mb-2`}>
<View style={tw`flex-1`}>
<MyTextInput
topLabel={`Code ${index + 1}`}
placeholder="e.g., SECRET10"
value={code}
onChangeText={(text: string) => {
const next = [...(values.couponCodes || [])];
next[index] = text.toUpperCase();
setFieldValue('couponCodes', next);
}}
keyboardType="default"
autoCapitalize="characters"
error={!!codeError}
/>
{codeError && <Text style={tw`text-red-500 text-xs mt-1`}>{codeError}</Text>}
</View>
<TouchableOpacity
onPress={() => {
const next = (values.couponCodes || []).filter((_: string, i: number) => i !== index);
setFieldValue('couponCodes', next.length > 0 ? next : ['']);
}}
style={tw`ml-2 p-2`}
>
<Text style={tw`text-red-500 font-bold text-lg`}></Text>
</TouchableOpacity>
</View>
);
})}
{(errors.couponCodes as any)?.message && (
<Text style={tw`text-red-500 text-xs mb-2`}>{(errors.couponCodes as any).message}</Text>
)}
<View style={tw`mt-2 flex-row self-start`}>
<TouchableOpacity
onPress={() => setFieldValue('couponCodes', [...(values.couponCodes || []), ''])}
style={tw`py-2 px-3 bg-blue-50 rounded-lg border border-blue-200`}
>
<Text style={tw`text-blue-600 font-medium`}>+ Add Code</Text>
</TouchableOpacity>
{patternCode && (
<TouchableOpacity
onPress={() => setMultiDialogOpen(true)}
style={tw`ml-2 py-2 px-3 bg-green-50 rounded-lg border border-green-200`}
>
<Text style={tw`text-green-600 font-medium`}>Add Multiple</Text>
</TouchableOpacity>
)}
</View>
</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 - only for percentage discounts */}
{values.flatDiscount === undefined && (
<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>
{/* First Order Only */}
<View style={tw`mb-4`}>
<Text style={tw`text-base mb-2`}>First Order Only</Text>
<TouchableOpacity
onPress={() => setFieldValue('isFirstOrderOnly', !values.isFirstOrderOnly)}
style={tw`flex-row items-center`}
>
<View style={tw`w-5 h-5 border-2 border-gray-300 rounded mr-3 ${values.isFirstOrderOnly ? 'bg-blue-500 border-blue-500' : ''}`}>
{values.isFirstOrderOnly && <Text style={tw`text-white text-center`}></Text>}
</View>
<Text style={tw`text-gray-700`}>Valid only on the customer&apos;s first order</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>
{/* Add Multiple Coupons Dialog */}
<BottomDialog open={multiDialogOpen} onClose={() => setMultiDialogOpen(false)}>
<View style={tw`p-4`}>
<Text style={tw`text-lg font-semibold mb-1`}>Add Multiple Coupons</Text>
<Text style={tw`text-sm text-gray-500 mb-4`}>
Generate {patternCode ? `${patternPrefix}${patternStart + 1}, ${patternPrefix}${patternStart + 2}, ...` : 'sequential'} codes from your pattern
</Text>
<MyTextInput
topLabel="How many?"
placeholder="e.g., 10"
value={multiCount}
onChangeText={setMultiCount}
keyboardType="numeric"
/>
<View style={tw`flex-row mt-4`}>
<TouchableOpacity
onPress={() => {
setMultiDialogOpen(false);
setMultiCount('');
}}
style={tw`flex-1 p-3 rounded bg-gray-100 mr-2`}
>
<Text style={tw`text-gray-700 text-center font-semibold`}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleAddMultiple}
style={tw`flex-1 p-3 rounded bg-green-500`}
>
<Text style={tw`text-white text-center font-semibold`}>Generate</Text>
</TouchableOpacity>
</View>
</View>
</BottomDialog>
{/* Submit Button */}
<MyButton
onPress={() => handleSubmit()}
loading={isLoading}
disabled={isLoading}
>
Create Coupon
</MyButton>
</AppContainer>
);
}}
</Formik>
);
}