This commit is contained in:
shafi54 2026-02-08 15:46:04 +05:30
parent d599c2e004
commit 5b19a0486c
22 changed files with 1130 additions and 216 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -114,20 +114,34 @@ function CustomDrawerContent() {
<MaterialIcons name="code" size={size} color={color} /> <MaterialIcons name="code" size={size} color={color} />
)} )}
/> />
<DrawerItem <DrawerItem
label="Stores" label="Stores"
onPress={() => router.push("/(drawer)/stores" as any)} onPress={() => router.push("/(drawer)/stores" as any)}
icon={({ color, size }) => ( icon={({ color, size }) => (
<MaterialIcons name="store" size={size} color={color} /> <MaterialIcons name="store" size={size} color={color} />
)} )}
/> />
<DrawerItem <DrawerItem
label="Logout" label="User Management"
onPress={() => logout()} onPress={() => router.push("/(drawer)/user-management" as any)}
icon={({ color, size }) => ( icon={({ color, size }) => (
<MaterialIcons name="logout" size={size} color={color} /> <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> </DrawerContentScrollView>
); );
} }
@ -213,10 +227,12 @@ export default function Layout() {
<Drawer.Screen name="slots" options={{ title: "Slots" }} /> <Drawer.Screen name="slots" options={{ title: "Slots" }} />
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} /> <Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
<Drawer.Screen name="stores" options={{ title: "Stores" }} /> <Drawer.Screen name="stores" options={{ title: "Stores" }} />
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} /> <Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
<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> <Drawer.Screen name="user-management" options={{ title: "User Management" }} />
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
</Drawer>
); );
} }

View file

@ -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;
@ -183,16 +183,34 @@ export default function Dashboard() {
iconColor: '#EAB308', iconColor: '#EAB308',
iconBg: '#FEF9C3', iconBg: '#FEF9C3',
}, },
{ {
title: 'App Constants', title: 'App Constants',
icon: 'settings-applications', icon: 'settings-applications',
description: 'Customize app settings', description: 'Customize app settings',
route: '/(drawer)/customize-app', route: '/(drawer)/customize-app',
category: 'settings', category: 'settings',
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' },
]; ];

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

View file

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

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

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

View file

@ -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 (

View file

@ -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",

View file

@ -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

View file

@ -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),

View file

@ -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' },

View file

@ -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'}`,
};
}),
}; };

View file

@ -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,

View file

@ -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,

View file

@ -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",

View file

@ -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

View file

@ -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>

View file

@ -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
View file

@ -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",

View file

@ -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) {
// } // }