enh
This commit is contained in:
parent
d599c2e004
commit
5b19a0486c
22 changed files with 1130 additions and 216 deletions
File diff suppressed because one or more lines are too long
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -120,6 +120,20 @@ function CustomDrawerContent() {
|
||||||
icon={({ color, size }) => (
|
icon={({ color, size }) => (
|
||||||
<MaterialIcons name="store" size={size} color={color} />
|
<MaterialIcons name="store" size={size} color={color} />
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="User Management"
|
||||||
|
onPress={() => router.push("/(drawer)/user-management" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="people" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Send Notifications"
|
||||||
|
onPress={() => router.push("/(drawer)/send-notifications" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="campaign" size={size} color={color} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<DrawerItem
|
<DrawerItem
|
||||||
label="Logout"
|
label="Logout"
|
||||||
|
|
@ -217,6 +231,8 @@ export default function Layout() {
|
||||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||||
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
|
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
|
||||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||||
|
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||||
|
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ interface MenuItem {
|
||||||
icon: string;
|
icon: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
route: string;
|
route: string;
|
||||||
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
|
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings' | 'users';
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
iconBg?: string;
|
iconBg?: string;
|
||||||
badgeCount?: number;
|
badgeCount?: number;
|
||||||
|
|
@ -192,6 +192,24 @@ export default function Dashboard() {
|
||||||
iconColor: '#7C3AED',
|
iconColor: '#7C3AED',
|
||||||
iconBg: '#F3E8FF',
|
iconBg: '#F3E8FF',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'User Management',
|
||||||
|
icon: 'people',
|
||||||
|
description: 'View and manage all users',
|
||||||
|
route: '/(drawer)/user-management',
|
||||||
|
category: 'users',
|
||||||
|
iconColor: '#0EA5E9',
|
||||||
|
iconBg: '#E0F2FE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Send Notifications',
|
||||||
|
icon: 'campaign',
|
||||||
|
description: 'Send push notifications to users',
|
||||||
|
route: '/(drawer)/send-notifications',
|
||||||
|
category: 'users',
|
||||||
|
iconColor: '#8B5CF6',
|
||||||
|
iconBg: '#F3E8FF',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const quickActions = menuItems.filter(item => item.category === 'quick');
|
const quickActions = menuItems.filter(item => item.category === 'quick');
|
||||||
|
|
@ -200,6 +218,7 @@ export default function Dashboard() {
|
||||||
{ key: 'orders', title: 'Orders', icon: 'receipt-long' },
|
{ key: 'orders', title: 'Orders', icon: 'receipt-long' },
|
||||||
{ key: 'products', title: 'Products & Inventory', icon: 'inventory' },
|
{ key: 'products', title: 'Products & Inventory', icon: 'inventory' },
|
||||||
{ key: 'marketing', title: 'Marketing & Promotions', icon: 'campaign' },
|
{ key: 'marketing', title: 'Marketing & Promotions', icon: 'campaign' },
|
||||||
|
{ key: 'users', title: 'User Management', icon: 'people' },
|
||||||
{ key: 'settings', title: 'Settings & Configuration', icon: 'settings' },
|
{ key: 'settings', title: 'Settings & Configuration', icon: 'settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
241
apps/admin-ui/app/(drawer)/send-notifications/index.tsx
Normal file
241
apps/admin-ui/app/(drawer)/send-notifications/index.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import {
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
MyTextInput,
|
||||||
|
BottomDropdown,
|
||||||
|
ImageUploader,
|
||||||
|
} from 'common-ui';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
isEligibleForNotif: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractKeyFromUrl = (url: string): string => {
|
||||||
|
const u = new URL(url);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, '');
|
||||||
|
return decodeURIComponent(rawKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SendNotifications() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [selectedImage, setSelectedImage] = useState<{ blob: Blob; mimeType: string } | null>(null);
|
||||||
|
const [displayImage, setDisplayImage] = useState<{ uri?: string } | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Query users eligible for notifications
|
||||||
|
const { data: usersData, isLoading: isLoadingUsers } = trpc.admin.user.getUsersForNotification.useQuery({
|
||||||
|
search: searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate upload URLs mutation
|
||||||
|
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
|
// Send notification mutation
|
||||||
|
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert('Success', 'Notification sent successfully!');
|
||||||
|
// Reset form
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
setMessage('');
|
||||||
|
setSelectedImage(null);
|
||||||
|
setDisplayImage(null);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to send notification');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligibleUsers = usersData?.users.filter((u: User) => u.isEligibleForNotif) || [];
|
||||||
|
|
||||||
|
const dropdownOptions = eligibleUsers.map((user: User) => ({
|
||||||
|
label: `${user.mobile || 'No Mobile'}${user.name ? ` - ${user.name}` : ''}`,
|
||||||
|
value: user.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleImagePick = usePickImage({
|
||||||
|
setFile: async (assets: any) => {
|
||||||
|
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||||
|
setSelectedImage(null);
|
||||||
|
setDisplayImage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Array.isArray(assets) ? assets[0] : assets;
|
||||||
|
const response = await fetch(file.uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
setSelectedImage({ blob, mimeType: file.mimeType || 'image/jpeg' });
|
||||||
|
setDisplayImage({ uri: file.uri });
|
||||||
|
},
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveImage = () => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
setDisplayImage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (message.trim().length === 0) {
|
||||||
|
Alert.alert('Error', 'Please enter a message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedUserIds.length === 0) {
|
||||||
|
Alert.alert('Error', 'Please select at least one user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
|
// Upload image if selected
|
||||||
|
if (selectedImage) {
|
||||||
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
contextString: 'notification',
|
||||||
|
mimeTypes: [selectedImage.mimeType],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadUrls.length > 0) {
|
||||||
|
const uploadUrl = uploadUrls[0];
|
||||||
|
imageUrl = extractKeyFromUrl(uploadUrl);
|
||||||
|
|
||||||
|
// Upload image
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: selectedImage.blob,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': selectedImage.mimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
await sendNotification.mutateAsync({
|
||||||
|
userIds: selectedUserIds,
|
||||||
|
text: message.trim(),
|
||||||
|
imageUrl,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to send notification');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedUserIds.length === 0) return 'Select users';
|
||||||
|
if (selectedUserIds.length === 1) {
|
||||||
|
const user = eligibleUsers.find((u: User) => u.id === selectedUserIds[0]);
|
||||||
|
return user ? `${user.mobile}${user.name ? ` - ${user.name}` : ''}` : '1 user selected';
|
||||||
|
}
|
||||||
|
return `${selectedUserIds.length} users selected`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingUsers) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4`}>Loading users...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`p-2 -ml-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900 ml-2`}>Send Notifications</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`p-4`}>
|
||||||
|
{/* User Selection */}
|
||||||
|
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Select Users</MyText>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Select Users"
|
||||||
|
value={selectedUserIds}
|
||||||
|
options={dropdownOptions}
|
||||||
|
onValueChange={(value) => setSelectedUserIds(value as number[])}
|
||||||
|
multiple={true}
|
||||||
|
placeholder="Select users"
|
||||||
|
onSearch={(query) => setSearchQuery(query)}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-gray-500 text-sm mt-2`}>
|
||||||
|
{getDisplayText()}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Message Input */}
|
||||||
|
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Message</MyText>
|
||||||
|
<MyTextInput
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
placeholder="Enter notification message..."
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
style={tw`text-gray-900`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Image Upload */}
|
||||||
|
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Image (Optional)</MyText>
|
||||||
|
<ImageUploader
|
||||||
|
images={displayImage ? [displayImage] : []}
|
||||||
|
existingImageUrls={[]}
|
||||||
|
onAddImage={handleImagePick}
|
||||||
|
onRemoveImage={handleRemoveImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSend}
|
||||||
|
disabled={sendNotification.isPending || message.trim().length === 0 || selectedUserIds.length === 0}
|
||||||
|
style={tw`${
|
||||||
|
sendNotification.isPending || message.trim().length === 0 || selectedUserIds.length === 0
|
||||||
|
? 'bg-gray-300'
|
||||||
|
: 'bg-blue-600'
|
||||||
|
} rounded-xl py-4 items-center shadow-sm`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold text-base`}>
|
||||||
|
{sendNotification.isPending ? 'Sending...' : 'Send Notification'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
||||||
import { AppContainer, MyText, tw } from 'common-ui';
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
export default function UserDetails() {
|
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data: userData, isLoading, error, refetch } = trpc.admin.staffUser.getUserDetails.useQuery(
|
|
||||||
{ userId: id ? parseInt(id) : 0 },
|
|
||||||
{ enabled: !!id }
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSuspensionMutation = trpc.admin.staffUser.updateUserSuspension.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
refetch();
|
|
||||||
Alert.alert('Success', 'User suspension status updated');
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to update suspension');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggleSuspension = () => {
|
|
||||||
if (!userData) return;
|
|
||||||
const newStatus = !userData.isSuspended;
|
|
||||||
updateSuspensionMutation.mutate({
|
|
||||||
userId: userData.id,
|
|
||||||
isSuspended: newStatus,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
|
||||||
<MyText style={tw`text-gray-500`}>Loading user details...</MyText>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !userData) {
|
|
||||||
return (
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
|
||||||
<MaterialIcons name="error-outline" size={48} color="#EF4444" />
|
|
||||||
<MyText style={tw`text-gray-900 text-xl font-bold mt-4 mb-2`}>
|
|
||||||
Error
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-500 text-center`}>
|
|
||||||
{error?.message || "Failed to load user details"}
|
|
||||||
</MyText>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.back()}
|
|
||||||
style={tw`mt-6 bg-gray-900 px-6 py-3 rounded-xl`}
|
|
||||||
>
|
|
||||||
<MyText style={tw`text-white font-bold`}>Go Back</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = userData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`flex-1 bg-gray-50`}>
|
|
||||||
{/* User Info */}
|
|
||||||
<View style={tw`p-4`}>
|
|
||||||
<View style={tw`bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-4`}>
|
|
||||||
<View style={tw`flex-row items-center mb-6`}>
|
|
||||||
<View style={tw`w-16 h-16 bg-blue-100 rounded-full items-center justify-center mr-4`}>
|
|
||||||
<MaterialIcons name="person" size={32} color="#3B82F6" />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<MyText style={tw`text-2xl font-bold text-gray-900`}>{user.name || 'n/a'}</MyText>
|
|
||||||
<MyText style={tw`text-sm text-gray-500`}>User ID: {user.id}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={tw`space-y-4`}>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="phone" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
|
|
||||||
<MyText style={tw`text-gray-700`}>{user.mobile}</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="email" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
|
|
||||||
<MyText style={tw`text-gray-700`}>{user.email}</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="calendar-today" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
|
|
||||||
<MyText style={tw`text-gray-700`}>
|
|
||||||
Added on {dayjs(user.addedOn).format('MMM DD, YYYY')}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="shopping-cart" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
|
|
||||||
<MyText style={tw`text-gray-700`}>
|
|
||||||
{user.lastOrdered
|
|
||||||
? `Last ordered ${dayjs(user.lastOrdered).format('MMM DD, YYYY')}`
|
|
||||||
: 'No orders yet'
|
|
||||||
}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Suspension Status */}
|
|
||||||
<View style={tw`bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-4`}>
|
|
||||||
<View style={tw`flex-row items-center justify-between`}>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons
|
|
||||||
name={user.isSuspended ? "block" : "check-circle"}
|
|
||||||
size={24}
|
|
||||||
color={user.isSuspended ? "#EF4444" : "#10B981"}
|
|
||||||
style={tw`mr-3`}
|
|
||||||
/>
|
|
||||||
<View>
|
|
||||||
<MyText style={tw`font-semibold text-gray-900`}>
|
|
||||||
{user.isSuspended ? 'Suspended' : 'Active'}
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-sm text-gray-500`}>
|
|
||||||
{user.isSuspended ? 'User is suspended' : 'User is active'}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleToggleSuspension}
|
|
||||||
disabled={updateSuspensionMutation.isPending}
|
|
||||||
style={tw`px-4 py-2 rounded-lg ${
|
|
||||||
user.isSuspended ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
} ${updateSuspensionMutation.isPending ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<MyText style={tw`text-white font-semibold text-sm`}>
|
|
||||||
{updateSuspensionMutation.isPending
|
|
||||||
? 'Updating...'
|
|
||||||
: user.isSuspended
|
|
||||||
? 'Revoke Suspension'
|
|
||||||
: 'Suspend User'
|
|
||||||
}
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
215
apps/admin-ui/app/(drawer)/user-management/[id].tsx
Normal file
215
apps/admin-ui/app/(drawer)/user-management/[id].tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import {
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
} from 'common-ui';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: number;
|
||||||
|
readableId: number;
|
||||||
|
totalAmount: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: string;
|
||||||
|
isFlashDelivery: boolean;
|
||||||
|
itemCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderItemProps {
|
||||||
|
order: Order;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'delivered':
|
||||||
|
return 'text-green-600 bg-green-50 border-green-100';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'text-red-600 bg-red-50 border-red-100';
|
||||||
|
default:
|
||||||
|
return 'text-yellow-600 bg-yellow-50 border-yellow-100';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrderItem: React.FC<OrderItemProps> = ({ order, onPress }) => {
|
||||||
|
const statusStyle = getStatusColor(order.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-3 shadow-sm`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center justify-between mb-3`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
||||||
|
#{order.readableId}
|
||||||
|
</MyText>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`ml-2 px-2 py-0.5 bg-amber-100 rounded-full border border-amber-200`}>
|
||||||
|
<MyText style={tw`text-[10px] font-black text-amber-700 uppercase`}>⚡</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={tw`px-3 py-1 rounded-full border ${statusStyle}`}>
|
||||||
|
<MyText style={tw`text-xs font-bold uppercase tracking-wider ${statusStyle.split(' ')[0]}`}>
|
||||||
|
{order.status}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row justify-between items-center`}>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-gray-500 text-sm mb-1`}>
|
||||||
|
{dayjs(order.createdAt).format('MMM DD, YYYY • h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-400 text-xs`}>
|
||||||
|
{order.itemCount} {order.itemCount === 1 ? 'item' : 'items'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-bold text-blue-600`}>
|
||||||
|
₹{order.totalAmount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`mt-3 pt-3 border-t border-gray-100 flex-row items-center justify-center`}>
|
||||||
|
<MyText style={tw`text-blue-600 font-medium text-sm`}>
|
||||||
|
View Order Details
|
||||||
|
</MyText>
|
||||||
|
<MaterialIcons name="chevron-right" size={18} color="#3b82f6" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UserDetails() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const userId = id ? parseInt(id) : 0;
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = trpc.admin.user.getUserDetails.useQuery(
|
||||||
|
{ userId },
|
||||||
|
{ enabled: !!userId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOrderPress = useCallback((orderId: number) => {
|
||||||
|
router.push(`/(drawer)/order-details/${orderId}`);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4`}>Loading user details...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||||
|
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
|
||||||
|
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 mt-2 text-center`}>
|
||||||
|
{error?.message || 'Failed to load user details'}
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => refetch()}
|
||||||
|
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, orders } = data;
|
||||||
|
const displayName = user.name || 'Unnamed User';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`p-2 -ml-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900 ml-2`}>User Details</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{/* User Info Card */}
|
||||||
|
<View style={tw`bg-white p-5 m-4 rounded-2xl shadow-sm border border-gray-100`}>
|
||||||
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
|
<View style={tw`w-12 h-12 bg-blue-50 rounded-full items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="person" size={24} color="#3B82F6" />
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-gray-900 font-bold text-lg mb-0.5`}>
|
||||||
|
{user.mobile || 'No Mobile'}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500`}>
|
||||||
|
{displayName}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`bg-gray-50 p-3 rounded-xl`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialIcons name="access-time" size={18} color="#6B7280" />
|
||||||
|
<MyText style={tw`ml-2 text-gray-600`}>
|
||||||
|
Registered {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Orders Section */}
|
||||||
|
<View style={tw`px-4 pb-8`}>
|
||||||
|
<View style={tw`flex-row items-center justify-between mb-4`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-900`}>Order History</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-sm`}>
|
||||||
|
{orders.length} {orders.length === 1 ? 'order' : 'orders'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<View style={tw`bg-white rounded-xl border border-gray-100 p-8 items-center`}>
|
||||||
|
<MaterialIcons name="shopping-bag" size={48} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center`}>
|
||||||
|
No orders yet
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
orders.map((order) => (
|
||||||
|
<OrderItem
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
onPress={() => handleOrderPress(order.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
apps/admin-ui/app/(drawer)/user-management/index.tsx
Normal file
260
apps/admin-ui/app/(drawer)/user-management/index.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
RefreshControl,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import {
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
SearchBar,
|
||||||
|
MyFlatList,
|
||||||
|
} from 'common-ui';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
totalOrders: number;
|
||||||
|
lastOrderDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserItemProps {
|
||||||
|
user: User;
|
||||||
|
index: number;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserItem: React.FC<UserItemProps> = ({ user, index, onPress }) => {
|
||||||
|
const displayName = user.name || 'Unnamed User';
|
||||||
|
const hasOrders = user.totalOrders > 0;
|
||||||
|
|
||||||
|
const lastOrderText = user.lastOrderDate
|
||||||
|
? formatDistanceToNow(new Date(user.lastOrderDate), { addSuffix: true })
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-3 shadow-sm`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center justify-between`}>
|
||||||
|
{/* Left: Index number */}
|
||||||
|
<View style={tw`w-8 h-8 rounded-full bg-gray-100 items-center justify-center mr-3`}>
|
||||||
|
<MyText style={tw`text-gray-600 text-xs font-bold`}>{index + 1}</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Middle: User Info */}
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
{/* Mobile number - primary identifier */}
|
||||||
|
<MyText style={tw`text-gray-900 font-bold text-base mb-0.5`}>
|
||||||
|
{user.mobile || 'No Mobile'}
|
||||||
|
</MyText>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<MyText style={tw`text-gray-500 text-sm mb-1`}>
|
||||||
|
{displayName}
|
||||||
|
</MyText>
|
||||||
|
|
||||||
|
{/* Registration date */}
|
||||||
|
<MyText style={tw`text-gray-400 text-xs`}>
|
||||||
|
Registered: {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right: Order Stats */}
|
||||||
|
<View style={tw`items-end`}>
|
||||||
|
{/* Total Orders */}
|
||||||
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
|
<MaterialIcons name="shopping-bag" size={14} color={hasOrders ? '#10B981' : '#9CA3AF'} />
|
||||||
|
<MyText style={tw`${hasOrders ? 'text-green-600' : 'text-gray-400'} font-bold text-sm ml-1`}>
|
||||||
|
{user.totalOrders} orders
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Last Order */}
|
||||||
|
<MyText style={tw`text-gray-400 text-xs`}>
|
||||||
|
Last: {lastOrderText}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ListHeaderProps {
|
||||||
|
searchTerm: string;
|
||||||
|
onSearchChange: (text: string) => void;
|
||||||
|
userCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListHeader: React.FC<ListHeaderProps> = ({ searchTerm, onSearchChange, userCount }) => (
|
||||||
|
<View>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View style={tw`px-4 py-3 bg-white border-b border-gray-100`}>
|
||||||
|
<SearchBar
|
||||||
|
value={searchTerm}
|
||||||
|
onChangeText={onSearchChange}
|
||||||
|
placeholder="Search by mobile number..."
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stats Summary */}
|
||||||
|
<View style={tw`px-4 py-2 bg-gray-50`}>
|
||||||
|
<MyText style={tw`text-gray-500 text-sm`}>
|
||||||
|
Showing {userCount} users
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ListFooter: React.FC<{ isFetching: boolean }> = ({ isFetching }) => {
|
||||||
|
if (!isFetching) return null;
|
||||||
|
return (
|
||||||
|
<View style={tw`py-4 items-center`}>
|
||||||
|
<ActivityIndicator size="small" color="#3b82f6" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||||
|
|
||||||
|
// Infinite scroll query
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = trpc.admin.user.getAllUsers.useInfiniteQuery(
|
||||||
|
{
|
||||||
|
limit: 30,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.pages?.length) {
|
||||||
|
setHasLoadedOnce(true);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Flatten all pages and remove duplicates
|
||||||
|
const users = useMemo(() => {
|
||||||
|
const allUsers = data?.pages.flatMap((page) => page.users) || [];
|
||||||
|
// Remove duplicates based on user id
|
||||||
|
const uniqueUsers = allUsers.filter((user, index, self) =>
|
||||||
|
index === self.findIndex((u) => u.id === user.id)
|
||||||
|
);
|
||||||
|
return uniqueUsers;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const handleUserPress = useCallback((userId: number) => {
|
||||||
|
router.push(`/(drawer)/user-management/${userId}`);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const renderUserItem = useCallback(({ item, index }: { item: User; index: number }) => {
|
||||||
|
return <UserItem user={item} index={index} onPress={() => handleUserPress(item.id)} />;
|
||||||
|
}, [handleUserPress]);
|
||||||
|
|
||||||
|
if (isLoading && !hasLoadedOnce) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4`}>Loading users...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||||
|
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
|
||||||
|
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 mt-2 text-center`}>
|
||||||
|
{error?.message || 'Failed to load users'}
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => refetch()}
|
||||||
|
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
{/* Users List */}
|
||||||
|
<MyFlatList
|
||||||
|
data={users}
|
||||||
|
renderItem={renderUserItem}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<ListHeader
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onSearchChange={setSearchTerm}
|
||||||
|
userCount={users.length}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={tw`pb-8`}
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-12`}>
|
||||||
|
<MaterialIcons name="people-outline" size={64} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
|
||||||
|
No users found
|
||||||
|
</MyText>
|
||||||
|
{searchTerm && (
|
||||||
|
<MyText style={tw`text-gray-400 mt-1 text-center`}>
|
||||||
|
Try adjusting your search
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,7 @@ export default function Users() {
|
||||||
const users = data?.pages.flatMap(page => page.users) || [];
|
const users = data?.pages.flatMap(page => page.users) || [];
|
||||||
|
|
||||||
const handleUserPress = (userId: string) => {
|
const handleUserPress = (userId: string) => {
|
||||||
router.push(`/user-details/${userId}`);
|
router.push(`/(drawer)/user-management/${userId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"expo": "~53.0.22",
|
"expo": "~53.0.22",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
ENV_MODE=PROD
|
ENV_MODE=PROD
|
||||||
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
||||||
DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||||
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||||
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||||
PHONE_PE_CLIENT_VERSION=1
|
PHONE_PE_CLIENT_VERSION=1
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,14 @@ export const notifCreds = mf.table('notif_creds', {
|
||||||
lastVerified: timestamp('last_verified'),
|
lastVerified: timestamp('last_verified'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const userNotifications = mf.table('user_notifications', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
imageUrl: varchar('image_url', { length: 500 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
body: text('body').notNull(),
|
||||||
|
applicableUsers: jsonb('applicable_users'),
|
||||||
|
});
|
||||||
|
|
||||||
export const staffRoles = mf.table('staff_roles', {
|
export const staffRoles = mf.table('staff_roles', {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
roleName: staffRoleEnum('role_name').notNull(),
|
roleName: staffRoleEnum('role_name').notNull(),
|
||||||
|
|
@ -588,6 +596,10 @@ export const notifCredsRelations = relations(notifCreds, ({ one }) => ({
|
||||||
user: one(users, { fields: [notifCreds.userId], references: [users.id] }),
|
user: one(users, { fields: [notifCreds.userId], references: [users.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const userNotificationsRelations = relations(userNotifications, ({}) => ({
|
||||||
|
// No relations needed for now
|
||||||
|
}));
|
||||||
|
|
||||||
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
|
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
|
||||||
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
||||||
products: many(productInfo),
|
products: many(productInfo),
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export async function seed() {
|
||||||
{ key: CONST_KEYS.allItemsOrder, value: [] },
|
{ key: CONST_KEYS.allItemsOrder, value: [] },
|
||||||
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
|
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
|
||||||
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
||||||
{ key: CONST_KEYS.appStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
{ key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' },
|
||||||
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
|
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
|
||||||
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
|
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
|
||||||
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { protectedProcedure } from '../trpc-index';
|
import { protectedProcedure } from '../trpc-index';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db } from '../../db/db_index';
|
import { db } from '../../db/db_index';
|
||||||
import { users, complaints } from '../../db/schema';
|
import { users, complaints, orders, orderItems, notifCreds, userNotifications } from '../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, sql, desc, asc, count, max } from 'drizzle-orm';
|
||||||
import { ApiError } from '../../lib/api-error';
|
import { ApiError } from '../../lib/api-error';
|
||||||
|
|
||||||
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
|
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
|
||||||
|
|
@ -60,4 +60,254 @@ export const userRouter = {
|
||||||
unresolvedComplaints: count || 0,
|
unresolvedComplaints: count || 0,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getAllUsers: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
limit: z.number().min(1).max(100).default(50),
|
||||||
|
cursor: z.number().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { limit, cursor, search } = input;
|
||||||
|
|
||||||
|
// Build where conditions
|
||||||
|
const whereConditions = [];
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
whereConditions.push(sql`${users.id} > ${cursor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users with filters applied
|
||||||
|
const usersList = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
mobile: users.mobile,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined)
|
||||||
|
.orderBy(asc(users.id))
|
||||||
|
.limit(limit + 1); // Get one extra to determine if there's more
|
||||||
|
|
||||||
|
// Check if there are more results
|
||||||
|
const hasMore = usersList.length > limit;
|
||||||
|
const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList;
|
||||||
|
|
||||||
|
// Get order stats for each user
|
||||||
|
const userIds = usersToReturn.map(u => u.id);
|
||||||
|
|
||||||
|
let orderCounts: { userId: number; totalOrders: number }[] = [];
|
||||||
|
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = [];
|
||||||
|
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
// Get total orders per user
|
||||||
|
orderCounts = await db
|
||||||
|
.select({
|
||||||
|
userId: orders.userId,
|
||||||
|
totalOrders: count(orders.id),
|
||||||
|
})
|
||||||
|
.from(orders)
|
||||||
|
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
|
||||||
|
.groupBy(orders.userId);
|
||||||
|
|
||||||
|
// Get last order date per user
|
||||||
|
lastOrders = await db
|
||||||
|
.select({
|
||||||
|
userId: orders.userId,
|
||||||
|
lastOrderDate: max(orders.createdAt),
|
||||||
|
})
|
||||||
|
.from(orders)
|
||||||
|
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
|
||||||
|
.groupBy(orders.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lookup maps
|
||||||
|
const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
|
||||||
|
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate]));
|
||||||
|
|
||||||
|
// Combine data
|
||||||
|
const usersWithStats = usersToReturn.map(user => ({
|
||||||
|
...user,
|
||||||
|
totalOrders: orderCountMap.get(user.id) || 0,
|
||||||
|
lastOrderDate: lastOrderMap.get(user.id) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get next cursor
|
||||||
|
const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: usersWithStats,
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getUserDetails: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
userId: z.number(),
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { userId } = input;
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
const user = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
mobile: users.mobile,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
throw new ApiError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all orders for this user with order items count
|
||||||
|
const userOrders = await db
|
||||||
|
.select({
|
||||||
|
id: orders.id,
|
||||||
|
readableId: orders.readableId,
|
||||||
|
totalAmount: orders.totalAmount,
|
||||||
|
createdAt: orders.createdAt,
|
||||||
|
isFlashDelivery: orders.isFlashDelivery,
|
||||||
|
})
|
||||||
|
.from(orders)
|
||||||
|
.where(eq(orders.userId, userId))
|
||||||
|
.orderBy(desc(orders.createdAt));
|
||||||
|
|
||||||
|
// Get order status for each order
|
||||||
|
const orderIds = userOrders.map(o => o.id);
|
||||||
|
|
||||||
|
let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = [];
|
||||||
|
|
||||||
|
if (orderIds.length > 0) {
|
||||||
|
const { orderStatus } = await import('../../db/schema');
|
||||||
|
orderStatuses = await db
|
||||||
|
.select({
|
||||||
|
orderId: orderStatus.orderId,
|
||||||
|
isDelivered: orderStatus.isDelivered,
|
||||||
|
isCancelled: orderStatus.isCancelled,
|
||||||
|
})
|
||||||
|
.from(orderStatus)
|
||||||
|
.where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get item counts for each order
|
||||||
|
const itemCounts = await db
|
||||||
|
.select({
|
||||||
|
orderId: orderItems.orderId,
|
||||||
|
itemCount: count(orderItems.id),
|
||||||
|
})
|
||||||
|
.from(orderItems)
|
||||||
|
.where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`)
|
||||||
|
.groupBy(orderItems.orderId);
|
||||||
|
|
||||||
|
// Create lookup maps
|
||||||
|
const statusMap = new Map(orderStatuses.map(s => [s.orderId, s]));
|
||||||
|
const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount]));
|
||||||
|
|
||||||
|
// Determine status string
|
||||||
|
const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => {
|
||||||
|
if (!status) return 'pending';
|
||||||
|
if (status.isCancelled) return 'cancelled';
|
||||||
|
if (status.isDelivered) return 'delivered';
|
||||||
|
return 'pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combine data
|
||||||
|
const ordersWithDetails = userOrders.map(order => {
|
||||||
|
const status = statusMap.get(order.id);
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
readableId: order.readableId,
|
||||||
|
totalAmount: order.totalAmount,
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
isFlashDelivery: order.isFlashDelivery,
|
||||||
|
status: getStatus(status),
|
||||||
|
itemCount: itemCountMap.get(order.id) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: user[0],
|
||||||
|
orders: ordersWithDetails,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getUsersForNotification: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { search } = input;
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
let usersList;
|
||||||
|
if (search && search.trim()) {
|
||||||
|
usersList = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
mobile: users.mobile,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`);
|
||||||
|
} else {
|
||||||
|
usersList = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
mobile: users.mobile,
|
||||||
|
})
|
||||||
|
.from(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get eligible users (have notif_creds entry)
|
||||||
|
const eligibleUsers = await db
|
||||||
|
.select({ userId: notifCreds.userId })
|
||||||
|
.from(notifCreds);
|
||||||
|
|
||||||
|
const eligibleSet = new Set(eligibleUsers.map(u => u.userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: usersList.map(user => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
mobile: user.mobile,
|
||||||
|
isEligibleForNotif: eligibleSet.has(user.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendNotification: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
userIds: z.array(z.number()),
|
||||||
|
text: z.string().min(1, 'Message is required'),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { userIds, text, imageUrl } = input;
|
||||||
|
|
||||||
|
// Store notification in database
|
||||||
|
await db.insert(userNotifications).values({
|
||||||
|
body: text,
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
applicableUsers: userIds.length > 0 ? userIds : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual push notification logic
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Notification sent to ${userIds.length > 0 ? userIds.length + ' users' : 'all users'}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
@ -109,7 +109,7 @@ export const commonApiRouter = router({
|
||||||
popularItems: consts[CONST_KEYS.popularItems] ?? '5,3,2,4,1',
|
popularItems: consts[CONST_KEYS.popularItems] ?? '5,3,2,4,1',
|
||||||
versionNum: consts[CONST_KEYS.versionNum] ?? '1.1.0',
|
versionNum: consts[CONST_KEYS.versionNum] ?? '1.1.0',
|
||||||
playStoreUrl: consts[CONST_KEYS.playStoreUrl] ?? 'https://play.google.com/store/apps/details?id=in.freshyo.app',
|
playStoreUrl: consts[CONST_KEYS.playStoreUrl] ?? 'https://play.google.com/store/apps/details?id=in.freshyo.app',
|
||||||
appStoreUrl: consts[CONST_KEYS.appStoreUrl] ?? 'https://play.google.com/store/apps/details?id=in.freshyo.app',
|
appStoreUrl: consts[CONST_KEYS.appStoreUrl] ?? 'https://apps.apple.com/in/app/freshyo/id6756889077',
|
||||||
webViewHtml: null,
|
webViewHtml: null,
|
||||||
isWebviewClosable: true,
|
isWebviewClosable: true,
|
||||||
isFlashDeliveryEnabled: consts[CONST_KEYS.isFlashDeliveryEnabled] ?? true,
|
isFlashDeliveryEnabled: consts[CONST_KEYS.isFlashDeliveryEnabled] ?? true,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
cartItems,
|
cartItems,
|
||||||
refunds,
|
refunds,
|
||||||
units,
|
units,
|
||||||
|
userDetails,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { eq, and, inArray, desc, gte, lte } from "drizzle-orm";
|
import { eq, and, inArray, desc, gte, lte } from "drizzle-orm";
|
||||||
import { generateSignedUrlsFromS3Urls } from "../../lib/s3-client";
|
import { generateSignedUrlsFromS3Urls } from "../../lib/s3-client";
|
||||||
|
|
@ -376,6 +377,16 @@ export const orderRouter = router({
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = ctx.user.userId;
|
const userId = ctx.user.userId;
|
||||||
|
|
||||||
|
// Check if user is suspended from placing orders
|
||||||
|
const userDetail = await db.query.userDetails.findFirst({
|
||||||
|
where: eq(userDetails.userId, userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userDetail?.isSuspended) {
|
||||||
|
throw new ApiError("You are suspended from placing orders", 403);
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedItems,
|
selectedItems,
|
||||||
addressId,
|
addressId,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Freshyo",
|
"name": "Freshyo",
|
||||||
"slug": "freshyo",
|
"slug": "freshyo",
|
||||||
"version": "1.2.0",
|
"version": "1.1.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/freshyo-logo.png",
|
"icon": "./assets/images/freshyo-logo.png",
|
||||||
"scheme": "freshyo",
|
"scheme": "freshyo",
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,7 @@ export default function Dashboard() {
|
||||||
<MyText
|
<MyText
|
||||||
style={tw`text-xl font-extrabold text-white tracking-tight drop-shadow-md text-neutral-800`}
|
style={tw`text-xl font-extrabold text-white tracking-tight drop-shadow-md text-neutral-800`}
|
||||||
>
|
>
|
||||||
Our Stores
|
Our Storess
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}>
|
<MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}>
|
||||||
Fresh from our locations
|
Fresh from our locations
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { View, Dimensions } from "react-native";
|
import { View, Dimensions } from "react-native";
|
||||||
import { useRouter, useLocalSearchParams } from "expo-router";
|
import { useRouter, useLocalSearchParams } from "expo-router";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,6 +14,23 @@ import { trpc } from "@/src/trpc-client";
|
||||||
import ProductCard from "@/components/ProductCard";
|
import ProductCard from "@/components/ProductCard";
|
||||||
import FloatingCartBar from "@/components/floating-cart-bar";
|
import FloatingCartBar from "@/components/floating-cart-bar";
|
||||||
|
|
||||||
|
// Debounce hook for search
|
||||||
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get("window");
|
const { width: screenWidth } = Dimensions.get("window");
|
||||||
const itemWidth = (screenWidth - 48) / 2;
|
const itemWidth = (screenWidth - 48) / 2;
|
||||||
|
|
||||||
|
|
@ -22,18 +39,21 @@ export default function SearchResults() {
|
||||||
const { q } = useLocalSearchParams();
|
const { q } = useLocalSearchParams();
|
||||||
const query = (q as string) || "";
|
const query = (q as string) || "";
|
||||||
const [inputQuery, setInputQuery] = useState(query);
|
const [inputQuery, setInputQuery] = useState(query);
|
||||||
const [searchQuery, setSearchQuery] = useState(query);
|
|
||||||
const searchInputRef = useRef<any>(null);
|
const searchInputRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Debounce the search query for automatic search
|
||||||
|
const debouncedQuery = useDebounce(inputQuery, 300);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
// Focus with requestAnimationFrame for better timing
|
||||||
|
requestAnimationFrame(() => {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}, 100);
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: productsData, isLoading, error, refetch } =
|
const { data: productsData, isLoading, error, refetch } =
|
||||||
trpc.common.product.getAllProductsSummary.useQuery({
|
trpc.common.product.getAllProductsSummary.useQuery({
|
||||||
searchQuery: searchQuery || undefined,
|
searchQuery: debouncedQuery || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const products = productsData?.products || [];
|
const products = productsData?.products || [];
|
||||||
|
|
@ -46,9 +66,9 @@ export default function SearchResults() {
|
||||||
refetch();
|
refetch();
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = useCallback(() => {
|
||||||
setSearchQuery(inputQuery);
|
// Search is now automatic via debounce, but keep this for manual submit
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -84,9 +104,22 @@ export default function SearchResults() {
|
||||||
useAddToCartDialog={true}
|
useAddToCartDialog={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
keyExtractor={(item, index) => index.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
columnWrapperStyle={{ gap: 16, justifyContent: "center" }}
|
columnWrapperStyle={{ gap: 16, justifyContent: "center" }}
|
||||||
contentContainerStyle={[tw`pb-24`, { gap: 16 }]}
|
contentContainerStyle={[tw`pb-24`, { gap: 16 }]}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-12 px-4`}>
|
||||||
|
<MaterialIcons name="search-off" size={64} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center text-lg font-medium`}>
|
||||||
|
No products found
|
||||||
|
</MyText>
|
||||||
|
{debouncedQuery && (
|
||||||
|
<MyText style={tw`text-gray-400 mt-2 text-center`}>
|
||||||
|
Try adjusting your search for "{debouncedQuery}"
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={tw`pt-4 pb-2 px-4`}>
|
<View style={tw`pt-4 pb-2 px-4`}>
|
||||||
<SearchBar
|
<SearchBar
|
||||||
|
|
@ -98,8 +131,8 @@ export default function SearchResults() {
|
||||||
/>
|
/>
|
||||||
<View style={tw`flex-row justify-between items-center mb-2`}>
|
<View style={tw`flex-row justify-between items-center mb-2`}>
|
||||||
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
||||||
{searchQuery
|
{debouncedQuery
|
||||||
? `Search Results for "${searchQuery}"`
|
? `Search Results for "${debouncedQuery}"`
|
||||||
: "All Products"}
|
: "All Products"}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, ActivityIndicator } from 'react-native';
|
import { View, ActivityIndicator, Platform } from 'react-native';
|
||||||
import { tw, theme, MyText, MyTouchableOpacity , BottomDialog } from 'common-ui';
|
import { tw, theme, MyText, MyTouchableOpacity , BottomDialog } from 'common-ui';
|
||||||
import { trpc, trpcClient } from '@/src/trpc-client';
|
import { trpc, trpcClient } from '@/src/trpc-client';
|
||||||
import { useGetEssentialConsts } from '@/src/api-hooks/essential-consts.api';
|
import { useGetEssentialConsts } from '@/src/api-hooks/essential-consts.api';
|
||||||
|
|
@ -15,7 +15,10 @@ const HealthTestWrapper: React.FC<HealthTestWrapperProps> = ({ children }) => {
|
||||||
const { data: backendConsts } = useGetEssentialConsts();
|
const { data: backendConsts } = useGetEssentialConsts();
|
||||||
|
|
||||||
const versionFromBackend = backendConsts?.versionNum;
|
const versionFromBackend = backendConsts?.versionNum;
|
||||||
const appUrl = backendConsts?.playStoreUrl;
|
|
||||||
|
const appUrl = Platform.OS === 'ios'
|
||||||
|
? backendConsts?.appStoreUrl
|
||||||
|
: backendConsts?.playStoreUrl;
|
||||||
|
|
||||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||||
|
|
||||||
|
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -57,6 +57,7 @@
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"expo": "~53.0.22",
|
"expo": "~53.0.22",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
|
|
@ -13042,6 +13043,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,10 @@ const isDevMode = Constants.executionEnvironment !== "standalone";
|
||||||
// const BASE_API_URL = API_URL;
|
// const BASE_API_URL = API_URL;
|
||||||
// const BASE_API_URL = 'http://10.0.2.2:4000';
|
// const BASE_API_URL = 'http://10.0.2.2:4000';
|
||||||
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
||||||
const BASE_API_URL = 'http://192.168.1.3:4000';
|
// const BASE_API_URL = 'http://192.168.1.3:4000';
|
||||||
// let BASE_API_URL = "https://mf.freshyo.in";
|
// let BASE_API_URL = "https://mf.freshyo.in";
|
||||||
// let BASE_API_URL = 'http://192.168.100.104:4000';
|
// let BASE_API_URL = 'http://192.168.100.104:4000';
|
||||||
// let BASE_API_URL = 'http://192.168.29.176:4000';
|
let BASE_API_URL = 'http://192.168.29.176:4000';
|
||||||
|
|
||||||
// if(isDevMode) {
|
// if(isDevMode) {
|
||||||
// }
|
// }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue