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
|
|
@ -114,20 +114,34 @@ function CustomDrawerContent() {
|
|||
<MaterialIcons name="code" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Stores"
|
||||
onPress={() => router.push("/(drawer)/stores" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="store" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Logout"
|
||||
onPress={() => logout()}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="logout" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Stores"
|
||||
onPress={() => router.push("/(drawer)/stores" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<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
|
||||
label="Logout"
|
||||
onPress={() => logout()}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="logout" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
</DrawerContentScrollView>
|
||||
);
|
||||
}
|
||||
|
|
@ -213,10 +227,12 @@ export default function Layout() {
|
|||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
|
||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||
</Drawer>
|
||||
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ interface MenuItem {
|
|||
icon: string;
|
||||
description?: string;
|
||||
route: string;
|
||||
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
|
||||
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings' | 'users';
|
||||
iconColor?: string;
|
||||
iconBg?: string;
|
||||
badgeCount?: number;
|
||||
|
|
@ -183,16 +183,34 @@ export default function Dashboard() {
|
|||
iconColor: '#EAB308',
|
||||
iconBg: '#FEF9C3',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
icon: 'settings-applications',
|
||||
description: 'Customize app settings',
|
||||
route: '/(drawer)/customize-app',
|
||||
category: 'settings',
|
||||
iconColor: '#7C3AED',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
];
|
||||
{
|
||||
title: 'App Constants',
|
||||
icon: 'settings-applications',
|
||||
description: 'Customize app settings',
|
||||
route: '/(drawer)/customize-app',
|
||||
category: 'settings',
|
||||
iconColor: '#7C3AED',
|
||||
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');
|
||||
|
||||
|
|
@ -200,6 +218,7 @@ export default function Dashboard() {
|
|||
{ key: 'orders', title: 'Orders', icon: 'receipt-long' },
|
||||
{ key: 'products', title: 'Products & Inventory', icon: 'inventory' },
|
||||
{ key: 'marketing', title: 'Marketing & Promotions', icon: 'campaign' },
|
||||
{ key: 'users', title: 'User Management', icon: 'people' },
|
||||
{ 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 handleUserPress = (userId: string) => {
|
||||
router.push(`/user-details/${userId}`);
|
||||
router.push(`/(drawer)/user-management/${userId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"@trpc/react-query": "^11.6.0",
|
||||
"axios": "^1.11.0",
|
||||
"buffer": "^6.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"expo": "~53.0.22",
|
||||
"expo-blur": "~14.1.5",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
ENV_MODE=PROD
|
||||
# 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=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
|
||||
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||
PHONE_PE_CLIENT_VERSION=1
|
||||
|
|
|
|||
|
|
@ -419,6 +419,14 @@ export const notifCreds = mf.table('notif_creds', {
|
|||
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', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
roleName: staffRoleEnum('role_name').notNull(),
|
||||
|
|
@ -588,6 +596,10 @@ export const notifCredsRelations = relations(notifCreds, ({ one }) => ({
|
|||
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 }) => ({
|
||||
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
||||
products: many(productInfo),
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export async function seed() {
|
|||
{ key: CONST_KEYS.allItemsOrder, value: [] },
|
||||
{ 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.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.supportMobile, value: '8688182552' },
|
||||
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { protectedProcedure } from '../trpc-index';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../../db/db_index';
|
||||
import { users, complaints } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users, complaints, orders, orderItems, notifCreds, userNotifications } from '../../db/schema';
|
||||
import { eq, sql, desc, asc, count, max } from 'drizzle-orm';
|
||||
import { ApiError } from '../../lib/api-error';
|
||||
|
||||
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
|
||||
|
|
@ -60,4 +60,254 @@ export const userRouter = {
|
|||
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',
|
||||
versionNum: consts[CONST_KEYS.versionNum] ?? '1.1.0',
|
||||
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,
|
||||
isWebviewClosable: true,
|
||||
isFlashDeliveryEnabled: consts[CONST_KEYS.isFlashDeliveryEnabled] ?? true,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
cartItems,
|
||||
refunds,
|
||||
units,
|
||||
userDetails,
|
||||
} from "../../db/schema";
|
||||
import { eq, and, inArray, desc, gte, lte } from "drizzle-orm";
|
||||
import { generateSignedUrlsFromS3Urls } from "../../lib/s3-client";
|
||||
|
|
@ -376,6 +377,16 @@ export const orderRouter = router({
|
|||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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 {
|
||||
selectedItems,
|
||||
addressId,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Freshyo",
|
||||
"slug": "freshyo",
|
||||
"version": "1.2.0",
|
||||
"version": "1.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/freshyo-logo.png",
|
||||
"scheme": "freshyo",
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ export default function Dashboard() {
|
|||
<MyText
|
||||
style={tw`text-xl font-extrabold text-white tracking-tight drop-shadow-md text-neutral-800`}
|
||||
>
|
||||
Our Stores
|
||||
Our Storess
|
||||
</MyText>
|
||||
<MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}>
|
||||
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 { useRouter, useLocalSearchParams } from "expo-router";
|
||||
import {
|
||||
|
|
@ -14,6 +14,23 @@ import { trpc } from "@/src/trpc-client";
|
|||
import ProductCard from "@/components/ProductCard";
|
||||
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 itemWidth = (screenWidth - 48) / 2;
|
||||
|
||||
|
|
@ -22,18 +39,21 @@ export default function SearchResults() {
|
|||
const { q } = useLocalSearchParams();
|
||||
const query = (q as string) || "";
|
||||
const [inputQuery, setInputQuery] = useState(query);
|
||||
const [searchQuery, setSearchQuery] = useState(query);
|
||||
const searchInputRef = useRef<any>(null);
|
||||
|
||||
// Debounce the search query for automatic search
|
||||
const debouncedQuery = useDebounce(inputQuery, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
// Focus with requestAnimationFrame for better timing
|
||||
requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { data: productsData, isLoading, error, refetch } =
|
||||
trpc.common.product.getAllProductsSummary.useQuery({
|
||||
searchQuery: searchQuery || undefined,
|
||||
searchQuery: debouncedQuery || undefined,
|
||||
});
|
||||
|
||||
const products = productsData?.products || [];
|
||||
|
|
@ -46,9 +66,9 @@ export default function SearchResults() {
|
|||
refetch();
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchQuery(inputQuery);
|
||||
};
|
||||
const handleSearch = useCallback(() => {
|
||||
// Search is now automatic via debounce, but keep this for manual submit
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -84,9 +104,22 @@ export default function SearchResults() {
|
|||
useAddToCartDialog={true}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item, index) => index.toString()}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
columnWrapperStyle={{ gap: 16, justifyContent: "center" }}
|
||||
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={
|
||||
<View style={tw`pt-4 pb-2 px-4`}>
|
||||
<SearchBar
|
||||
|
|
@ -98,8 +131,8 @@ export default function SearchResults() {
|
|||
/>
|
||||
<View style={tw`flex-row justify-between items-center mb-2`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
||||
{searchQuery
|
||||
? `Search Results for "${searchQuery}"`
|
||||
{debouncedQuery
|
||||
? `Search Results for "${debouncedQuery}"`
|
||||
: "All Products"}
|
||||
</MyText>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { trpc, trpcClient } from '@/src/trpc-client';
|
||||
import { useGetEssentialConsts } from '@/src/api-hooks/essential-consts.api';
|
||||
|
|
@ -15,7 +15,10 @@ const HealthTestWrapper: React.FC<HealthTestWrapperProps> = ({ children }) => {
|
|||
const { data: backendConsts } = useGetEssentialConsts();
|
||||
|
||||
const versionFromBackend = backendConsts?.versionNum;
|
||||
const appUrl = backendConsts?.playStoreUrl;
|
||||
|
||||
const appUrl = Platform.OS === 'ios'
|
||||
? backendConsts?.appStoreUrl
|
||||
: backendConsts?.playStoreUrl;
|
||||
|
||||
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",
|
||||
"axios": "^1.11.0",
|
||||
"buffer": "^6.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"expo": "~53.0.22",
|
||||
"expo-blur": "~14.1.5",
|
||||
|
|
@ -13042,6 +13043,16 @@
|
|||
"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": {
|
||||
"version": "1.11.19",
|
||||
"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 = 'http://10.0.2.2: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 = '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) {
|
||||
// }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue