515 lines
No EOL
18 KiB
TypeScript
515 lines
No EOL
18 KiB
TypeScript
import React, { useState, useCallback, useMemo } from 'react';
|
|
import { View, TouchableOpacity, Alert, RefreshControl, Dimensions } from 'react-native';
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { tw, MyButton, MyText, SearchBar, MyFlatList, useMarkDataFetchers, MyTouchableOpacity, BottomDropdown } from 'common-ui';
|
|
import useManualRefresh from 'common-ui/hooks/useManualRefresh';
|
|
import { trpc } from '@/src/trpc-client';
|
|
import { useRouter } from 'expo-router';
|
|
import { TabView, SceneMap, TabBar } from 'react-native-tab-view';
|
|
|
|
const CouponItem = ({ item, onDelete }: { item: any; onDelete: (id: number) => void }) => {
|
|
const router = useRouter();
|
|
|
|
const getCouponStatus = (coupon: any) => {
|
|
if (coupon.isInvalidated) return 'inactive';
|
|
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
|
return 'active';
|
|
};
|
|
|
|
const status = getCouponStatus(item);
|
|
|
|
const getBorderColor = () => {
|
|
if (status === 'active') return 'border-green-500';
|
|
if (status === 'expired') return 'border-yellow-500';
|
|
return 'border-red-500';
|
|
};
|
|
|
|
const getBgColor = () => {
|
|
if (status === 'active') return 'bg-green-100';
|
|
if (status === 'expired') return 'bg-yellow-100';
|
|
return 'bg-red-100';
|
|
};
|
|
|
|
const getTextColor = () => {
|
|
if (status === 'active') return 'text-green-600';
|
|
if (status === 'expired') return 'text-yellow-600';
|
|
return 'text-red-600';
|
|
};
|
|
|
|
const getIconColor = () => {
|
|
if (status === 'active') return '#10b981';
|
|
if (status === 'expired') return '#f59e0b';
|
|
return '#ef4444';
|
|
};
|
|
|
|
return (
|
|
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg border-l-4 ${getBorderColor()}`}>
|
|
<View style={tw`flex-row items-center mb-3`}>
|
|
<View style={tw`w-10 h-10 rounded-full ${getBgColor()} items-center justify-center mr-3`}>
|
|
<MaterialCommunityIcons
|
|
name={item.discountPercent ? "percent" : "currency-inr"}
|
|
size={20}
|
|
color={getIconColor()}
|
|
/>
|
|
</View>
|
|
<View style={tw`flex-1`}>
|
|
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.couponCode}</MyText>
|
|
<MyText style={tw`text-sm text-gray-500`}>ID: {item.id}</MyText>
|
|
</View>
|
|
<View style={tw`px-2 py-1 rounded-full ${getBgColor()}`}>
|
|
<MyText style={tw`text-xs font-semibold ${getTextColor()}`}>
|
|
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Inactive'}
|
|
</MyText>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={tw`bg-gray-50 p-3 rounded-lg mb-3`}>
|
|
<MyText style={tw`text-base font-semibold mb-1 text-gray-800`}>
|
|
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
|
</MyText>
|
|
<View style={tw`flex-row justify-between`}>
|
|
<MyText style={tw`text-sm text-gray-600`}>Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
|
<MyText style={tw`text-sm text-gray-600`}>Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
|
</View>
|
|
<MyText style={tw`text-sm text-gray-600 mt-1`}>
|
|
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
|
</MyText>
|
|
</View>
|
|
|
|
<View style={tw`mb-3`}>
|
|
<MyText style={tw`text-sm text-gray-700 mb-1`}>
|
|
<MaterialCommunityIcons name="account-group" size={14} color="#6b7280" /> Target: {item.isApplyForAll ? 'All Users' : item.applicableUsers?.length > 0 ? `${item.applicableUsers.length} Users` : 'All Users'}
|
|
</MyText>
|
|
{item.applicableProducts && item.applicableProducts.length > 0 && (
|
|
<MyText style={tw`text-sm text-gray-700`}>
|
|
<MaterialCommunityIcons name="package-variant" size={14} color="#6b7280" /> Products: {item.applicableProducts.length} selected
|
|
</MyText>
|
|
)}
|
|
</View>
|
|
|
|
<View style={tw`flex-row mt-3 gap-2`}>
|
|
<TouchableOpacity
|
|
onPress={() => router.push(`/(drawer)/edit-coupon/${item.id}`)}
|
|
style={tw`bg-blue-500 p-3 rounded-lg shadow-md flex-1 flex-row items-center justify-center`}
|
|
>
|
|
<MaterialCommunityIcons name="pencil" size={16} color="white" />
|
|
<MyText style={tw`text-white text-center font-semibold ml-1`}>Edit</MyText>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => onDelete(item.id)}
|
|
style={tw`bg-red-500 p-3 rounded-lg shadow-md flex-1 flex-row items-center justify-center`}
|
|
>
|
|
<MaterialCommunityIcons name="delete" size={16} color="white" />
|
|
<MyText style={tw`text-white text-center font-semibold ml-1`}>Delete</MyText>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const ReservedCouponItem = ({ item }: { item: any }) => {
|
|
const getStatus = () => {
|
|
if (item.isRedeemed) return 'redeemed';
|
|
if (item.validTill && new Date(item.validTill) <= new Date()) return 'expired';
|
|
return 'active';
|
|
};
|
|
|
|
const status = getStatus();
|
|
|
|
const getBorderColor = () => {
|
|
if (status === 'active') return 'border-green-500';
|
|
if (status === 'expired') return 'border-yellow-500';
|
|
return 'border-blue-500';
|
|
};
|
|
|
|
const getBgColor = () => {
|
|
if (status === 'active') return 'bg-green-100';
|
|
if (status === 'expired') return 'bg-yellow-100';
|
|
return 'bg-blue-100';
|
|
};
|
|
|
|
const getTextColor = () => {
|
|
if (status === 'active') return 'text-green-600';
|
|
if (status === 'expired') return 'text-yellow-600';
|
|
return 'text-blue-600';
|
|
};
|
|
|
|
const getIconColor = () => {
|
|
if (status === 'active') return '#10b981';
|
|
if (status === 'expired') return '#f59e0b';
|
|
return '#3b82f6';
|
|
};
|
|
|
|
return (
|
|
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg border-l-4 ${getBorderColor()}`}>
|
|
<View style={tw`flex-row items-center mb-3`}>
|
|
<View style={tw`w-10 h-10 rounded-full ${getBgColor()} items-center justify-center mr-3`}>
|
|
<MaterialCommunityIcons
|
|
name={item.discountPercent ? "percent" : "currency-inr"}
|
|
size={20}
|
|
color={getIconColor()}
|
|
/>
|
|
</View>
|
|
<View style={tw`flex-1`}>
|
|
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.secretCode}</MyText>
|
|
<MyText style={tw`text-sm text-gray-500`}>Coupon: {item.couponCode}</MyText>
|
|
</View>
|
|
<View style={tw`px-2 py-1 rounded-full ${getBgColor()}`}>
|
|
<MyText style={tw`text-xs font-semibold ${getTextColor()}`}>
|
|
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Redeemed'}
|
|
</MyText>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={tw`bg-gray-50 p-3 rounded-lg mb-3`}>
|
|
<MyText style={tw`text-base font-semibold mb-1 text-gray-800`}>
|
|
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
|
</MyText>
|
|
<View style={tw`flex-row justify-between`}>
|
|
<MyText style={tw`text-sm text-gray-600`}>Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
|
<MyText style={tw`text-sm text-gray-600`}>Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
|
</View>
|
|
<MyText style={tw`text-sm text-gray-600 mt-1`}>
|
|
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
|
</MyText>
|
|
</View>
|
|
|
|
{item.isRedeemed && item.redeemedUser && (
|
|
<View style={tw`mb-3`}>
|
|
<MyText style={tw`text-sm text-gray-700`}>
|
|
<MaterialCommunityIcons name="account-check" size={14} color="#6b7280" /> Redeemed by: {item.redeemedUser.name || 'Unknown'} ({item.redeemedUser.mobile})
|
|
</MyText>
|
|
<MyText style={tw`text-sm text-gray-600`}>
|
|
Redeemed on: {item.redeemedAt ? new Date(item.redeemedAt).toLocaleDateString() : 'N/A'}
|
|
</MyText>
|
|
</View>
|
|
)}
|
|
|
|
<View style={tw`flex-row justify-between items-center mt-3`}>
|
|
<MyText style={tw`text-sm text-gray-700`}>
|
|
<MaterialCommunityIcons name="account" size={14} color="#6b7280" /> Created by: {item.creator?.name || 'Unknown'}
|
|
</MyText>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const GeneralTab = () => {
|
|
const router = useRouter();
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
|
const {
|
|
data,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isLoading,
|
|
isFetchingNextPage,
|
|
error,
|
|
refetch,
|
|
} = trpc.admin.coupon.getAll.useInfiniteQuery(
|
|
{ limit: 20, search: searchQuery },
|
|
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
|
|
);
|
|
|
|
const coupons = useMemo(() => data?.pages.flatMap(page => page.coupons) || [], [data]);
|
|
|
|
const getCouponStatus = (coupon: any) => {
|
|
if (coupon.isInvalidated) return 'inactive';
|
|
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
|
return 'active';
|
|
};
|
|
|
|
const filteredCoupons = useMemo(() => {
|
|
let filtered = coupons;
|
|
if (statusFilters.length > 0) {
|
|
filtered = filtered.filter(coupon => statusFilters.includes(getCouponStatus(coupon)));
|
|
}
|
|
return filtered;
|
|
}, [coupons, statusFilters]);
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
await refetch();
|
|
setRefreshing(false);
|
|
}, [refetch]);
|
|
|
|
useManualRefresh(() => refetch());
|
|
|
|
useMarkDataFetchers(() => {
|
|
refetch();
|
|
});
|
|
|
|
const deleteCoupon = trpc.admin.coupon.delete.useMutation();
|
|
|
|
const handleDeleteCoupon = (id: number) => {
|
|
Alert.alert('Delete Coupon', 'Are you sure you want to delete this coupon?', [
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Delete',
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
deleteCoupon.mutate({ id }, {
|
|
onSuccess: () => {
|
|
Alert.alert('Success', 'Coupon deleted successfully');
|
|
refetch();
|
|
},
|
|
onError: (error: any) => {
|
|
Alert.alert('Error', error.message || 'Failed to delete coupon');
|
|
},
|
|
});
|
|
},
|
|
},
|
|
]);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
|
<MaterialCommunityIcons name="loading" size={32} color="#3b82f6" style={tw`mb-4`} />
|
|
<MyText style={tw`text-lg font-semibold text-gray-600`}>Loading Coupons...</MyText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<View style={tw`flex-1 justify-center items-center bg-white p-4`}>
|
|
<MaterialCommunityIcons name="alert-circle" size={32} color="#ef4444" style={tw`mb-4`} />
|
|
<MyText style={tw`text-lg font-semibold text-red-600 mb-2 text-center`}>Failed to load coupons</MyText>
|
|
<MyButton onPress={() => refetch()} style={tw`bg-red-500`}>
|
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
|
</MyButton>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={tw`flex-1`}>
|
|
<View style={tw`flex-row items-center px-4 pt-2`}>
|
|
<View style={tw`flex-1 mr-2`}>
|
|
<SearchBar
|
|
value={searchQuery}
|
|
onChangeText={setSearchQuery}
|
|
placeholder="Search coupons..."
|
|
/>
|
|
</View>
|
|
<BottomDropdown
|
|
label="Filter by Status"
|
|
value={statusFilters}
|
|
options={[
|
|
{ label: 'Active', value: 'active' },
|
|
{ label: 'Expired', value: 'expired' },
|
|
{ label: 'Inactive', value: 'inactive' },
|
|
]}
|
|
onValueChange={(value) => setStatusFilters(value as string[])}
|
|
multiple={true}
|
|
triggerComponent={({ onPress }) => (
|
|
<TouchableOpacity onPress={onPress} style={tw`p-2`}>
|
|
<MaterialCommunityIcons name="filter-variant" size={24} color="#6b7280" />
|
|
</TouchableOpacity>
|
|
)}
|
|
/>
|
|
</View>
|
|
<MyFlatList
|
|
data={filteredCoupons}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
renderItem={({ item }) => <CouponItem item={item} onDelete={handleDeleteCoupon} />}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
|
}
|
|
contentContainerStyle={tw`px-4 pb-4`}
|
|
onEndReached={() => {
|
|
if (hasNextPage && !isFetchingNextPage) {
|
|
fetchNextPage();
|
|
}
|
|
}}
|
|
onEndReachedThreshold={0.5}
|
|
ListEmptyComponent={
|
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
|
<MaterialCommunityIcons name="ticket-percent-outline" size={40} color="#9ca3af" style={tw`mb-4`} />
|
|
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Coupons Found</MyText>
|
|
{searchQuery ? (
|
|
<MyButton onPress={() => setSearchQuery('')} style={tw`bg-gray-500 mt-4`}>
|
|
<MyText style={tw`text-white font-semibold`}>Clear Search</MyText>
|
|
</MyButton>
|
|
) : null}
|
|
</View>
|
|
}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const ReservedTab = () => {
|
|
const router = useRouter();
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
error,
|
|
refetch
|
|
} = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
|
{ limit: 20, search: searchQuery },
|
|
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
|
|
);
|
|
|
|
const coupons = useMemo(() => data?.pages.flatMap((page) => page.coupons) || [], [data]);
|
|
|
|
const getStatus = (coupon: any) => {
|
|
if (coupon.isRedeemed) return 'redeemed';
|
|
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
|
return 'active';
|
|
};
|
|
|
|
const filteredCoupons = useMemo(() => {
|
|
let filtered = coupons;
|
|
if (statusFilters.length > 0) {
|
|
filtered = filtered.filter(coupon => statusFilters.includes(getStatus(coupon)));
|
|
}
|
|
return filtered;
|
|
}, [coupons, statusFilters]);
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
await refetch();
|
|
setRefreshing(false);
|
|
}, [refetch]);
|
|
|
|
useManualRefresh(() => refetch());
|
|
|
|
useMarkDataFetchers(() => {
|
|
refetch();
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
|
<MaterialCommunityIcons name="loading" size={32} color="#3b82f6" style={tw`mb-4`} />
|
|
<MyText style={tw`text-lg font-semibold text-gray-600`}>Loading Reserved Coupons...</MyText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<View style={tw`flex-1 justify-center items-center bg-white p-4`}>
|
|
<MaterialCommunityIcons name="alert-circle" size={32} color="#ef4444" style={tw`mb-4`} />
|
|
<MyText style={tw`text-lg font-semibold text-red-600 mb-2 text-center`}>Failed to load reserved coupons</MyText>
|
|
<MyButton onPress={() => refetch()} style={tw`bg-red-500`}>
|
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
|
</MyButton>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={tw`flex-1`}>
|
|
<View style={tw`flex-row items-center px-4 py-2`}>
|
|
<View style={tw`flex-1 mr-2`}>
|
|
<SearchBar
|
|
value={searchQuery}
|
|
onChangeText={setSearchQuery}
|
|
placeholder="Search reserved coupons..."
|
|
/>
|
|
</View>
|
|
<BottomDropdown
|
|
label="Filter by Status"
|
|
value={statusFilters}
|
|
options={[
|
|
{ label: 'Active', value: 'active' },
|
|
{ label: 'Expired', value: 'expired' },
|
|
{ label: 'Redeemed', value: 'redeemed' },
|
|
]}
|
|
onValueChange={(value) => setStatusFilters(value as string[])}
|
|
multiple={true}
|
|
triggerComponent={({ onPress }) => (
|
|
<TouchableOpacity onPress={onPress} style={tw`p-2`}>
|
|
<MaterialCommunityIcons name="filter-variant" size={24} color="#6b7280" />
|
|
</TouchableOpacity>
|
|
)}
|
|
/>
|
|
</View>
|
|
<MyFlatList
|
|
data={filteredCoupons}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
renderItem={({ item }) => <ReservedCouponItem item={item} />}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
|
}
|
|
contentContainerStyle={tw`px-4 pb-4`}
|
|
onEndReached={() => {
|
|
if (hasNextPage && !isFetchingNextPage) {
|
|
fetchNextPage();
|
|
}
|
|
}}
|
|
onEndReachedThreshold={0.5}
|
|
ListEmptyComponent={
|
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
|
<MaterialCommunityIcons name="ticket-percent-outline" size={40} color="#9ca3af" style={tw`mb-4`} />
|
|
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Reserved Coupons Found</MyText>
|
|
{searchQuery ? (
|
|
<MyButton onPress={() => setSearchQuery('')} style={tw`bg-gray-500 mt-4`}>
|
|
<MyText style={tw`text-white font-semibold`}>Clear Search</MyText>
|
|
</MyButton>
|
|
) : null}
|
|
</View>
|
|
}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default function Coupons() {
|
|
const router = useRouter();
|
|
const [index, setIndex] = useState(0);
|
|
const routes = [
|
|
{ key: 'general', title: 'General' },
|
|
{ key: 'reserved', title: 'Reserved' },
|
|
];
|
|
|
|
return (
|
|
<View style={tw`flex-1 bg-white`}>
|
|
<TabView
|
|
navigationState={{ index, routes }}
|
|
renderScene={SceneMap({
|
|
general: GeneralTab,
|
|
reserved: ReservedTab,
|
|
})}
|
|
onIndexChange={setIndex}
|
|
initialLayout={{ width: Dimensions.get('window').width }}
|
|
lazy
|
|
renderTabBar={props => (
|
|
<TabBar
|
|
{...props}
|
|
style={{ backgroundColor: '#fff' }}
|
|
indicatorStyle={{ backgroundColor: '#F83758' }}
|
|
activeColor='#F83758'
|
|
inactiveColor='#6b7280'
|
|
/>
|
|
)}
|
|
/>
|
|
<MyTouchableOpacity
|
|
onPress={() => router.push('/(drawer)/create-coupon')}
|
|
activeOpacity={0.95}
|
|
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
|
>
|
|
<LinearGradient
|
|
colors={['#F83758', '#E91E63']}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg`}
|
|
>
|
|
<MaterialCommunityIcons name="plus" size={32} color="white" />
|
|
</LinearGradient>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
);
|
|
} |