diff --git a/apps/admin-ui/app/(drawer)/dashboard/complaints/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/complaints/index.tsx index 1fc927e..c02924b 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/complaints/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/complaints/index.tsx @@ -117,6 +117,10 @@ export default function Complaints() { contentContainerStyle={tw`px-4 py-4`} data={complaints} keyExtractor={(item) => item.id.toString()} + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={10} + removeClippedSubviews onEndReached={handleLoadMore} onEndReachedThreshold={0.5} renderItem={({ item }) => ( @@ -146,6 +150,20 @@ export default function Complaints() { {item.text} + {item.response && ( + + + + + Admin Reply + + + + {item.response} + + + )} + {item.images && item.images.length > 0 && ( diff --git a/apps/admin-ui/app/(drawer)/dashboard/coupons/create.tsx b/apps/admin-ui/app/(drawer)/dashboard/coupons/create.tsx index ba3bbac..5474054 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/coupons/create.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/coupons/create.tsx @@ -25,7 +25,6 @@ export default function CreateCoupon() { ) const handleCreateCoupon = async (values: any) => { - console.log('Form values:', values); // Debug log const { isReservedCoupon, couponCodes, ...rest } = values; // Transform targetUsers array to targetUser for backend compatibility const payload = { @@ -33,43 +32,29 @@ export default function CreateCoupon() { targetUser: rest.targetUsers?.[0] || undefined, }; delete payload.targetUsers; - console.log('Payload:', payload); // Debug log - if (isReservedCoupon && Array.isArray(couponCodes) && couponCodes.length > 0) { - // Create one reserved coupon per code, same config - const codes = couponCodes.map((c: string) => c.trim()).filter(Boolean); - try { - for (const code of codes) { - await createReservedCoupon.mutateAsync({ ...payload, couponCode: code }); - } - await refetchCoupons() - await refetchReservedCoupons() - Alert.alert('Success', `${codes.length} reserved coupon${codes.length > 1 ? 's' : ''} created successfully`, [ - { text: 'OK', onPress: () => router.back() } - ]); - } catch (error: any) { - Alert.alert('Error', error.message || 'Failed to create reserved coupons'); - } + // For flat discounts, send the discount value as maxValue too + if (payload.flatDiscount !== undefined) { + payload.maxValue = payload.flatDiscount; + } + + const codes = (couponCodes || []).map((c: string) => c.trim()).filter(Boolean); + if (codes.length === 0) { + Alert.alert('Error', 'At least one coupon code is required'); return; } const mutation = isReservedCoupon ? createReservedCoupon : createCoupon; - const isLoading = isReservedCoupon ? createReservedCoupon.isPending : createCoupon.isPending; - - if (isLoading) return; // Prevent double submission - - mutation.mutate(payload, { - onSuccess: async () => { - await refetchCoupons() - await refetchReservedCoupons() - Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [ - { text: 'OK', onPress: () => router.back() } - ]); - }, - onError: (error: any) => { - Alert.alert('Error', error.message || 'Failed to create coupon'); - }, - }); + try { + await mutation.mutateAsync({ ...payload, couponCodes: codes }); + await refetchCoupons() + await refetchReservedCoupons() + Alert.alert('Success', `${codes.length} coupon${codes.length > 1 ? 's' : ''} created successfully`, [ + { text: 'OK', onPress: () => router.back() } + ]); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to create coupons'); + } }; return ( diff --git a/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx b/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx index 1320290..cfb8dd4 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/coupons/edit/[id].tsx @@ -37,6 +37,11 @@ export default function EditCoupon() { }; delete updates.targetUsers; + // For flat discounts, send the discount value as maxValue too + if (updates.flatDiscount !== undefined) { + updates.maxValue = updates.flatDiscount; + } + updateCoupon.mutate({ id: couponId, updates }, { onSuccess: async () => { await refetch() @@ -74,7 +79,7 @@ export default function EditCoupon() { // Transform coupon data for form (targetUser to targetUsers array) const initialValues: Partial = { - couponCode: coupon.couponCode, + couponCodes: coupon.couponCode ? [coupon.couponCode] : [''], isUserBased: coupon.isUserBased, isApplyForAll: coupon.isApplyForAll, targetUsers: coupon.targetUser ? [coupon.targetUser.id] : [], diff --git a/apps/admin-ui/src/components/CouponForm.tsx b/apps/admin-ui/src/components/CouponForm.tsx index 020f922..e39be7c 100644 --- a/apps/admin-ui/src/components/CouponForm.tsx +++ b/apps/admin-ui/src/components/CouponForm.tsx @@ -11,7 +11,7 @@ const USERS_PAGE_SIZE = 10; interface CouponFormValues extends CreateCouponPayload { isReservedCoupon?: boolean; - couponCodes?: string[]; + couponCodes: string[]; skuIds?: number[]; } @@ -23,11 +23,6 @@ interface CouponFormProps { 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'), couponCodes: Yup.array().of( Yup.string() .required('Code is required') @@ -62,20 +57,16 @@ const couponValidationSchema = Yup.object().shape({ return this.createError({ message: 'Must provide either percentage or flat discount' }); } return true; -}).test('reserved-codes-required', 'At least one coupon code is required', function(value: any) { - if (value.isReservedCoupon) { - 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' }); - } +}).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('reserved-codes-unique', 'Coupon codes must be unique', function(value: any) { - if (value.isReservedCoupon) { - 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' }); - } +}).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; }); @@ -124,8 +115,7 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo // User search functionality will be inside Formik const defaultValues: CouponFormValues = { - couponCode: '', - couponCodes: [], + couponCodes: [''], isUserBased: false, isApplyForAll: false, targetUsers: [], @@ -173,70 +163,56 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo Is Reserved Coupon - {/* Coupon Code (single, non-reserved) OR Coupon Codes (array, reserved) */} - {!isReserved ? ( - - setFieldValue('couponCode', text.toUpperCase())} - keyboardType="default" - autoCapitalize="characters" - error={!!(touched.couponCode && errors.couponCode)} - /> - - ) : ( - - Coupon Codes - - Each code creates a separate reserved coupon with the same settings. - - {(values.couponCodes || []).map((code: string, index: number) => { - const codeError = Array.isArray(errors.couponCodes) - ? (errors.couponCodes as any)[index] - : errors.couponCodes; - return ( - - - { - const next = [...(values.couponCodes || [])]; - next[index] = text.toUpperCase(); - setFieldValue('couponCodes', next); - }} - keyboardType="default" - autoCapitalize="characters" - error={!!codeError} - /> - {codeError && {codeError}} - - { - const next = (values.couponCodes || []).filter((_: string, i: number) => i !== index); + {/* Coupon Codes */} + + Coupon Codes + + Each code creates a separate coupon with the same settings. + + {(values.couponCodes || []).map((code: string, index: number) => { + const codeError = Array.isArray(errors.couponCodes) + ? (errors.couponCodes as any)[index] + : errors.couponCodes; + return ( + + + { + const next = [...(values.couponCodes || [])]; + next[index] = text.toUpperCase(); setFieldValue('couponCodes', next); }} - style={tw`ml-2 p-2`} - > - - + keyboardType="default" + autoCapitalize="characters" + error={!!codeError} + /> + {codeError && {codeError}} - ); - })} - {(errors.couponCodes as any)?.message && ( - {(errors.couponCodes as any).message} - )} - setFieldValue('couponCodes', [...(values.couponCodes || []), ''])} - style={tw`mt-2 py-2 px-3 bg-blue-50 rounded-lg border border-blue-200 self-start`} - > - + Add Code - - - )} + { + const next = (values.couponCodes || []).filter((_: string, i: number) => i !== index); + setFieldValue('couponCodes', next.length > 0 ? next : ['']); + }} + style={tw`ml-2 p-2`} + > + + + + ); + })} + {(errors.couponCodes as any)?.message && ( + {(errors.couponCodes as any).message} + )} + setFieldValue('couponCodes', [...(values.couponCodes || []), ''])} + style={tw`mt-2 py-2 px-3 bg-blue-50 rounded-lg border border-blue-200 self-start`} + > + + Add Code + + {/* Discount Type Selection */} @@ -308,17 +284,19 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo /> - {/* Maximum Discount Value */} - - setFieldValue('maxValue', parseFloat(text) || undefined)} - keyboardType="numeric" - error={!!(touched.maxValue && errors.maxValue)} - /> - + {/* Maximum Discount Value - only for percentage discounts */} + {values.flatDiscount === undefined && ( + + setFieldValue('maxValue', parseFloat(text) || undefined)} + keyboardType="numeric" + error={!!(touched.maxValue && errors.maxValue)} + /> + + )} {/* Validity Period */} diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts b/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts index f4463ec..bca4d06 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts @@ -27,6 +27,7 @@ export const complaintRouter = router({ status: string; createdAt: Date; images: string[]; + response: string | null; }>; nextCursor?: number; }> => { @@ -83,6 +84,7 @@ export const complaintRouter = router({ status: c.isResolved ? 'resolved' : 'pending', createdAt: c.createdAt, images: signedImages, + response: c.response, }; }) ); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts index e8e9bfd..703cd2a 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts @@ -24,6 +24,7 @@ import type { Coupon, CouponValidationResult, UserMiniInfo } from '@packages/sha const createCouponBodySchema = z.object({ couponCode: z.string().optional(), + couponCodes: z.array(z.string()).optional(), isUserBased: z.boolean().optional(), discountPercent: z.number().optional(), flatDiscount: z.number().optional(), @@ -48,8 +49,8 @@ const validateCouponBodySchema = z.object({ export const couponRouter = router({ create: protectedProcedure .input(createCouponBodySchema) - .mutation(async ({ input, ctx }): Promise => { - const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + .mutation(async ({ input, ctx }): Promise => { + const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { @@ -72,18 +73,15 @@ export const couponRouter = router({ throw new Error("Unauthorized"); } - // Generate coupon code if not provided - let finalCouponCode = couponCode; - if (!finalCouponCode) { + // Support both single code (legacy) and array of codes + let codes: string[] = couponCodes?.filter(Boolean) || []; + if (codes.length === 0 && couponCode) { + codes = [couponCode]; + } + if (codes.length === 0) { const timestamp = Date.now().toString().slice(-6); const random = Math.random().toString(36).substring(2, 8).toUpperCase(); - finalCouponCode = `MF${timestamp}${random}`; - } - - // Using dbService helper (new implementation) - const codeExists = await checkCouponExists(finalCouponCode); - if (codeExists) { - throw new Error("Coupon code already exists"); + codes = [`MF${timestamp}${random}`]; } // If applicableUsers is provided, verify users exist @@ -94,66 +92,37 @@ export const couponRouter = router({ } } - const coupon = await createCouponWithRelations( - { - couponCode: finalCouponCode, - isUserBased: isUserBased || false, - discountPercent: discountPercent?.toString(), - flatDiscount: flatDiscount?.toString(), - minOrder: minOrder?.toString(), - skuIds: skuIds || null, - createdBy: staffUserId, - maxValue: maxValue?.toString(), - isApplyForAll: isApplyForAll || false, - validTill: validTill ? dayjs(validTill).toDate() : undefined, - maxLimitForUser, - exclusiveApply: exclusiveApply || false, - }, - applicableUsers, - applicableProducts - ); + const createdCoupons: Coupon[] = []; + for (const code of codes) { + const finalCouponCode = code.trim().toUpperCase(); + const codeExists = await checkCouponExists(finalCouponCode); + if (codeExists) { + throw new Error(`Coupon code already exists: ${finalCouponCode}`); + } - /* - // Old implementation - direct DB query with transaction: - const result = await db.insert(coupons).values({ - couponCode: finalCouponCode, - isUserBased: isUserBased || false, - discountPercent: discountPercent?.toString(), - flatDiscount: flatDiscount?.toString(), - minOrder: minOrder?.toString(), - skuIds: skuIds || null, - createdBy: staffUserId, - maxValue: maxValue?.toString(), - isApplyForAll: isApplyForAll || false, - validTill: validTill ? dayjs(validTill).toDate() : undefined, - maxLimitForUser, - exclusiveApply: exclusiveApply || false, - }).returning(); - - const coupon = result[0]; - - // Insert applicable users - if (applicableUsers && applicableUsers.length > 0) { - await db.insert(couponApplicableUsers).values( - applicableUsers.map(userId => ({ - couponId: coupon.id, - userId, - })) + const coupon = await createCouponWithRelations( + { + couponCode: finalCouponCode, + isUserBased: isUserBased || false, + discountPercent: discountPercent?.toString(), + flatDiscount: flatDiscount?.toString(), + minOrder: minOrder?.toString(), + skuIds: skuIds || null, + createdBy: staffUserId, + maxValue: maxValue?.toString(), + isApplyForAll: isApplyForAll || false, + validTill: validTill ? dayjs(validTill).toDate() : undefined, + maxLimitForUser, + exclusiveApply: exclusiveApply || false, + }, + applicableUsers, + applicableProducts ); + + createdCoupons.push(coupon); } - // Insert applicable products - if (applicableProducts && applicableProducts.length > 0) { - await db.insert(couponApplicableProducts).values( - applicableProducts.map(skuId => ({ - couponId: coupon.id, - skuId, - })) - ); - } - */ - - return coupon; + return createdCoupons; }), getAll: protectedProcedure @@ -210,7 +179,8 @@ export const couponRouter = router({ // Prepare update data const updateData: any = {}; - if (updates.couponCode !== undefined) updateData.couponCode = updates.couponCode; + const updatedCode = updates.couponCodes?.[0]?.trim() || updates.couponCode; + if (updatedCode !== undefined && updatedCode !== '') updateData.couponCode = updatedCode.trim().toUpperCase(); if (updates.isUserBased !== undefined) updateData.isUserBased = updates.isUserBased; if (updates.discountPercent !== undefined) updateData.discountPercent = updates.discountPercent?.toString(); if (updates.flatDiscount !== undefined) updateData.flatDiscount = updates.flatDiscount?.toString(); @@ -405,7 +375,7 @@ export const couponRouter = router({ createReservedCoupon: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }): Promise => { - const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; + const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { @@ -418,62 +388,46 @@ export const couponRouter = router({ throw new Error("Unauthorized"); } - // Generate secret code if not provided - let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; - - // Using dbService helper (new implementation) - const codeExists = await checkReservedCouponExists(secretCode); - if (codeExists) { - throw new Error("Secret code already exists"); + // Support both single code (legacy) and array of codes + let codes: string[] = couponCodes?.filter(Boolean) || []; + if (codes.length === 0 && couponCode) { + codes = [couponCode]; + } + if (codes.length === 0) { + const timestamp = Date.now().toString().slice(-6); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + codes = [`SECRET${timestamp}${random}`]; } - const coupon = await createReservedCouponWithProducts( - { - secretCode, - couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`, - discountPercent: discountPercent?.toString(), - flatDiscount: flatDiscount?.toString(), - minOrder: minOrder?.toString(), - skuIds, - maxValue: maxValue?.toString(), - validTill: validTill ? dayjs(validTill).toDate() : undefined, - maxLimitForUser, - exclusiveApply: exclusiveApply || false, - createdBy: staffUserId, - }, - applicableProducts - ); + const createdCoupons = []; + for (const code of codes) { + const secretCode = code.trim().toUpperCase(); + const codeExists = await checkReservedCouponExists(secretCode); + if (codeExists) { + throw new Error(`Secret code already exists: ${secretCode}`); + } - /* - // Old implementation - direct DB query: - const result = await db.insert(reservedCoupons).values({ - secretCode, - couponCode: couponCode || RESERVED${Date.now().toString().slice(-6)}, - discountPercent: discountPercent?.toString(), - flatDiscount: flatDiscount?.toString(), - minOrder: minOrder?.toString(), - skuIds, - maxValue: maxValue?.toString(), - validTill: validTill ? dayjs(validTill).toDate() : undefined, - maxLimitForUser, - exclusiveApply: exclusiveApply || false, - createdBy: staffUserId, - }).returning(); - - const coupon = result[0]; - - // Insert applicable products if provided - if (applicableProducts && applicableProducts.length > 0) { - await db.insert(couponApplicableProducts).values( - applicableProducts.map(skuId => ({ - couponId: coupon.id, - skuId, - })) + const coupon = await createReservedCouponWithProducts( + { + secretCode, + couponCode: secretCode, + discountPercent: discountPercent?.toString(), + flatDiscount: flatDiscount?.toString(), + minOrder: minOrder?.toString(), + skuIds, + maxValue: maxValue?.toString(), + validTill: validTill ? dayjs(validTill).toDate() : undefined, + maxLimitForUser, + exclusiveApply: exclusiveApply || false, + createdBy: staffUserId, + }, + applicableProducts ); - } - */ - return coupon; + createdCoupons.push(coupon); + } + + return createdCoupons; }), getUsersMiniInfo: protectedProcedure diff --git a/apps/user-ui/components/SlotSpecificView.tsx b/apps/user-ui/components/SlotSpecificView.tsx index 2e1fd4e..7880df4 100644 --- a/apps/user-ui/components/SlotSpecificView.tsx +++ b/apps/user-ui/components/SlotSpecificView.tsx @@ -5,7 +5,7 @@ import { View, ScrollView, Alert, Dimensions } from 'react-native'; import { Image } from 'expo-image'; import { useRouter, usePathname } from 'expo-router'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { tw, theme, MyText, MyTouchableOpacity, MyFlatList, AppContainer, MiniQuantifier } from 'common-ui'; +import { tw, theme, MyText, MyTouchableOpacity, MyFlatList, AppContainer, MiniQuantifier, SearchBar } from 'common-ui'; import { trpc } from '@/src/trpc-client'; import { useAllProducts, useStores, useSlots } from '@/src/hooks/prominent-api-hooks'; import { AllProductsApiType } from '@backend/trpc/router'; @@ -445,6 +445,7 @@ interface FlashDeliveryProductsProps { export function FlashDeliveryProducts({ storeId:storeIdParent, baseUrl, onProductPress }: FlashDeliveryProductsProps) { useHideTabNav('quick_delivery'); const [isLoadingDialogOpen, setIsLoadingDialogOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = useState(''); const router = useRouter(); const storeId = storeIdParent; const storeIdNum = storeId; @@ -506,11 +507,28 @@ export function FlashDeliveryProducts({ storeId:storeIdParent, baseUrl, onProduc }) || []; } + // Apply search filter (searches within this store's products only) + const trimmedQuery = searchQuery.trim().toLowerCase(); + if (trimmedQuery) { + flashProducts = flashProducts.filter(p => + p.name?.toLowerCase().includes(trimmedQuery) + ); + } + return ( + + + } renderItem={({ item }) => (