freshyo/apps/admin-web/src/components/CouponForm.tsx
2026-09-06 23:05:20 +05:30

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