558 lines
22 KiB
TypeScript
558 lines
22 KiB
TypeScript
import { useState, useEffect, type ChangeEvent } from 'react'
|
|
import { Formik } from 'formik'
|
|
import * as Yup from 'yup'
|
|
import { pInput as PInput, MyButton, AppContainer, Checkbox, BottomDialog, p as P, MyFlatList } from 'web-components'
|
|
import DateTimeInput from './DateTimeInput'
|
|
import { trpc } from '@/lib/trpc-client'
|
|
|
|
// Local copy of common-ui/shared-types CreateCouponPayload (web cannot import
|
|
// the RN common-ui package; @packages/shared does not export this type).
|
|
export interface CreateCouponPayload {
|
|
couponCodes: string[]
|
|
discountPercent?: number
|
|
flatDiscount?: number
|
|
minOrder?: number
|
|
maxValue?: number
|
|
validTill?: string
|
|
maxLimitForUser?: number
|
|
isApplyForAll: boolean
|
|
isUserBased: boolean
|
|
targetUsers?: number[]
|
|
productIds?: number[]
|
|
skuIds?: number[]
|
|
applicableUsers?: number[]
|
|
applicableProducts?: number[]
|
|
exclusiveApply?: boolean
|
|
}
|
|
|
|
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(),
|
|
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,
|
|
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 */}
|
|
<div className="mb-4 flex flex-row items-center">
|
|
<Checkbox
|
|
checked={(values as any).isReservedCoupon || false}
|
|
onPress={() => setFieldValue('isReservedCoupon', !(values as any).isReservedCoupon)}
|
|
/>
|
|
<P className="ml-2 text-sm font-medium text-gray-700">Is Reserved Coupon</P>
|
|
</div>
|
|
|
|
{/* Coupon Codes */}
|
|
<div className="mb-4">
|
|
<P className="text-base font-bold mb-1 block">Coupon Codes</P>
|
|
<P className="text-xs text-gray-500 mb-2 block">
|
|
Each code creates a separate coupon with the same settings.
|
|
</P>
|
|
{(values.couponCodes || []).map((code: string, index: number) => {
|
|
const codeError = Array.isArray(errors.couponCodes)
|
|
? (errors.couponCodes as any)[index]
|
|
: errors.couponCodes
|
|
return (
|
|
<div key={index} className="flex flex-row items-center mb-2">
|
|
<div className="flex-1">
|
|
<PInput
|
|
topLabel={`Code ${index + 1}`}
|
|
placeholder="e.g., SECRET10"
|
|
value={code}
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
const next = [...(values.couponCodes || [])]
|
|
next[index] = e.target.value.toUpperCase()
|
|
setFieldValue('couponCodes', next)
|
|
}}
|
|
error={!!codeError}
|
|
/>
|
|
{codeError && <P className="text-red-500 text-xs mt-1 block">{codeError}</P>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = (values.couponCodes || []).filter((_: string, i: number) => i !== index)
|
|
setFieldValue('couponCodes', next.length > 0 ? next : [''])
|
|
}}
|
|
className="ml-2 p-2 text-red-500 font-bold text-lg"
|
|
aria-label={`Remove code ${index + 1}`}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
{(errors.couponCodes as any)?.message && (
|
|
<P className="text-red-500 text-xs mb-2 block">{(errors.couponCodes as any).message}</P>
|
|
)}
|
|
<div className="mt-2 flex flex-row self-start">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFieldValue('couponCodes', [...(values.couponCodes || []), ''])}
|
|
className="py-2 px-3 bg-blue-50 rounded-lg border border-blue-200 text-blue-600 font-medium"
|
|
>
|
|
+ Add Code
|
|
</button>
|
|
|
|
{patternCode && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setMultiDialogOpen(true)}
|
|
className="ml-2 py-2 px-3 bg-green-50 rounded-lg border border-green-200 text-green-600 font-medium"
|
|
>
|
|
Add Multiple
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Discount Type Selection */}
|
|
<P className="text-base font-bold mb-2 block">
|
|
Discount Type *
|
|
</P>
|
|
|
|
<div className="flex flex-row mb-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setFieldValue('discountPercent', values.discountPercent || 0)
|
|
setFieldValue('flatDiscount', undefined)
|
|
}}
|
|
className={`flex-1 p-3 border rounded-lg mr-2 ${
|
|
values.discountPercent !== undefined ? 'border-blue-500' : 'border-gray-300'
|
|
}`}
|
|
>
|
|
<P className="block text-center">Percentage</P>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setFieldValue('flatDiscount', values.flatDiscount || 0)
|
|
setFieldValue('discountPercent', undefined)
|
|
}}
|
|
className={`flex-1 p-3 border rounded-lg ${
|
|
values.flatDiscount !== undefined ? 'border-blue-500' : 'border-gray-300'
|
|
}`}
|
|
>
|
|
<P className="block text-center">Flat Amount</P>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Discount Value */}
|
|
{values.discountPercent !== undefined && (
|
|
<div className="mb-4">
|
|
<PInput
|
|
topLabel="Discount Percentage *"
|
|
placeholder="e.g., 10"
|
|
value={values.discountPercent?.toString() || ''}
|
|
onChange={(e) => setFieldValue('discountPercent', parseFloat(e.target.value) || 0)}
|
|
type="number"
|
|
inputMode="numeric"
|
|
error={!!(touched.discountPercent && errors.discountPercent)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{values.flatDiscount !== undefined && (
|
|
<div className="mb-4">
|
|
<PInput
|
|
topLabel="Flat Discount Amount *"
|
|
placeholder="e.g., 50"
|
|
value={values.flatDiscount?.toString() || ''}
|
|
onChange={(e) => setFieldValue('flatDiscount', parseFloat(e.target.value) || 0)}
|
|
type="number"
|
|
inputMode="numeric"
|
|
error={!!(touched.flatDiscount && errors.flatDiscount)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Minimum Order */}
|
|
<div className="mb-4">
|
|
<PInput
|
|
topLabel="Minimum Order Amount"
|
|
placeholder="e.g., 100"
|
|
value={values.minOrder?.toString() || ''}
|
|
onChange={(e) => setFieldValue('minOrder', parseFloat(e.target.value) || undefined)}
|
|
type="number"
|
|
inputMode="numeric"
|
|
error={!!(touched.minOrder && errors.minOrder)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Maximum Discount Value - only for percentage discounts */}
|
|
{values.flatDiscount === undefined && (
|
|
<div className="mb-4">
|
|
<PInput
|
|
topLabel="Maximum Discount Value"
|
|
placeholder="e.g., 200"
|
|
value={values.maxValue?.toString() || ''}
|
|
onChange={(e) => setFieldValue('maxValue', parseFloat(e.target.value) || undefined)}
|
|
type="number"
|
|
inputMode="numeric"
|
|
error={!!(touched.maxValue && errors.maxValue)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Validity Period */}
|
|
<div className="mb-4">
|
|
<P className="text-base mb-2 block">Valid Till</P>
|
|
<DateTimeInput
|
|
value={values.validTill ? new Date(values.validTill) : null}
|
|
setValue={(date) => {
|
|
|
|
setFieldValue('validTill', date?.toISOString())
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Usage Limit */}
|
|
<div className="mb-4">
|
|
<PInput
|
|
topLabel="Max Uses Per User"
|
|
placeholder="e.g., 5"
|
|
value={values.maxLimitForUser?.toString() || ''}
|
|
onChange={(e) => setFieldValue('maxLimitForUser', parseInt(e.target.value) || undefined)}
|
|
type="number"
|
|
inputMode="numeric"
|
|
error={!!(touched.maxLimitForUser && errors.maxLimitForUser)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Exclusive Apply */}
|
|
<div className="mb-4">
|
|
<P className="text-base mb-2 block">Exclusive Apply</P>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFieldValue('exclusiveApply', !values.exclusiveApply)}
|
|
className="flex flex-row items-center"
|
|
>
|
|
<div className={`w-5 h-5 border-2 border-gray-300 rounded mr-3 flex items-center justify-center ${values.exclusiveApply ? 'bg-blue-500 border-blue-500' : ''}`}>
|
|
{values.exclusiveApply && <P className="text-white text-center">✓</P>}
|
|
</div>
|
|
<P className="text-gray-700">Exclusive coupon (cannot be combined with other coupons)</P>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Target Audience */}
|
|
<P className={`text-base font-bold mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>
|
|
Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''}
|
|
</P>
|
|
|
|
<div className="flex flex-row mb-4">
|
|
<button
|
|
type="button"
|
|
disabled={isReserved}
|
|
onClick={isReserved ? undefined : () => {
|
|
setFieldValue('isApplyForAll', true)
|
|
setFieldValue('isUserBased', false)
|
|
setFieldValue('targetUsers', [])
|
|
}}
|
|
className={`flex-1 p-3 border rounded-lg mr-2 ${
|
|
values.isApplyForAll ? 'border-blue-500' : 'border-gray-300'
|
|
} ${isReserved ? 'opacity-50' : ''}`}
|
|
>
|
|
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>All Users</P>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={isReserved}
|
|
onClick={isReserved ? undefined : () => {
|
|
setFieldValue('isUserBased', true)
|
|
setFieldValue('isApplyForAll', false)
|
|
}}
|
|
className={`flex-1 p-3 border rounded-lg ${
|
|
values.isUserBased ? 'border-blue-500' : 'border-gray-300'
|
|
} ${isReserved ? 'opacity-50' : ''}`}
|
|
>
|
|
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>Specific User</P>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Applicable User Selection */}
|
|
<div className="mb-4">
|
|
<P className={`text-base mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>Applicable Users (Optional)</P>
|
|
<button
|
|
type="button"
|
|
disabled={isReserved}
|
|
onClick={isReserved ? undefined : () => setUsersDropdownOpen(true)}
|
|
className={`border border-gray-300 rounded p-3 bg-white w-full text-left ${isReserved ? 'opacity-50' : ''}`}
|
|
>
|
|
<P className={isReserved ? 'text-gray-400' : 'text-gray-700'}>
|
|
{values.applicableUsers?.length ? `${values.applicableUsers.length} users selected` : 'Select users...'}
|
|
</P>
|
|
</button>
|
|
</div>
|
|
|
|
<BottomDialog open={usersDropdownOpen} onClose={() => setUsersDropdownOpen(false)}>
|
|
<div className="p-4">
|
|
<P className="text-lg font-semibold mb-4 block">Select Applicable Users</P>
|
|
<PInput
|
|
placeholder="Search users by name or mobile..."
|
|
value={userSearchQuery}
|
|
onChange={(e) => setUserSearchQuery(e.target.value)}
|
|
/>
|
|
<div className="overflow-auto" style={{ maxHeight: 240 }}>
|
|
<MyFlatList
|
|
data={allUsers}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
renderItem={(item) => (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleUserSelection(item.id)}
|
|
className="flex flex-row items-center p-3 border-b border-gray-200 w-full text-left"
|
|
>
|
|
<span onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={values.applicableUsers?.includes(item.id) || false}
|
|
onPress={() => toggleUserSelection(item.id)}
|
|
/>
|
|
</span>
|
|
<P className="ml-3 text-base">{item.mobile} - {item.name}</P>
|
|
</button>
|
|
)}
|
|
ListFooterComponent={
|
|
<>
|
|
{isFetchingUsers ? <P className="text-center p-3 block">Loading...</P> : null}
|
|
{hasMoreUsers && !isFetchingUsers ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setUserOffset(prev => prev + USERS_PAGE_SIZE)}
|
|
className="w-full p-3 text-center text-blue-600 font-medium"
|
|
>
|
|
Load more
|
|
</button>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setUsersDropdownOpen(false)}
|
|
className="mt-4 bg-blue-500 p-3 rounded text-white text-center font-semibold w-full"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</BottomDialog>
|
|
|
|
{/* Add Multiple Coupons Dialog */}
|
|
<BottomDialog open={multiDialogOpen} onClose={() => setMultiDialogOpen(false)}>
|
|
<div className="p-4">
|
|
<P className="text-lg font-semibold mb-1 block">Add Multiple Coupons</P>
|
|
<P className="text-sm text-gray-500 mb-4 block">
|
|
Generate {patternCode ? `${patternPrefix}${patternStart + 1}, ${patternPrefix}${patternStart + 2}, ...` : 'sequential'} codes from your pattern
|
|
</P>
|
|
<PInput
|
|
topLabel="How many?"
|
|
placeholder="e.g., 10"
|
|
value={multiCount}
|
|
onChange={(e) => setMultiCount(e.target.value)}
|
|
type="number"
|
|
inputMode="numeric"
|
|
/>
|
|
<div className="flex flex-row mt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setMultiDialogOpen(false)
|
|
setMultiCount('')
|
|
}}
|
|
className="flex-1 p-3 rounded bg-gray-100 mr-2 text-gray-700 text-center font-semibold"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddMultiple}
|
|
className="flex-1 p-3 rounded bg-green-500 text-white text-center font-semibold"
|
|
>
|
|
Generate
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</BottomDialog>
|
|
|
|
{/* Submit Button */}
|
|
<MyButton
|
|
onClick={() => handleSubmit()}
|
|
disabled={isLoading}
|
|
textContent={isLoading ? 'Creating...' : 'Create Coupon'}
|
|
/>
|
|
</AppContainer>
|
|
)
|
|
}}
|
|
</Formik>
|
|
)
|
|
}
|