Compare commits

..

58 commits

Author SHA1 Message Date
shafi54
5ed889a34f Merge branch 'main' of https://git.technocracy.ovh/shafi/freshyo 2026-03-23 03:23:15 +05:30
shafi54
3ddc939a48 enh 2026-03-23 03:22:20 +05:30
shafi54
24252b717b enh 2026-03-22 21:43:44 +05:30
shafi54
78305e1670 enh 2026-03-22 21:28:32 +05:30
shafi54
1a3fe7826f enh 2026-03-15 22:38:17 +05:30
shafi54
79bf6782f5 enh 2026-03-10 10:21:49 +05:30
e5f80c9237 Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2026-03-09 17:30:04 +00:00
shafi54
2f65e9ae80 enh 2026-03-09 22:58:14 +05:30
shafi54
e10e94bf72 enh 2026-03-09 22:02:26 +05:30
b881ebd19b Merge pull request 'merge test' (#1) from test into main
Reviewed-on: #1
2026-03-09 15:50:27 +00:00
shafi54
e5e26d9d5b enh 2026-03-09 21:06:39 +05:30
shafi54
728ed3fa31 enh 2026-03-09 21:05:58 +05:30
shafi54
d08020ff80 enh 2026-03-08 01:38:03 +05:30
shafi54
1df3d8ff16 enh 2026-03-08 01:04:09 +05:30
shafi54
5e550104d3 enh 2026-03-08 01:01:34 +05:30
shafi54
1a4a2aadc5 enh 2026-03-08 00:32:25 +05:30
shafi54
a4fcdf77dc enh 2026-03-08 00:31:55 +05:30
shafi54
8fc603db0a enh 2026-03-07 16:24:24 +05:30
shafi54
bbf5d1657b enh 2026-03-05 16:31:58 +05:30
shafi54
72475f7f71 enh 2026-03-04 23:36:11 +05:30
shafi54
8d702ed2ff enh 2026-03-04 21:48:32 +05:30
shafi54
32feef5621 enh 2026-03-04 19:23:21 +05:30
shafi54
1a74efdd3c enh 2026-03-04 19:18:59 +05:30
shafi54
ffaade32d6 enh 2026-03-04 01:12:43 +05:30
shafi54
dc644aef7e enh 2026-03-04 01:03:13 +05:30
shafi54
ed7318f9ee enh 2026-03-04 00:17:52 +05:30
shafi54
7fa44712bf enh 2026-02-28 00:51:02 +05:30
shafi54
5bd0f8ded7 enh 2026-02-28 00:26:05 +05:30
shafi54
6bcf080593 enh 2026-02-26 22:43:05 +05:30
shafi54
6c2b7f9bfd enh 2026-02-26 22:42:09 +05:30
shafi54
1dca7a3454 enh 2026-02-21 19:46:43 +05:30
shafi54
04ea8c9284 enh 2026-02-21 19:42:18 +05:30
shafi54
a875e63751 enh 2026-02-21 18:13:46 +05:30
shafi54
b2a35176dd enh 2026-02-21 16:13:23 +05:30
shafi54
10d13408d3 enh 2026-02-20 12:42:59 +05:30
shafi54
d4afa75eaf enh 2026-02-20 11:40:59 +05:30
shafi54
40a98e38f5 enh 2026-02-18 23:51:28 +05:30
shafi54
a1aee3262b enh 2026-02-18 02:29:01 +05:30
shafi54
da47a0a014 enh 2026-02-16 18:13:09 +05:30
shafi54
83e733fdd1 enh 2026-02-12 01:49:36 +05:30
shafi54
e546c52c05 enh 2026-02-09 01:20:48 +05:30
shafi54
8fe3e4a301 enh 2026-02-09 01:04:43 +05:30
shafi54
002b73cf87 enh 2026-02-09 00:59:44 +05:30
shafi54
bce754d0a1 enh 2026-02-09 00:53:57 +05:30
shafi54
31395e5cc7 enh 2026-02-09 00:40:57 +05:30
shafi54
2a106b5467 enh 2026-02-09 00:15:37 +05:30
shafi54
ffa4a0ed44 enh 2026-02-08 21:52:26 +05:30
shafi54
37f5d48bbb enh 2026-02-08 21:49:07 +05:30
shafi54
3487501d72 enh 2026-02-08 21:43:19 +05:30
shafi54
637c90a771 enh 2026-02-08 21:36:45 +05:30
shafi54
dc11e77707 enh 2026-02-08 16:10:31 +05:30
shafi54
c7412d774a enh 2026-02-08 16:05:54 +05:30
shafi54
ee0b71fcd3 enh 2026-02-08 16:02:57 +05:30
shafi54
5b19a0486c enh 2026-02-08 15:46:04 +05:30
shafi54
d599c2e004 enh 2026-02-08 03:01:33 +05:30
shafi54
55c41fa0af enh 2026-02-08 01:45:50 +05:30
shafi54
d1d7db55a0 enh 2026-02-08 00:50:03 +05:30
shafi54
78e90fd398 enh 2026-02-08 00:10:47 +05:30
224 changed files with 31370 additions and 2021 deletions

8
.expo/README.md Normal file
View file

@ -0,0 +1,8 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.

3
.expo/devices.json Normal file
View file

@ -0,0 +1,3 @@
{
"devices": []
}

View file

@ -0,0 +1,4 @@
{
"dependencies": "c63a16a85154f1ea03750b1df53dcdee0200585f",
"devDependencies": "0a1ec1c6df1c9d5100926df058dd0824b1293819"
}

View file

@ -1,3 +1,7 @@
{ {
"expo": {} "expo": {
"ios": {
"bundleIdentifier": "com.mohammedshafiuddin54.meat-farmer-monorepo"
}
}
} }

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
{ {
"dependencies": "091948e86692e0cce7744b6b0543448538c3125a", "dependencies": "4650ceb7c30aaa4d5fd17b9577e186af7a84b50d",
"devDependencies": "b3b38265f32b99a8299270a292f38ca26288d53d" "devDependencies": "b3b38265f32b99a8299270a292f38ca26288d53d"
} }

File diff suppressed because one or more lines are too long

View file

@ -120,6 +120,20 @@ function CustomDrawerContent() {
icon={({ color, size }) => ( icon={({ color, size }) => (
<MaterialIcons name="store" size={size} color={color} /> <MaterialIcons name="store" size={size} color={color} />
)} )}
/>
<DrawerItem
label="User Management"
onPress={() => router.push("/(drawer)/user-management" as any)}
icon={({ color, size }) => (
<MaterialIcons name="people" size={size} color={color} />
)}
/>
<DrawerItem
label="Send Notifications"
onPress={() => router.push("/(drawer)/send-notifications" as any)}
icon={({ color, size }) => (
<MaterialIcons name="campaign" size={size} color={color} />
)}
/> />
<DrawerItem <DrawerItem
label="Logout" label="Logout"
@ -215,8 +229,9 @@ export default function Layout() {
<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="rebalance-orders" options={{ title: "Rebalance Orders" }} /> <Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
</Drawer> </Drawer>
); );
} }

View file

@ -1,25 +1,58 @@
import React, { useState } from "react"; import React, { useState, useCallback, useMemo } from "react";
import { View, Text, TouchableOpacity, Alert } from "react-native"; import { View, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, usePagination, ImageViewerURI } from "common-ui"; import { MaterialIcons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, ImageViewerURI } from "common-ui";
import { trpc } from "@/src/trpc-client"; import { trpc } from "@/src/trpc-client";
export default function Complaints() { export default function Complaints() {
const { currentPage, pageSize, PaginationComponent } = usePagination(5); // 5 complaints per page for testing const router = useRouter();
const { data, isLoading, error, refetch } = trpc.admin.complaint.getAll.useQuery({ const [dialogOpen, setDialogOpen] = useState(false);
page: currentPage, const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
limit: pageSize,
}); const {
const resolveComplaint = trpc.admin.complaint.resolve.useMutation(); data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = trpc.admin.complaint.getAll.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
useMarkDataFetchers(() => { useMarkDataFetchers(() => {
refetch(); refetch();
}); });
const [dialogOpen, setDialogOpen] = useState(false); const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
const complaints = data?.complaints || []; const complaints = useMemo(() => {
const totalCount = data?.totalCount || 0; const allComplaints = data?.pages.flatMap((page) => page.complaints) || [];
return allComplaints.filter(
(complaint, index, self) =>
index === self.findIndex((c) => c.id === complaint.id)
);
}, [data]);
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleUserPress = useCallback((userId: number) => {
router.push(`/(drawer)/user-management/${userId}`);
}, [router]);
const handleOrderPress = useCallback((orderId: number) => {
router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
}, [router]);
const handleMarkResolved = (id: number) => { const handleMarkResolved = (id: number) => {
setSelectedComplaintId(id); setSelectedComplaintId(id);
@ -52,35 +85,72 @@ export default function Complaints() {
if (isLoading) { if (isLoading) {
return ( return (
<View style={tw`flex-1 justify-center items-center`}> <View style={tw`flex-1 justify-center items-center bg-gray-50`}>
<MyText style={tw`text-gray-600`}>Loading complaints...</MyText> <ActivityIndicator size="large" color="#3B82F6" />
<MyText style={tw`text-gray-500 mt-4`}>Loading complaints...</MyText>
</View> </View>
); );
} }
if (error) { if (isError) {
return ( return (
<View style={tw`flex-1 justify-center items-center`}> <View style={tw`flex-1 justify-center items-center bg-gray-50 p-6`}>
<MyText style={tw`text-red-600`}>Error loading complaints</MyText> <MaterialIcons name="error-outline" size={48} color="#EF4444" />
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
<MyText style={tw`text-gray-500 text-center mt-2 mb-6`}>
{error?.message || "Failed to load complaints"}
</MyText>
<TouchableOpacity
onPress={() => refetch()}
style={tw`bg-blue-600 px-6 py-3 rounded-full`}
>
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
</TouchableOpacity>
</View> </View>
); );
} }
return ( return (
<View style={tw`flex-1`}> <View style={tw`flex-1 bg-gray-50`}>
<MyFlatList <MyFlatList
style={tw`flex-1 bg-white`} style={tw`flex-1`}
contentContainerStyle={tw`px-4 pb-6`} contentContainerStyle={tw`px-4 py-4`}
data={complaints} data={complaints}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg`}> <View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-sm border border-gray-100`}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Complaint #{item.id}</MyText> <View style={tw`flex-row justify-between items-start mb-2`}>
<MyText style={tw`text-base mb-2 text-gray-700`}>{item.text}</MyText> <MyText style={tw`text-lg font-bold text-gray-900`}>
Complaint #{item.id}
</MyText>
<View
style={tw`px-2.5 py-1 rounded-full ${
item.status === "resolved"
? "bg-green-100 border border-green-200"
: "bg-amber-100 border border-amber-200"
}`}
>
<MyText
style={tw`text-xs font-semibold ${
item.status === "resolved" ? "text-green-700" : "text-amber-700"
}`}
>
{item.status === "resolved" ? "Resolved" : "Pending"}
</MyText>
</View>
</View>
<MyText style={tw`text-base text-gray-700 mb-3 leading-5`}>
{item.text}
</MyText>
{item.images && item.images.length > 0 && ( {item.images && item.images.length > 0 && (
<View style={tw`mt-3 mb-3`}> <View style={tw`mb-3`}>
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>Attached Images:</MyText> <MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>
Attached Images:
</MyText>
<View style={tw`flex-row flex-wrap gap-2`}> <View style={tw`flex-row flex-wrap gap-2`}>
{item.images.map((imageUri: string, index: number) => ( {item.images.map((imageUri: string, index: number) => (
<ImageViewerURI <ImageViewerURI
@ -93,53 +163,64 @@ export default function Complaints() {
</View> </View>
)} )}
<View style={tw`flex-row items-center mb-2`}> <View style={tw`flex-row items-center gap-2 mb-3`}>
<MaterialIcons name="person" size={14} color="#6B7280" />
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() => item.userId && handleUserPress(item.userId)}
Alert.alert("User Page", "User page coming soon")
}
> >
<MyText style={tw`text-sm text-blue-600 underline`}> <MyText style={tw`text-sm text-blue-600 underline`}>
{item.userName} {item.userName || item.userMobile || "Unknown User"}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
<MyText style={tw`text-sm text-gray-600 mx-2`}>|</MyText>
{item.orderId && ( {item.orderId && (
<>
<MyText style={tw`text-sm text-gray-400`}>|</MyText>
<MaterialIcons name="shopping-bag" size={14} color="#6B7280" />
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() => item.orderId && handleOrderPress(item.orderId)}
Alert.alert("Order Page", "Order page coming soon")
}
> >
<MyText style={tw`text-sm text-blue-600 underline`}> <MyText style={tw`text-sm text-blue-600 underline`}>
Order #{item.orderId} Order #{item.orderId}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</>
)} )}
</View> </View>
<MyText
style={tw`text-sm ${
item.status === "resolved" ? "text-green-600" : "text-red-600"
}`}
>
Status: {item.status}
</MyText>
{item.status === "pending" && ( {item.status === "pending" && (
<TouchableOpacity <TouchableOpacity
onPress={() => handleMarkResolved(item.id)} onPress={() => handleMarkResolved(item.id)}
style={tw`mt-2 bg-blue-500 p-3 rounded-lg shadow-md`} style={tw`bg-blue-500 py-3 rounded-xl items-center shadow-sm mt-2`}
> >
<MyText style={tw`text-white text-center font-semibold`}>Mark as Resolved</MyText> <MyText style={tw`text-white font-semibold`}>
Resolve Complaint
</MyText>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
)} )}
ListEmptyComponent={ ListEmptyComponent={
<View style={tw`flex-1 justify-center items-center py-10`}> <View style={tw`flex-1 justify-center items-center py-20`}>
<MyText style={tw`text-gray-500 text-center`}>No complaints found</MyText> <View style={tw`bg-white p-6 rounded-full shadow-sm mb-4`}>
<MaterialIcons name="inbox" size={48} color="#D1D5DB" />
</View>
<MyText style={tw`text-gray-900 text-lg font-bold`}>
No complaints
</MyText>
<MyText style={tw`text-gray-500 text-center mt-2`}>
All complaints will appear here
</MyText>
</View> </View>
} }
ListFooterComponent={
isFetchingNextPage ? (
<View style={tw`py-4 items-center flex-row justify-center`}>
<ActivityIndicator size="small" color="#3B82F6" />
<MyText style={tw`text-gray-500 ml-2`}>Loading more...</MyText>
</View>
) : null
}
/> />
<PaginationComponent totalCount={totalCount} />
<ConfirmationDialog <ConfirmationDialog
open={dialogOpen} open={dialogOpen}
positiveAction={handleConfirmResolve} positiveAction={handleConfirmResolve}
@ -148,10 +229,11 @@ export default function Complaints() {
setDialogOpen(false); setDialogOpen(false);
setSelectedComplaintId(null); setSelectedComplaintId(null);
}} }}
title="Mark as Resolved" title="Resolve Complaint"
message="Add admin notes for this resolution:" message="Add admin notes for this resolution:"
confirmText="Resolve" confirmText="Resolve"
cancelText="Cancel" cancelText="Cancel"
isLoading={resolveComplaint.isPending}
/> />
</View> </View>
); );

View file

@ -17,6 +17,13 @@ export default function Layout() {
headerShown: false, headerShown: false,
}} }}
/> />
<Stack.Screen
name="all-items-order"
options={{
title: "All Items Order",
headerShown: false,
}}
/>
</Stack> </Stack>
); );
} }

View file

@ -0,0 +1,391 @@
import React, { useState, useEffect, useCallback } from "react";
import {
View,
Alert,
ActivityIndicator,
Dimensions,
StyleSheet,
} from "react-native";
import { TouchableOpacity } from "react-native-gesture-handler";
import { Image } from "expo-image";
import DraggableFlatList, {
ScaleDecorator,
} from "react-native-draggable-flatlist";
import {
AppContainer,
MyText,
tw,
MyTouchableOpacity,
} from "common-ui";
import { useRouter } from "expo-router";
import { trpc } from "../../../src/trpc-client";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { useQueryClient } from "@tanstack/react-query";
const { width: screenWidth } = Dimensions.get("window");
// Item takes full width minus padding
const itemWidth = screenWidth - 48; // 24px padding each side
const itemHeight = 80;
interface Product {
id: number;
name: string;
images: string[];
isOutOfStock: boolean;
}
interface ProductItemProps {
item: Product;
drag: () => void;
isActive: boolean;
}
const ProductItem: React.FC<ProductItemProps> = ({
item,
drag,
isActive,
}) => {
return (
<ScaleDecorator>
<TouchableOpacity
onLongPress={drag}
activeOpacity={1}
style={[
styles.item,
isActive && styles.activeItem,
item.isOutOfStock && styles.outOfStock,
]}
>
{/* Drag Handle */}
<View style={styles.dragHandle}>
<MaterialIcons
name="drag-indicator"
size={24}
color={isActive ? "#3b82f6" : "#9ca3af"}
/>
</View>
{/* Product Image */}
{item.images?.[0] ? (
<Image
source={{ uri: item.images[0] }}
style={styles.image}
resizeMode="cover"
/>
) : (
<View style={styles.placeholderImage}>
<MaterialIcons name="image" size={24} color="#9ca3af" />
</View>
)}
{/* Product Info */}
<View style={styles.info}>
<MyText style={styles.name} numberOfLines={1}>
{item.name.length > 30 ? item.name.substring(0, 30) + '...' : item.name}
</MyText>
{item.isOutOfStock && (
<MaterialIcons name="remove-circle" size={16} color="#dc2626" />
)}
</View>
</TouchableOpacity>
</ScaleDecorator>
);
};
export default function AllItemsOrder() {
const router = useRouter();
const queryClient = useQueryClient();
const [products, setProducts] = useState<Product[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Get current order from constants
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery({});
const updateConstants = trpc.admin.const.updateConstants.useMutation();
// Initialize products from constants
useEffect(() => {
if (allProducts?.products) {
const allItemsOrderConstant = constants?.find(c => c.key === 'allItemsOrder');
let orderedIds: number[] = [];
if (allItemsOrderConstant) {
const value = allItemsOrderConstant.value;
if (Array.isArray(value)) {
orderedIds = value.map((id: any) => parseInt(id));
} else if (typeof value === 'string') {
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter(id => !isNaN(id));
}
}
// Create product map for quick lookup
const productMap = new Map(allProducts.products.map(p => [p.id, p]));
// Sort products based on order, products not in order go to end
const sortedProducts: Product[] = [];
// First add products in the specified order
for (const id of orderedIds) {
const product = productMap.get(id);
if (product) {
sortedProducts.push({
id: product.id,
name: product.name,
images: product.images || [],
isOutOfStock: product.isOutOfStock || false,
});
productMap.delete(id);
}
}
// Then add remaining products (not in order yet)
for (const product of productMap.values()) {
sortedProducts.push({
id: product.id,
name: product.name,
images: product.images || [],
isOutOfStock: product.isOutOfStock || false,
});
}
setProducts(sortedProducts);
}
}, [constants, allProducts]);
const handleDragEnd = useCallback(({ data }: { data: Product[] }) => {
setProducts(data);
setHasChanges(true);
}, []);
const renderItem = useCallback(({ item, drag, isActive }: { item: Product; drag: () => void; isActive: boolean }) => {
return (
<ProductItem
item={item}
drag={drag}
isActive={isActive}
/>
);
}, []);
const handleSave = () => {
const productIds = products.map(p => p.id);
updateConstants.mutate(
{
constants: [{
key: 'allItemsOrder',
value: productIds
}]
},
{
onSuccess: () => {
setHasChanges(false);
Alert.alert('Success', 'All items order updated successfully!');
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] });
},
onError: (error) => {
Alert.alert('Error', 'Failed to update items order. Please try again.');
console.error('Update all items order error:', error);
}
}
);
};
// Show loading state while data is being fetched
if (isLoadingConstants || isLoadingProducts) {
return (
<AppContainer>
<View style={tw`flex-1 bg-gray-50`}>
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
<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`}>All Items Order</MyText>
<View style={tw`w-16`} />
</View>
<View style={tw`flex-1 justify-center items-center p-8`}>
<ActivityIndicator size="large" color="#3b82f6" />
<MyText style={tw`text-gray-500 mt-4 text-center`}>
{isLoadingConstants ? 'Loading order...' : 'Loading products...'}
</MyText>
</View>
</View>
</AppContainer>
);
}
// Show error state if queries failed
if (constantsError || productsError) {
return (
<AppContainer>
<View style={tw`flex-1 bg-gray-50`}>
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
<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`}>All Items Order</MyText>
<View style={tw`w-16`} />
</View>
<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`}>
{constantsError ? 'Failed to load order' : 'Failed to load products'}
</MyText>
<TouchableOpacity
onPress={() => router.back()}
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
>
<MyText style={tw`text-white font-semibold`}>Go Back</MyText>
</TouchableOpacity>
</View>
</View>
</AppContainer>
);
}
return (
<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 justify-between`}>
<MyTouchableOpacity
onPress={() => router.back()}
style={tw`p-2 -ml-4`}
>
<MaterialIcons name="chevron-left" size={24} color="#374151" />
</MyTouchableOpacity>
<MyText style={tw`text-xl font-bold text-gray-900`}>All Items Order</MyText>
<MyTouchableOpacity
onPress={handleSave}
disabled={!hasChanges || updateConstants.isPending}
style={tw`px-4 py-2 rounded-lg ${
hasChanges && !updateConstants.isPending
? 'bg-blue-600'
: 'bg-gray-300'
}`}
>
<MyText style={tw`${
hasChanges && !updateConstants.isPending
? 'text-white'
: 'text-gray-500'
} font-semibold`}>
{updateConstants.isPending ? 'Saving...' : 'Save'}
</MyText>
</MyTouchableOpacity>
</View>
{/* Content */}
{products.length === 0 ? (
<View style={tw`flex-1 justify-center items-center p-8`}>
<MaterialIcons name="inventory" size={64} color="#e5e7eb" />
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
No products available
</MyText>
</View>
) : (
<View style={tw`flex-1`}>
<View style={tw`bg-blue-50 px-4 py-2 mb-2 mt-2 mx-4 rounded-lg`}>
<MyText style={tw`text-blue-700 text-xs text-center`}>
Long press and drag to reorder {products.length} items
</MyText>
</View>
<View style={tw`flex-1 px-3`}>
<DraggableFlatList
data={products}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={true}
contentContainerStyle={{ paddingBottom: 20 }}
containerStyle={tw`flex-1`}
keyboardShouldPersistTaps="handled"
// Enable auto-scroll during drag
activationDistance={10}
/>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
item: {
width: itemWidth,
height: 60,
backgroundColor: 'white',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 10,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
marginVertical: 4,
},
activeItem: {
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderColor: '#3b82f6',
transform: [{ scale: 1.02 }],
},
outOfStock: {
opacity: 0.6,
},
dragHandle: {
marginRight: 8,
padding: 2,
},
image: {
width: 30,
height: 30,
borderRadius: 6,
marginRight: 10,
},
placeholderImage: {
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: '#f3f4f6',
marginRight: 10,
alignItems: 'center',
justifyContent: 'center',
},
info: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
name: {
fontSize: 13,
color: '#111827',
fontWeight: '500',
flex: 1,
marginRight: 4,
},
orderNumber: {
fontSize: 11,
color: '#9ca3af',
marginLeft: 8,
},
});

View file

@ -31,6 +31,7 @@ const CONST_LABELS: Record<string, string> = {
playStoreUrl: 'Play Store URL', playStoreUrl: 'Play Store URL',
appStoreUrl: 'App Store URL', appStoreUrl: 'App Store URL',
popularItems: 'Popular Items', popularItems: 'Popular Items',
allItemsOrder: 'All Items Order',
isFlashDeliveryEnabled: 'Enable Flash Delivery', isFlashDeliveryEnabled: 'Enable Flash Delivery',
supportMobile: 'Support Mobile', supportMobile: 'Support Mobile',
supportEmail: 'Support Email', supportEmail: 'Support Email',
@ -48,6 +49,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
// Special handling for popularItems - show navigation button instead of input // Special handling for popularItems - show navigation button instead of input
if (constant.key === 'popularItems') { if (constant.key === 'popularItems') {
console.log('key is allItemsOrder')
return ( return (
<View> <View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}> <MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
@ -67,6 +69,28 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
); );
} }
// Special handling for allItemsOrder - show navigation button instead of input
if (constant.key === 'allItemsOrder') {
return (
<View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
{CONST_LABELS[constant.key] || constant.key}
</MyText>
<MyTouchableOpacity
onPress={() => router.push('/(drawer)/customize-app/all-items-order')}
style={tw`bg-green-50 border-2 border-dashed border-green-200 p-4 rounded-lg flex-row items-center justify-center`}
>
<MaterialIcons name="reorder" size={20} color="#16a34a" style={tw`mr-2`} />
<MyText style={tw`text-green-700 font-medium`}>
Manage All Visible Items ({Array.isArray(constant.value) ? constant.value.length : 0} items)
</MyText>
<MaterialIcons name="chevron-right" size={20} color="#16a34a" style={tw`ml-2`} />
</MyTouchableOpacity>
</View>
);
}
// Handle boolean values - show checkbox // Handle boolean values - show checkbox
if (typeof constant.value === 'boolean') { if (typeof constant.value === 'boolean') {
return ( return (
@ -134,6 +158,7 @@ export default function CustomizeApp() {
const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery(); const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery();
const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation(); const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation();
const handleSubmit = (values: ConstantFormData) => { const handleSubmit = (values: ConstantFormData) => {
// Filter out constants that haven't changed // Filter out constants that haven't changed
const changedConstants = values.constants.filter((constant, index) => { const changedConstants = values.constants.filter((constant, index) => {

View file

@ -1,10 +1,11 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { import {
View, View,
TouchableOpacity,
Alert, Alert,
ActivityIndicator, ActivityIndicator,
ScrollView,
} from "react-native"; } from "react-native";
import { TouchableOpacity } from "react-native-gesture-handler";
import { Image } from "expo-image"; import { Image } from "expo-image";
import DraggableFlatList, { import DraggableFlatList, {
RenderItemParams, RenderItemParams,
@ -16,6 +17,7 @@ import {
tw, tw,
BottomDialog, BottomDialog,
BottomDropdown, BottomDropdown,
MyTouchableOpacity,
} from "common-ui"; } from "common-ui";
import ProductsSelector from "../../../components/ProductsSelector"; import ProductsSelector from "../../../components/ProductsSelector";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@ -27,8 +29,8 @@ interface PopularProduct {
id: number; id: number;
name: string; name: string;
shortDescription: string | null; shortDescription: string | null;
price: string; price: number;
marketPrice: string | null; marketPrice: number | null;
unit: string; unit: string;
incrementStep: number; incrementStep: number;
productQuantity: number; productQuantity: number;
@ -119,7 +121,7 @@ export default function CustomizePopularItems() {
const [popularProducts, setPopularProducts] = useState<PopularProduct[]>([]); const [popularProducts, setPopularProducts] = useState<PopularProduct[]>([]);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [showAddDialog, setShowAddDialog] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null); const [selectedProductIds, setSelectedProductIds] = useState<number[]>([]);
// Get current popular items from constants // Get current popular items from constants
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery(); const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
@ -182,14 +184,20 @@ export default function CustomizePopularItems() {
}; };
const handleAddProduct = () => { const handleAddProduct = () => {
if (selectedProductId) { if (selectedProductIds.length > 0) {
const product = allProducts?.products.find(p => p.id === selectedProductId); const newProducts = selectedProductIds
if (product && !popularProducts.find(p => p.id === product.id)) { .map(id => allProducts?.products.find(p => p.id === id))
setPopularProducts(prev => [...prev, product as PopularProduct]); .filter((product): product is NonNullable<typeof product> =>
product !== undefined && !popularProducts.find(p => p.id === product.id)
);
if (newProducts.length > 0) {
setPopularProducts(prev => [...prev, ...newProducts as PopularProduct[]]);
setHasChanges(true); setHasChanges(true);
setSelectedProductId(null);
setShowAddDialog(false);
} }
setSelectedProductIds([]);
setShowAddDialog(false);
} }
}; };
@ -293,20 +301,19 @@ export default function CustomizePopularItems() {
} }
return ( return (
<AppContainer> <View style={[tw`flex-1 bg-gray-50 relative`]}>
<View style={tw`flex-1 bg-gray-50`}>
{/* Header */} {/* Header */}
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}> <View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
<TouchableOpacity <MyTouchableOpacity
onPress={() => router.back()} onPress={() => router.back()}
style={tw`p-2 -ml-4`} style={tw`p-2 -ml-4`}
> >
<MaterialIcons name="chevron-left" size={24} color="#374151" /> <MaterialIcons name="chevron-left" size={24} color="#374151" />
</TouchableOpacity> </MyTouchableOpacity>
<MyText style={tw`text-xl font-bold text-gray-900`}>Popular Items</MyText> <MyText style={tw`text-xl font-bold text-gray-900`}>Popular Items</MyText>
<TouchableOpacity <MyTouchableOpacity
onPress={handleSave} onPress={handleSave}
disabled={!hasChanges || updateConstants.isPending} disabled={!hasChanges || updateConstants.isPending}
style={tw`px-4 py-2 rounded-lg ${ style={tw`px-4 py-2 rounded-lg ${
@ -322,7 +329,7 @@ export default function CustomizePopularItems() {
} font-semibold`}> } font-semibold`}>
{updateConstants.isPending ? 'Saving...' : 'Save'} {updateConstants.isPending ? 'Saving...' : 'Save'}
</MyText> </MyText>
</TouchableOpacity> </MyTouchableOpacity>
</View> </View>
{/* Content */} {/* Content */}
@ -356,35 +363,41 @@ export default function CustomizePopularItems() {
)} )}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={true}
contentContainerStyle={tw`pb-8`} scrollEnabled={true}
contentContainerStyle={{ paddingBottom: 80 }}
containerStyle={tw`flex-1`}
keyboardShouldPersistTaps="handled"
/> />
</View> </View>
)} )}
{/* FAB for Add Product */} {/* FAB for Add Product - Fixed position */}
<View style={tw`absolute bottom-4 right-4`}> <View style={tw`absolute bottom-12 right-6 z-50`}>
<TouchableOpacity <MyTouchableOpacity
onPress={() => setShowAddDialog(true)} onPress={() => setShowAddDialog(true)}
style={tw`bg-blue-600 p-4 rounded-full shadow-lg`} style={tw`bg-blue-600 p-4 rounded-full shadow-lg elevation-5`}
> >
<MaterialIcons name="add" size={24} color="white" /> <MaterialIcons name="add" size={24} color="white" />
</TouchableOpacity> </MyTouchableOpacity>
</View> </View>
{/* Add Product Dialog */} {/* Add Product Dialog */}
<BottomDialog <BottomDialog
open={showAddDialog} open={showAddDialog}
onClose={() => setShowAddDialog(false)} onClose={() => {
setShowAddDialog(false);
setSelectedProductIds([]);
}}
> >
<View style={tw`pb-8 pt-2 px-4`}> <View style={tw`pb-8 pt-2 px-4`}>
<View style={tw`items-center mb-6`}> <View style={tw`items-center mb-6`}>
<View style={tw`w-12 h-1.5 bg-gray-200 rounded-full mb-4`} /> <View style={tw`w-12 h-1.5 bg-gray-200 rounded-full mb-4`} />
<MyText style={tw`text-lg font-bold text-gray-900`}> <MyText style={tw`text-lg font-bold text-gray-900`}>
Add Popular Item Add Popular Items
</MyText> </MyText>
<MyText style={tw`text-sm text-gray-500`}> <MyText style={tw`text-sm text-gray-500`}>
Select a product to add to popular items Select products to add to popular items
</MyText> </MyText>
</View> </View>
@ -398,41 +411,43 @@ export default function CustomizePopularItems() {
) : ( ) : (
<> <>
<ProductsSelector <ProductsSelector
value={selectedProductId || 0} value={selectedProductIds}
onChange={(val) => setSelectedProductId(val as number)} onChange={(val) => setSelectedProductIds(val as number[])}
multiple={false} multiple={true}
label="Select Product" label="Select Products"
placeholder="Choose a product..." placeholder="Choose products..."
labelFormat={(product) => `${product.name} - ₹${product.price}`} labelFormat={(product) => `${product.name} - ₹${product.price}`}
/> />
<View style={tw`flex-row gap-3 mt-6`}> <View style={tw`flex-row gap-3 mt-6`}>
<TouchableOpacity <MyTouchableOpacity
onPress={() => setShowAddDialog(false)} onPress={() => setShowAddDialog(false)}
style={tw`flex-1 bg-gray-100 p-3 rounded-lg`} style={tw`flex-1 bg-gray-100 p-3 rounded-lg`}
> >
<MyText style={tw`text-gray-700 text-center font-semibold`}> <MyText style={tw`text-gray-700 text-center font-semibold`}>
Cancel Cancel
</MyText> </MyText>
</TouchableOpacity> </MyTouchableOpacity>
<TouchableOpacity <MyTouchableOpacity
onPress={handleAddProduct} onPress={handleAddProduct}
disabled={!selectedProductId} disabled={selectedProductIds.length === 0}
style={tw`flex-1 ${ style={tw`flex-1 ${
selectedProductId ? 'bg-blue-600' : 'bg-gray-300' selectedProductIds.length > 0 ? 'bg-blue-600' : 'bg-gray-300'
} p-3 rounded-lg`} } p-3 rounded-lg`}
> >
<MyText style={tw`text-white text-center font-semibold`}> <MyText style={tw`text-white text-center font-semibold`}>
Add Product {selectedProductIds.length > 0
? `Add ${selectedProductIds.length} Product${selectedProductIds.length > 1 ? 's' : ''}`
: 'Add Products'}
</MyText> </MyText>
</TouchableOpacity> </MyTouchableOpacity>
</View> </View>
</> </>
)} )}
</View> </View>
</BottomDialog> </BottomDialog>
</View> </View>
</AppContainer>
); );
} }

View file

@ -13,11 +13,12 @@ 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;
onPress?: () => void; onPress?: () => void;
testID?: string;
} }
interface MenuItemComponentProps { interface MenuItemComponentProps {
@ -100,6 +101,7 @@ export default function Dashboard() {
category: 'quick', category: 'quick',
iconColor: '#06B6D4', iconColor: '#06B6D4',
iconBg: '#CFFAFE', iconBg: '#CFFAFE',
testID: 'delivery-slots-menu-item',
}, },
{ {
title: 'Add Product', title: 'Add Product',
@ -192,6 +194,24 @@ export default function Dashboard() {
iconColor: '#7C3AED', iconColor: '#7C3AED',
iconBg: '#F3E8FF', iconBg: '#F3E8FF',
}, },
{
title: 'User Management',
icon: 'people',
description: 'View and manage all users',
route: '/(drawer)/user-management',
category: 'users',
iconColor: '#0EA5E9',
iconBg: '#E0F2FE',
},
{
title: 'Send Notifications',
icon: 'campaign',
description: 'Send push notifications to users',
route: '/(drawer)/send-notifications',
category: 'users',
iconColor: '#8B5CF6',
iconBg: '#F3E8FF',
},
]; ];
const quickActions = menuItems.filter(item => item.category === 'quick'); const quickActions = menuItems.filter(item => item.category === 'quick');
@ -200,6 +220,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' },
]; ];
@ -226,6 +247,8 @@ export default function Dashboard() {
{quickActions.map((item) => ( {quickActions.map((item) => (
<Pressable <Pressable
key={`quick-${item.route}`} key={`quick-${item.route}`}
testID={item.testID}
accessibilityLabel={item.testID}
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)} onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
style={({ pressed }) => [ style={({ pressed }) => [
tw`bg-white rounded-xl p-3 shadow-sm border border-gray-100 items-center`, tw`bg-white rounded-xl p-3 shadow-sm border border-gray-100 items-center`,

View file

@ -6,6 +6,7 @@ export default function Layout() {
<Stack.Screen name="index" options={{ title: 'Manage Orders' }} /> <Stack.Screen name="index" options={{ title: 'Manage Orders' }} />
<Stack.Screen name="delivery-sequences" options={{ title: 'Delivery Sequences' }} /> <Stack.Screen name="delivery-sequences" options={{ title: 'Delivery Sequences' }} />
<Stack.Screen name="orders" options={{ title: 'Orders' }} /> <Stack.Screen name="orders" options={{ title: 'Orders' }} />
<Stack.Screen name="order-details" options={{ title: 'Order Details' }} />
</Stack> </Stack>
); );
} }

View file

@ -726,7 +726,7 @@ export default function DeliverySequences() {
}} }}
onViewDetails={() => { onViewDetails={() => {
if (selectedOrder) { if (selectedOrder) {
router.push(`/order-details/${selectedOrder.id}`); router.push(`/manage-orders/order-details/${selectedOrder.id}`);
} }
setShowOrderMenu(false); setShowOrderMenu(false);
}} }}

View file

@ -18,7 +18,7 @@ export default function ManageOrders() {
useCallback(() => { useCallback(() => {
const target = getNavigationTarget(); const target = getNavigationTarget();
if (target) { if (target) {
router.replace(target as any); router.push(target as any);
} }
}, [router, getNavigationTarget]) }, [router, getNavigationTarget])
); );

View file

@ -13,6 +13,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import FontAwesome5 from "@expo/vector-icons/FontAwesome5"; import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
import dayjs from "dayjs"; import dayjs from "dayjs";
import CancelOrderDialog from "@/components/CancelOrderDialog"; import CancelOrderDialog from "@/components/CancelOrderDialog";
import { UserIncidentsView } from "@/components/UserIncidentsView";
export default function OrderDetails() { export default function OrderDetails() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
@ -82,6 +83,16 @@ export default function OrderDetails() {
}, },
}); });
const removeDeliveryChargeMutation = trpc.admin.order.removeDeliveryCharge.useMutation({
onSuccess: () => {
Alert.alert("Success", "Delivery charge has been removed");
refetch();
},
onError: (error: any) => {
Alert.alert("Error", error.message || "Failed to remove delivery charge");
},
});
if (isLoading) { if (isLoading) {
return ( return (
<View style={tw`flex-1 justify-center items-center bg-gray-50`}> <View style={tw`flex-1 justify-center items-center bg-gray-50`}>
@ -267,6 +278,23 @@ export default function OrderDetails() {
</View> </View>
</View> </View>
{/* Cancellation Reason */}
{order.status === "cancelled" && order.cancelReason && (
<View
style={tw`bg-red-50 p-5 rounded-2xl border border-red-100 mb-4`}
>
<View style={tw`flex-row items-center mb-2`}>
<MaterialIcons name="cancel" size={18} color="#DC2626" />
<MyText style={tw`text-sm font-bold text-red-800 ml-2`}>
Cancellation Reason
</MyText>
</View>
<MyText style={tw`text-sm text-red-900 leading-5`}>
{order.cancelReason}
</MyText>
</View>
)}
{/* Order Progress (Simplified Timeline) */} {/* Order Progress (Simplified Timeline) */}
{order.status !== "cancelled" && ( {order.status !== "cancelled" && (
<View <View
@ -348,12 +376,17 @@ export default function OrderDetails() {
)} )}
{/* Customer Details */} {/* Customer Details */}
<View <TouchableOpacity
onPress={() => order.userId && router.push(`/(drawer)/user-management/${order.userId}`)}
activeOpacity={0.7}
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`} style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
> >
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}> <View style={tw`flex-row items-center justify-between mb-4`}>
<MyText style={tw`text-base font-bold text-gray-900`}>
Customer Details Customer Details
</MyText> </MyText>
<MaterialIcons name="chevron-right" size={20} color="#6B7280" />
</View>
<View style={tw`flex-row items-center mb-4`}> <View style={tw`flex-row items-center mb-4`}>
<View <View
@ -363,7 +396,7 @@ export default function OrderDetails() {
</View> </View>
<View> <View>
<MyText style={tw`text-sm font-bold text-gray-900`}> <MyText style={tw`text-sm font-bold text-gray-900`}>
{order.customerName} {order.customerName || 'Unknown User'}
</MyText> </MyText>
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText> <MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
</View> </View>
@ -404,7 +437,7 @@ export default function OrderDetails() {
</View> </View>
</View> </View>
</View> </View>
</View> </TouchableOpacity>
{/* Order Items */} {/* Order Items */}
<View <View
@ -486,6 +519,40 @@ export default function OrderDetails() {
-{discountAmount} -{discountAmount}
</MyText> </MyText>
</View> </View>
)}
{order.deliveryCharge > 0 && (
<View style={tw`flex-row justify-between items-center mb-2`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`text-gray-600 font-medium`}>
Delivery Charge
</MyText>
<TouchableOpacity
onPress={() => {
Alert.alert(
'Remove Delivery Cost',
'Are you sure you want to remove the delivery cost from this order?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removeDeliveryChargeMutation.mutate({ orderId: order.id }),
},
]
);
}}
disabled={removeDeliveryChargeMutation.isPending}
style={tw`ml-2 px-2 py-1 bg-red-100 rounded-md`}
>
<MyText style={tw`text-xs font-bold text-red-600`}>
{removeDeliveryChargeMutation.isPending ? 'Removing...' : 'Remove'}
</MyText>
</TouchableOpacity>
</View>
<MyText style={tw`text-gray-600 font-medium`}>
{order.deliveryCharge}
</MyText>
</View>
)} )}
<View style={tw`flex-row justify-between items-center pt-2 border-t border-gray-200`}> <View style={tw`flex-row justify-between items-center pt-2 border-t border-gray-200`}>
<View style={tw`flex-row items-center`}> <View style={tw`flex-row items-center`}>
@ -544,6 +611,14 @@ export default function OrderDetails() {
</View> </View>
)} )}
{/* User Incidents Section */}
{order.userId && (
<UserIncidentsView
userId={order.userId}
orderId={order.id}
/>
)}
{/* Coupon Applied Section */} {/* Coupon Applied Section */}
{order.couponCode && ( {order.couponCode && (
<View <View

View file

@ -56,7 +56,11 @@ interface OrderType {
orderId: string; orderId: string;
readableId: number; readableId: number;
customerName: string | null; customerName: string | null;
customerMobile?: string | null;
address: string; address: string;
addressId: number;
latitude: number | null;
longitude: number | null;
totalAmount: number; totalAmount: number;
deliveryCharge: number; deliveryCharge: number;
items: { items: {
@ -82,6 +86,7 @@ interface OrderType {
discountAmount?: number; discountAmount?: number;
adminNotes?: string | null; adminNotes?: string | null;
userNotes?: string | null; userNotes?: string | null;
userNegativityScore?: number;
} }
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => { const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
@ -100,12 +105,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation(); const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
const handleOrderPress = () => { const handleOrderPress = () => {
router.push(`/order-details/${order.orderId}` as any); router.push(`/manage-orders/order-details/${order.orderId}` as any);
}; };
const handleMenuOption = () => { const handleMenuOption = () => {
setMenuOpen(false); setMenuOpen(false);
router.push(`/order-details/${order.orderId}` as any); router.push(`/manage-orders/order-details/${order.orderId}` as any);
}; };
const handleMarkPackaged = (isPackaged: boolean) => { const handleMarkPackaged = (isPackaged: boolean) => {
@ -168,8 +173,8 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
<View style={tw`flex-row justify-between items-start`}> <View style={tw`flex-row justify-between items-start`}>
<View style={tw`flex-1`}> <View style={tw`flex-1`}>
<View style={tw`flex-row items-center mb-1`}> <View style={tw`flex-row items-center mb-1`}>
<MyText style={tw`font-bold text-lg mr-2 ${order.status === 'cancelled' ? 'text-red-600' : 'text-gray-900'}`}> <MyText style={tw`font-bold text-lg mr-2 ${order.status === 'cancelled' ? 'text-red-600' : (order.userNegativityScore && order.userNegativityScore > 0 ? 'text-yellow-600' : 'text-gray-900')}`}>
{order.customerName || 'Unknown Customer'} {order.customerName || order.customerMobile || 'Unknown Customer'}
</MyText> </MyText>
<View style={tw`bg-gray-200 px-2 py-0.5 rounded mr-2`}> <View style={tw`bg-gray-200 px-2 py-0.5 rounded mr-2`}>
<MyText style={tw`text-xs font-medium text-gray-600`}>#{order.readableId}</MyText> <MyText style={tw`text-xs font-medium text-gray-600`}>#{order.readableId}</MyText>
@ -186,6 +191,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
<MyText style={tw`text-xs text-gray-500 ml-1`}> <MyText style={tw`text-xs text-gray-500 ml-1`}>
{dayjs(order.createdAt).format('MMM D, h:mm A')} {dayjs(order.createdAt).format('MMM D, h:mm A')}
</MyText> </MyText>
{order.userNegativityScore && order.userNegativityScore > 0 && (
<View style={tw`flex-row items-center ml-2`}>
<MaterialIcons name="warning" size={14} color="#CA8A04" />
<MyText style={tw`text-xs text-yellow-600 font-semibold ml-1`}>Negative Customer</MyText>
</View>
)}
</View> </View>
</View> </View>
@ -359,11 +370,11 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
isDelivered: order.isDelivered, isDelivered: order.isDelivered,
isFlashDelivery: order.isFlashDelivery, isFlashDelivery: order.isFlashDelivery,
address: order.address, address: order.address,
addressId: 0, addressId: order.addressId,
adminNotes: order.adminNotes, adminNotes: order.adminNotes,
userNotes: order.userNotes, userNotes: order.userNotes,
latitude: null, latitude: order.latitude,
longitude: null, longitude: order.longitude,
status: order.status, status: order.status,
}} }}
onViewDetails={handleMenuOption} onViewDetails={handleMenuOption}
@ -377,7 +388,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
setMenuOpen(false); setMenuOpen(false);
setCancelDialogOpen(true); setCancelDialogOpen(true);
}} }}
onAttachLocation={() => {}} onAttachLocation={() => refetch()}
onWhatsApp={() => {}} onWhatsApp={() => {}}
onDial={() => {}} onDial={() => {}}
/> />

View file

@ -44,7 +44,6 @@ export default function EditProduct() {
tagIds: values.tagIds, tagIds: values.tagIds,
}; };
console.log({payload})
const formData = new FormData(); const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => { Object.entries(payload).forEach(([key, value]) => {

View file

@ -186,7 +186,7 @@ export default function RebalanceOrders() {
setDialogOpen={setDialogOpen} setDialogOpen={setDialogOpen}
/> />
)} )}
contentContainerStyle={tw`pb-24 flex-1`} // Space for floating button contentContainerStyle={tw`pb-24`} // Space for floating button
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onRefresh={handleRefresh} onRefresh={handleRefresh}
refreshing={refreshing} refreshing={refreshing}

View file

@ -0,0 +1,274 @@
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 [title, setTitle] = useState('');
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([]);
setTitle('');
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 (title.trim().length === 0) {
Alert.alert('Error', 'Please enter a title');
return;
}
if (message.trim().length === 0) {
Alert.alert('Error', 'Please enter a message');
return;
}
// Check if sending to all users
const isSendingToAll = selectedUserIds.length === 0;
if (isSendingToAll) {
const confirmed = await new Promise<boolean>((resolve) => {
Alert.alert(
'Send to All Users?',
'This will send the notification to all users with push tokens. Continue?',
[
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
{ text: 'Send', style: 'default', onPress: () => resolve(true) },
]
);
});
if (!confirmed) 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,
title: title.trim(),
text: message.trim(),
imageUrl,
});
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to send notification');
}
};
const getDisplayText = () => {
if (selectedUserIds.length === 0) return 'All 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`}>
{/* Title 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`}>Title</MyText>
<MyTextInput
value={title}
onChangeText={setTitle}
placeholder="Enter notification title..."
style={tw`text-gray-900`}
/>
</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 - Hidden for now */}
{/* <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> */}
{/* 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 (Optional)</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>
<MyText style={tw`text-blue-600 text-xs mt-1`}>
Leave empty to send to all users
</MyText>
</View>
{/* Submit Button */}
<TouchableOpacity
onPress={handleSend}
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0}
style={tw`${
sendNotification.isPending || title.trim().length === 0 || message.trim().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...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
</MyText>
</TouchableOpacity>
</ScrollView>
</View>
</AppContainer>
);
}

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { MaterialCommunityIcons, Entypo } from '@expo/vector-icons'; import { MaterialCommunityIcons, Entypo } from '@expo/vector-icons';
import { View, TouchableOpacity, FlatList, Alert } from 'react-native'; import { View, TouchableOpacity, FlatList, Alert, ActivityIndicator } from 'react-native';
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity } from 'common-ui'; import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity, Checkbox } from 'common-ui';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -12,6 +12,7 @@ interface SlotItemProps {
router: any; router: any;
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>; setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>;
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetch: () => void;
} }
const SlotItemComponent: React.FC<SlotItemProps> = ({ const SlotItemComponent: React.FC<SlotItemProps> = ({
@ -19,6 +20,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
router, router,
setDialogProducts, setDialogProducts,
setDialogOpen, setDialogOpen,
refetch,
}) => { }) => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || []; const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || [];
@ -28,6 +30,29 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'; const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
const statusText = isActive ? 'Active' : 'Inactive'; const statusText = isActive ? 'Active' : 'Inactive';
const updateSlotCapacity = trpc.admin.slots.updateSlotCapacity.useMutation();
const handleCapacityToggle = () => {
updateSlotCapacity.mutate(
{ slotId: slot.id, isCapacityFull: !slot.isCapacityFull },
{
onSuccess: () => {
setMenuOpen(false);
refetch();
Alert.alert(
'Success',
slot.isCapacityFull
? 'Slot capacity reset. It will now be visible to users.'
: 'Slot marked as full capacity. It will be hidden from users.'
);
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to update slot capacity');
},
}
);
};
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${slot.id}`)} onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${slot.id}`)}
@ -58,6 +83,11 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}> <View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText> <MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
</View> </View>
{slot.isCapacityFull && (
<View style={tw`px-2 py-1 rounded-full bg-red-500 ml-2`}>
<MyText style={tw`text-xs font-bold text-white`}>FULL</MyText>
</View>
)}
<TouchableOpacity <TouchableOpacity
onPress={() => setMenuOpen(true)} onPress={() => setMenuOpen(true)}
style={tw`ml-2 p-1`} style={tw`ml-2 p-1`}
@ -72,6 +102,48 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}> <BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
<View style={tw`p-4`}> <View style={tw`p-4`}>
<MyText style={tw`text-lg font-bold mb-4`}>Slot #{slot.id} Actions</MyText> <MyText style={tw`text-lg font-bold mb-4`}>Slot #{slot.id} Actions</MyText>
{/* Capacity Toggle */}
<TouchableOpacity
onPress={handleCapacityToggle}
disabled={updateSlotCapacity.isPending}
style={tw`py-4 border-b border-gray-200`}
>
<View style={tw`flex-row items-center justify-between`}>
<View style={tw`flex-row items-center flex-1`}>
{updateSlotCapacity.isPending ? (
<ActivityIndicator size="small" color="#EF4444" style={tw`mr-3`} />
) : (
<MaterialCommunityIcons
name={slot.isCapacityFull ? "package-variant-closed" : "package-variant"}
size={20}
color={slot.isCapacityFull ? "#EF4444" : "#4B5563"}
style={tw`mr-3`}
/>
)}
<View>
<MyText style={tw`text-base text-gray-800`}>Mark as Full Capacity</MyText>
<MyText style={tw`text-xs text-gray-500 mt-0.5`}>
{slot.isCapacityFull
? "Slot is hidden from users"
: "Hidden from users when full"}
</MyText>
</View>
</View>
{updateSlotCapacity.isPending ? (
<ActivityIndicator size="small" color="#EF4444" />
) : (
<Checkbox
checked={slot.isCapacityFull}
onPress={handleCapacityToggle}
size={22}
fillColor="#EF4444"
checkColor="#FFFFFF"
/>
)}
</View>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
setMenuOpen(false); setMenuOpen(false);
@ -193,6 +265,7 @@ export default function Slots() {
router={router} router={router}
setDialogProducts={setDialogProducts} setDialogProducts={setDialogProducts}
setDialogOpen={setDialogOpen} setDialogOpen={setDialogOpen}
refetch={refetch}
/> />
)} )}
contentContainerStyle={tw`p-4`} contentContainerStyle={tw`p-4`}
@ -202,6 +275,8 @@ export default function Slots() {
{/* FAB for Add New Slot */} {/* FAB for Add New Slot */}
<MyTouchableOpacity <MyTouchableOpacity
testID="add-slot-fab"
accessibilityLabel="add-slot-fab"
onPress={() => router.push('/slots/add' as any)} onPress={() => router.push('/slots/add' as any)}
activeOpacity={0.95} activeOpacity={0.95}
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }} style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}

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,258 @@
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,
Checkbox,
} from 'common-ui';
import { trpc } from '@/src/trpc-client';
import { formatDistanceToNow } from 'date-fns';
import dayjs from 'dayjs';
import { UserIncidentsView } from '@/components/UserIncidentsView';
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 updateSuspension = trpc.admin.user.updateUserSuspension.useMutation({
onSuccess: () => {
refetch();
},
});
const handleOrderPress = useCallback((orderId: number) => {
router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
}, [router]);
const handleSuspensionToggle = useCallback((isSuspended: boolean) => {
updateSuspension.mutate({ userId, isSuspended });
}, [userId, updateSuspension]);
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`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`text-gray-900 font-bold text-lg mb-0.5`}>
{user.mobile || 'No Mobile'}
</MyText>
{user.isSuspended && (
<View style={tw`ml-2 px-2 py-0.5 bg-red-100 rounded-full border border-red-200`}>
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Suspended</MyText>
</View>
)}
</View>
<MyText style={tw`text-gray-500`}>
{displayName}
</MyText>
</View>
</View>
<View style={tw`bg-gray-50 p-3 rounded-xl mb-4`}>
<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>
{/* Suspension Toggle */}
<View style={tw`flex-row items-center justify-between pt-4 border-t border-gray-100`}>
<View style={tw`flex-1`}>
<MyText style={tw`text-gray-900 font-bold text-base mb-1`}>
Suspend User
</MyText>
<MyText style={tw`text-gray-500 text-sm`}>
Prevent user from placing orders
</MyText>
</View>
{updateSuspension.isPending ? (
<ActivityIndicator size="small" color="#3b82f6" />
) : (
<Checkbox
checked={user.isSuspended}
onPress={() => handleSuspensionToggle(!user.isSuspended)}
size={28}
/>
)}
</View>
</View>
{/* User Incidents Section */}
<UserIncidentsView userId={userId} orderId={null} />
{/* 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,268 @@
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;
isSuspended: boolean;
}
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 */}
<View style={tw`flex-row items-center mb-0.5`}>
<MyText style={tw`text-gray-900 font-bold text-base`}>
{user.mobile || 'No Mobile'}
</MyText>
{user.isSuspended && (
<View style={tw`ml-2 px-2 py-0.5 bg-red-100 rounded-full border border-red-200`}>
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Suspended</MyText>
</View>
)}
</View>
{/* 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

@ -11,7 +11,11 @@ export default function Layout() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}> <trpc.Provider client={trpcClient} queryClient={queryClient}>
<StaffAuthProvider> <StaffAuthProvider>
<SafeAreaView edges={['left', 'right', 'bottom']} style={{ flex: 1, backgroundColor: '#fff' }}> <SafeAreaView
edges={['left', 'right', 'bottom']}
style={{ flex: 1, backgroundColor: '#fff' }}
testID="app-root"
>
<RefreshProvider queryClient={queryClient}> <RefreshProvider queryClient={queryClient}>
<Stack screenOptions={{ headerShown: false }} /> <Stack screenOptions={{ headerShown: false }} />
</RefreshProvider> </RefreshProvider>

View file

@ -26,7 +26,6 @@ export default function LoginScreen() {
} }
}; };
console.log('from the login page')
return ( return (
@ -52,6 +51,8 @@ export default function LoginScreen() {
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
style={{ marginBottom: 20 }} style={{ marginBottom: 20 }}
testID="login-name-input"
accessibilityLabel="login-name-input"
/> />
<MyTextInput <MyTextInput
@ -63,6 +64,8 @@ export default function LoginScreen() {
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
style={{ marginBottom: 30 }} style={{ marginBottom: 30 }}
testID="login-password-input"
accessibilityLabel="login-password-input"
/> />
{loginError && ( {loginError && (
@ -84,6 +87,8 @@ export default function LoginScreen() {
disabled={isLoggingIn} disabled={isLoggingIn}
fullWidth fullWidth
style={{ marginBottom: 20 }} style={{ marginBottom: 20 }}
testID="login-button"
accessibilityLabel="login-button"
/> />
</View> </View>
</AppContainer> </AppContainer>

View file

@ -103,6 +103,18 @@ export function OrderOptionsMenu({
} }
}; };
const handleOpenInMaps = () => {
if (order.latitude && order.longitude) {
const url = `https://www.google.com/maps/search/?api=1&query=${order.latitude},${order.longitude}`;
Linking.openURL(url);
} else {
Alert.alert('No location coordinates available');
}
};
const hasCoordinates = order.latitude !== null && order.latitude !== undefined &&
order.longitude !== null && order.longitude !== undefined;
return ( return (
<BottomDialog open={open} onClose={onClose}> <BottomDialog open={open} onClose={onClose}>
<View style={{ maxHeight: SCREEN_HEIGHT * 0.7 }}> <View style={{ maxHeight: SCREEN_HEIGHT * 0.7 }}>
@ -257,6 +269,29 @@ export function OrderOptionsMenu({
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} /> <MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
</TouchableOpacity> </TouchableOpacity>
{hasCoordinates && (
<TouchableOpacity
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
onPress={() => {
handleOpenInMaps();
onClose();
}}
>
<View style={tw`w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4`}>
<MaterialIcons name="map" size={20} color="#2563eb" />
</View>
<View>
<MyText style={tw`font-semibold text-gray-800 text-base`}>
Open in Maps
</MyText>
<MyText style={tw`text-gray-500 text-xs`}>
View delivery location on Google Maps
</MyText>
</View>
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
</TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`} style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
onPress={() => { onPress={() => {

View file

@ -147,6 +147,7 @@ export default function ProductsSelector({
{showGroups && groups.length > 0 && ( {showGroups && groups.length > 0 && (
<View style={tw`mb-4`}> <View style={tw`mb-4`}>
<BottomDropdown <BottomDropdown
testID="product-groups-dropdown"
label="Select Product Groups" label="Select Product Groups"
options={groupOptions} options={groupOptions}
value={selectedGroupIds.map(id => id.toString())} value={selectedGroupIds.map(id => id.toString())}

View file

@ -73,6 +73,11 @@ export default function SlotForm({
return; return;
} }
if (values.freezeTime > values.deliveryTime) {
Alert.alert('Error', 'Freeze time must be before or equal to delivery time');
return;
}
const slotData = { const slotData = {
deliveryTime: values.deliveryTime.toISOString(), deliveryTime: values.deliveryTime.toISOString(),
freezeTime: values.freezeTime.toISOString(), freezeTime: values.freezeTime.toISOString(),
@ -143,12 +148,22 @@ export default function SlotForm({
<View style={tw`mb-4`}> <View style={tw`mb-4`}>
<Text style={tw`text-lg font-semibold mb-2`}>Delivery Date & Time</Text> <Text style={tw`text-lg font-semibold mb-2`}>Delivery Date & Time</Text>
<DateTimePickerMod value={values.deliveryTime} setValue={(value) => setFieldValue('deliveryTime', value)} /> <DateTimePickerMod
dateTestID="delivery-date-picker"
timeTestID="delivery-time-picker"
value={values.deliveryTime}
setValue={(value) => setFieldValue('deliveryTime', value)}
/>
</View> </View>
<View style={tw`mb-4`}> <View style={tw`mb-4`}>
<Text style={tw`text-lg font-semibold mb-2`}>Freeze Date & Time</Text> <Text style={tw`text-lg font-semibold mb-2`}>Freeze Date & Time</Text>
<DateTimePickerMod value={values.freezeTime} setValue={(value) => setFieldValue('freezeTime', value)} /> <DateTimePickerMod
dateTestID="freeze-date-picker"
timeTestID="freeze-time-picker"
value={values.freezeTime}
setValue={(value) => setFieldValue('freezeTime', value)}
/>
</View> </View>
<View style={tw`mb-4`}> <View style={tw`mb-4`}>
@ -215,6 +230,8 @@ export default function SlotForm({
</FieldArray> </FieldArray>
<TouchableOpacity <TouchableOpacity
testID="create-slot-button"
accessibilityLabel="create-slot-button"
onPress={() => handleSubmit()} onPress={() => handleSubmit()}
disabled={isPending} disabled={isPending}
style={tw`${isPending ? 'bg-pink2' : 'bg-pink1'} p-3 rounded-lg items-center mt-6 pb-4`} style={tw`${isPending ? 'bg-pink2' : 'bg-pink1'} p-3 rounded-lg items-center mt-6 pb-4`}

View file

@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { View, TouchableOpacity } from 'react-native';
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Alert } from 'react-native';
interface UserIncidentDialogProps {
orderId: number;
userId: number;
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export default function UserIncidentDialog({ orderId, userId, open, onClose, onSuccess }: UserIncidentDialogProps) {
const [adminComment, setAdminComment] = useState('');
const [negativityScore, setNegativityScore] = useState('');
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
onSuccess: () => {
Alert.alert('Success', 'Incident added successfully');
setAdminComment('');
setNegativityScore('');
onClose();
onSuccess?.();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to add incident');
},
});
const handleAddIncident = () => {
const score = negativityScore ? parseInt(negativityScore) : undefined;
if (!adminComment.trim() && !negativityScore) {
Alert.alert('Error', 'Please enter a comment or negativity score');
return;
}
addIncidentMutation.mutate({
userId,
orderId,
adminComment: adminComment || undefined,
negativityScore: score,
});
};
return (
<BottomDialog open={open} onClose={onClose}>
<View style={tw`p-6`}>
<View style={tw`items-center mb-6`}>
<View style={tw`w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3`}>
<MaterialIcons name="warning" size={24} color="#D97706" />
</View>
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
Add User Incident
</MyText>
<MyText style={tw`text-gray-500 text-center mt-2 text-sm leading-5`}>
Record an incident for this user. This will be visible in their profile.
</MyText>
</View>
<MyTextInput
topLabel="Admin Comment"
value={adminComment}
onChangeText={setAdminComment}
placeholder="Enter details about the incident..."
multiline
style={tw`h-24`}
/>
<MyTextInput
topLabel="Negativity Score (Optional)"
value={negativityScore}
onChangeText={setNegativityScore}
placeholder="0"
keyboardType="numeric"
style={tw`mt-4`}
/>
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex-row items-start`}>
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
</MyText>
</View>
<View style={tw`flex-row gap-3`}>
<TouchableOpacity
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
onPress={onClose}
>
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
</TouchableOpacity>
<TouchableOpacity
style={tw`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
onPress={handleAddIncident}
disabled={addIncidentMutation.isPending}
>
<MyText style={tw`text-white font-bold`}>
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
</MyText>
</TouchableOpacity>
</View>
</View>
</BottomDialog>
);
}

View file

@ -0,0 +1,206 @@
import React, { useState } from 'react';
import { View, TouchableOpacity, Alert } from 'react-native';
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import dayjs from 'dayjs';
function UserIncidentDialog({
userId,
orderId,
open,
onClose,
onSuccess
}: {
userId: number;
orderId: number | null;
open: boolean;
onClose: () => void;
onSuccess?: () => void
}) {
const [adminComment, setAdminComment] = useState('');
const [negativityScore, setNegativityScore] = useState('');
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
onSuccess: () => {
Alert.alert('Success', 'Incident added successfully');
setAdminComment('');
setNegativityScore('');
onClose();
onSuccess?.();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to add incident');
},
});
const handleAddIncident = () => {
const score = negativityScore ? parseInt(negativityScore) : undefined;
if (!adminComment.trim() && !negativityScore) {
Alert.alert('Error', 'Please enter a comment or negativity score');
return;
}
addIncidentMutation.mutate({
userId,
orderId: orderId || undefined,
adminComment: adminComment || undefined,
negativityScore: score,
});
};
return (
<BottomDialog open={open} onClose={onClose}>
<View style={tw`p-6`}>
<View style={tw`items-center mb-6`}>
<View style={tw`w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3`}>
<MaterialIcons name="warning" size={24} color="#D97706" />
</View>
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
Add User Incident
</MyText>
<MyText style={tw`text-gray-500 text-center mt-2 text-sm leading-5`}>
Record an incident for this user. This will be visible in their profile.
</MyText>
</View>
<MyTextInput
topLabel="Admin Comment"
value={adminComment}
onChangeText={setAdminComment}
placeholder="Enter details about the incident..."
multiline
style={tw`h-24`}
/>
<MyTextInput
topLabel="Negativity Score (Optional)"
value={negativityScore}
onChangeText={setNegativityScore}
placeholder="0"
keyboardType="numeric"
style={tw`mt-4`}
/>
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex-row items-start`}>
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
</MyText>
</View>
<View style={tw`flex-row gap-3`}>
<TouchableOpacity
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
onPress={onClose}
>
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
</TouchableOpacity>
<TouchableOpacity
style={tw`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
onPress={handleAddIncident}
disabled={addIncidentMutation.isPending}
>
<MyText style={tw`text-white font-bold`}>
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
</MyText>
</TouchableOpacity>
</View>
</View>
</BottomDialog>
);
}
export function UserIncidentsView({ userId, orderId }: { userId: number; orderId: number | null }) {
const [incidentDialogOpen, setIncidentDialogOpen] = useState(false);
const { data: incidentsData, refetch: refetchIncidents } = trpc.admin.user.getUserIncidents.useQuery(
{ userId },
{ enabled: !!userId }
);
return (
<>
<View style={tw`bg-amber-50 p-5 rounded-2xl shadow-sm mb-4 border border-amber-100`}>
<View style={tw`flex-row items-center justify-between mb-4`}>
<MyText style={tw`text-base font-bold text-amber-900`}>
User Incidents
</MyText>
<TouchableOpacity
onPress={() => setIncidentDialogOpen(true)}
style={tw`flex-row items-center bg-amber-200 px-3 py-1.5 rounded-lg`}
>
<MaterialIcons name="add" size={16} color="#D97706" />
<MyText style={tw`text-xs font-bold text-amber-800 ml-1`}>Add Incident</MyText>
</TouchableOpacity>
</View>
{incidentsData?.incidents && incidentsData.incidents.length > 0 ? (
<View style={tw`space-y-3`}>
{incidentsData.incidents.map((incident: any, index: number) => (
<View
key={incident.id}
style={tw`bg-white p-4 rounded-xl border border-amber-200 ${index === incidentsData.incidents.length - 1 ? 'mb-0' : 'mb-3'}`}
>
<View style={tw`flex-row justify-between items-start mb-2`}>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="event" size={14} color="#6B7280" />
<MyText style={tw`text-xs text-gray-600 ml-1`}>
{dayjs(incident.dateAdded).format('MMM DD, YYYY • h:mm A')}
</MyText>
</View>
{incident.negativityScore && (
<View style={tw`px-2 py-1 bg-red-100 rounded-md`}>
<MyText style={tw`text-xs font-bold text-red-700`}>
Score: {incident.negativityScore}
</MyText>
</View>
)}
</View>
{incident.adminComment && (
<View style={tw`mt-2`}>
<MyText style={tw`text-sm text-gray-900 leading-5`}>
{incident.adminComment}
</MyText>
</View>
)}
<View style={tw`flex-row items-center mt-2 pt-2 border-t border-gray-100`}>
<MaterialIcons name="person" size={12} color="#9CA3AF" />
<MyText style={tw`text-xs text-gray-500 ml-1`}>
Added by {incident.addedBy}
</MyText>
{incident.orderId && (
<>
<MaterialIcons name="shopping-cart" size={12} color="#9CA3AF" style={tw`ml-3`} />
<MyText style={tw`text-xs text-gray-500 ml-1`}>
Order #{incident.orderId}
</MyText>
</>
)}
</View>
</View>
))}
</View>
) : (
<View style={tw`items-center py-6`}>
<MaterialIcons name="check-circle-outline" size={32} color="#D97706" />
<MyText style={tw`text-sm text-amber-700 mt-2`}>
No incidents recorded for this user
</MyText>
</View>
)}
</View>
<UserIncidentDialog
orderId={orderId}
userId={userId}
open={incidentDialogOpen}
onClose={() => setIncidentDialogOpen(false)}
onSuccess={refetchIncidents}
/>
</>
);
}

View file

@ -63,7 +63,6 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
}, },
onSubmit: async (values) => { onSubmit: async (values) => {
try { try {
console.log({values})
const submitData = { const submitData = {
snippetCode: values.snippetCode, snippetCode: values.snippetCode,

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,6 +1,6 @@
import { createTRPCProxyClient, httpBatchLink, TRPCClientError } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink, TRPCClientError } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query'; import { createTRPCReact } from '@trpc/react-query';
import {AppRouter} from '../../backend/src/trpc/router' import { AppRouter } from '@backend/trpc/router'
import { BASE_API_URL } from 'common-ui'; import { BASE_API_URL } from 'common-ui';
import { getJWT } from '@/hooks/useJWT'; import { getJWT } from '@/hooks/useJWT';
import { FORCE_LOGOUT_EVENT } from 'common-ui/src/lib/const-strs'; import { FORCE_LOGOUT_EVENT } from 'common-ui/src/lib/const-strs';

View file

@ -4,7 +4,11 @@
"strict": true, "strict": true,
"paths": { "paths": {
"@/*": [ "@/*": [
"./*" "./*",
"../backend/*"
],
"@backend/*": [
"../backend/src/*"
], ],
"shared-types": ["../shared-types"], "shared-types": ["../shared-types"],
"common-ui": ["../../packages/ui"], "common-ui": ["../../packages/ui"],

View file

@ -1,7 +1,6 @@
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
@ -21,6 +20,7 @@ S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
S3_BUCKET_NAME=meatfarmer S3_BUCKET_NAME=meatfarmer
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK- EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
JWT_SECRET=my_meatfarmer_jwt_secret_key JWT_SECRET=my_meatfarmer_jwt_secret_key
ASSETS_DOMAIN=https://assets.freshyo.in/
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379 # REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379 REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
APP_URL=http://localhost:4000 APP_URL=http://localhost:4000

View file

@ -0,0 +1 @@
This is a demo file.

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

View file

View file

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "freshyo-cefb2",
"private_key_id": "dcdb3d9edb6505567db69bbd24e447df78c82dc7",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDE3TSKEL9CF7yP\nUiSIvQC024yQGrERz1wtErH5Xff4pie1LSL1pXJHTpyWojp6dotkmCpxM36XjS+O\nO5pnJhSVwiYgvSxeO3EL6oNQwLP36pxQ7YwmoaFx9Jipau+OK+VY8Y/eMx4cWUJH\n7WeUDGwwJlMKE6CpEsbbBiAY5bF9wwe7v1YlkAnMm5ZZcujCqW1aShWKXuYoUMoP\n6egEiclCdQrHZ5IQCHRWruFTAOBuJ7v0A/9WM1gi7UM3VU3/8ccswP8DDoCrgrmh\nerUhFAEFMEjsns0B8SmwQ8v3GH5/SG0SCDwJniPFPnzdSxksaEB51OTaBcROJlED\nkwZZ+2u/AgMBAAECggEAPYL2vysjb6XWC5w5gSY5Ocmd/orwh+WYYhcE2CuV5zIX\nlyM+2K106zXzdJfFGO3AeVKYdF2IMRdy5AjYomFCLlcHLdSeL+V32abRmCJWOWEr\nrZfD4nA/b0ljiBA7QNuTYnq8HswvHOGA9dOGuTo2dccLzEq8uQd+bgJYdh8TGf2R\nqOpYHdRUJqDl+EuCDqLLqnq4l8E981GN78iVVL4DnYFE/3wb7tmuONww2+grq7ou\ntDtPQf4yNE2Vfx+5JnMsvU+J+iF/4vCI/9Oyg0keW/C8q9rbDdmeyefGRGWHtCCI\nH1wMzYTn2xw7EBH9O1NHDzTWkSTUfeo2dnaR0loVsQKBgQDnuwk180u1QXoDLUzj\ngd86CRnP/zqijdt+Y2oZnT+uHJrHCJbYNt3bdKRUEBU5KEcMdzMaeR8A1YEYK2oD\nd8M42nsOn22VymT0fIqwrHsf9e5mgGV+novqw848aEmgTIEmBnSYKc3Xa3px3wge\nJWLKlX/+y2uvI9En8u1FGQ0wcQKBgQDZe1uLqd6koPh/+VSAx2OtjCDgCAvlYoUh\nwIH3tFab/p41DyR+VDx6z18MACsSmyiewV4xUBmu1o+H/iiOxPXQvf7QchY+fYYb\nzOMJXM4ddcGUdLF8CPapbIFcLKemQIb0PIlrCQQeXq2E74JacP8kdqNmCQ8J/GZF\nMPapRTt3LwKBgQCU5jLJ7tZD1pnO9snEGkxUn0ptw0Nq9hoGwVyIrukfOJQfth4v\nOjoebHm25kqs2nukv+cfaJqKT6ZO4H6TUd4oZwLRZ5HjwRRToL8BPSM0azNPu8r7\nrGadaEnZuO0uSlpmE5nRuHLiq9YW20f9DurG339KOm2sMSiRMeBSGQHHkQKBgGTZ\nFQxgiwOgOVtujMbirtAtKJl6YbnOw5lxIVNx5q+TlF1aVjvWZ+0y+Aoikdag6Gcl\nl74aPK6chBY1vyzlHG/diqmyHaqAno2JpsYSqOl0T3291weDSI4r6JiLhHpNdccP\nw1FE7wn+MUxxm+rAdy+7a+3GyZiB2BLBr7+ygO61AoGBALySZ9m4hgX6uZtJvv3R\nrl8AWoG65NHCZ4694aEGTJDVDlPByV+Sd5iBOQ5dvhgA12Py2uj5ZHQXbuo0IGfJ\ngH8AZMIKX9UrhbE5BWYncg2ZR8uvKow8w36mLNnQhGZ71IZ9MXbWbpEK8CbCEvzZ\nyw0rKVgrrSRihW3stnl16Zs5\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@freshyo-cefb2.iam.gserviceaccount.com",
"client_id": "117456013812283364643",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40freshyo-cefb2.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

0
apps/backend/demo.json Normal file
View file

View file

@ -0,0 +1,7 @@
CREATE TABLE "mf"."user_notifications" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."user_notifications_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"image_url" varchar(500),
"created_at" timestamp DEFAULT now() NOT NULL,
"body" text NOT NULL,
"applicable_users" jsonb
);

View file

@ -0,0 +1 @@
ALTER TABLE "mf"."user_notifications" ADD COLUMN "title" varchar(255) NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE "mf"."delivery_slot_info" ADD COLUMN "is_capacity_full" boolean DEFAULT false NOT NULL;

View file

@ -0,0 +1,7 @@
CREATE TABLE "mf"."unlogged_user_tokens" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."unlogged_user_tokens_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"token" varchar(500) NOT NULL,
"added_at" timestamp DEFAULT now() NOT NULL,
"last_verified" timestamp,
CONSTRAINT "unlogged_user_tokens_token_unique" UNIQUE("token")
);

View file

@ -0,0 +1,13 @@
CREATE TABLE "mf"."user_incidents" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."user_incidents_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"order_id" integer,
"date_added" timestamp DEFAULT now() NOT NULL,
"admin_comment" text,
"added_by" integer,
"negativity_score" integer
);
--> statement-breakpoint
ALTER TABLE "mf"."user_incidents" ADD CONSTRAINT "user_incidents_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "mf"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mf"."user_incidents" ADD CONSTRAINT "user_incidents_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "mf"."orders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mf"."user_incidents" ADD CONSTRAINT "user_incidents_added_by_staff_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "mf"."staff_users"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -505,6 +505,41 @@
"when": 1770321591876, "when": 1770321591876,
"tag": "0071_moaning_shadow_king", "tag": "0071_moaning_shadow_king",
"breakpoints": true "breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1770546741428,
"tag": "0072_flowery_deathbird",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1770561175889,
"tag": "0073_faithful_gravity",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1771674555093,
"tag": "0074_outgoing_black_cat",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1772196660983,
"tag": "0075_cuddly_rocket_racer",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1772637259874,
"tag": "0076_sturdy_wolverine",
"breakpoints": true
} }
] ]
} }

View file

@ -5,21 +5,21 @@ import cors from "cors";
import multer from "multer"; import multer from "multer";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { db } from './src/db/db_index'; import { db } from '@/src/db/db_index';
import { staffUsers, userDetails } from './src/db/schema'; import { staffUsers, userDetails } from '@/src/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import mainRouter from './src/main-router'; import mainRouter from '@/src/main-router';
import initFunc from './src/lib/init'; import initFunc from '@/src/lib/init';
import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { appRouter } from './src/trpc/router'; import { appRouter } from '@/src/trpc/router';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import signedUrlCache from 'src/lib/signed-url-cache'; import signedUrlCache from '@/src/lib/signed-url-cache';
import { seed } from 'src/db/seed'; import { seed } from '@/src/db/seed';
import './src/jobs/jobs-index'; import '@/src/jobs/jobs-index';
import { startAutomatedJobs } from './src/lib/automatedJobs'; import { startAutomatedJobs } from '@/src/lib/automatedJobs';
// seed() seed()
initFunc() initFunc()
startAutomatedJobs() startAutomatedJobs()
@ -163,6 +163,15 @@ if (fs.existsSync(fallbackUiIndex)) {
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`) console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
} }
// Serve /assets/public folder at /assets route
const assetsPublicDir = path.resolve(__dirname, './assets/public');
if (fs.existsSync(assetsPublicDir)) {
app.use('/assets', express.static(assetsPublicDir));
console.log('Serving /assets from', assetsPublicDir);
} else {
console.warn('Assets public folder not found at', assetsPublicDir);
}
// Global error handler // Global error handler
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err); console.error(err);
@ -171,6 +180,6 @@ app.use((err: any, req: express.Request, res: express.Response, next: express.Ne
res.status(status).json({ message }); res.status(status).json({ message });
}); });
app.listen(4000, () => { app.listen(4000, '::', () => {
console.log("Server is running on http://localhost:4000/api/mobile/"); console.log("Server is running on http://localhost:4000/api/mobile/");
}); });

View file

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"migrate": "drizzle-kit generate:pg", "migrate": "drizzle-kit generate:pg",
"build": "rimraf ./dist && tsc --project tsconfig.json", "build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
"build2": "rimraf ./dist && tsc", "build2": "rimraf ./dist && tsc",
"db:push": "drizzle-kit push:pg", "db:push": "drizzle-kit push:pg",
"db:seed": "tsx src/db/seed.ts", "db:seed": "tsx src/db/seed.ts",
@ -42,7 +42,6 @@
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"pg-sdk-node": "https://phonepe.mycloudrepo.io/public/repositories/phonepe-pg-sdk-node/releases/v2/phonepe-pg-sdk-node.tgz",
"razorpay": "^2.9.6", "razorpay": "^2.9.6",
"redis": "^5.9.0", "redis": "^5.9.0",
"zod": "^4.1.12" "zod": "^4.1.12"
@ -55,6 +54,7 @@
"rimraf": "^6.1.2", "rimraf": "^6.1.2",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.2" "typescript": "^5.9.2"
} }
} }

View file

@ -1,7 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { authenticateStaff } from "../middleware/staff-auth"; import { authenticateStaff } from "@/src/middleware/staff-auth";
import productRouter from "./product.router"; import productRouter from "@/src/apis/admin-apis/apis/product.router"
import tagRouter from "./tag.router"; import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
const router = Router(); const router = Router();

View file

@ -1,11 +1,11 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { db } from "../db/db_index"; import { db } from "@/src/db/db_index";
import { productTagInfo } from "../db/schema"; import { productTagInfo } from "@/src/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { ApiError } from "../lib/api-error"; import { ApiError } from "@/src/lib/api-error";
import { imageUploadS3, generateSignedUrlFromS3Url } from "../lib/s3-client"; import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
import { deleteS3Image } from "../lib/delete-image"; import { deleteS3Image } from "@/src/lib/delete-image";
import { initializeAllStores } from '../stores/store-initializer'; import { initializeAllStores } from '@/src/stores/store-initializer';
/** /**
* Create a new product tag * Create a new product tag

View file

@ -1,12 +1,12 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { db } from "../db/db_index"; import { db } from "@/src/db/db_index";
import { productInfo, units, specialDeals, productTags } from "../db/schema"; import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { ApiError } from "../lib/api-error"; import { ApiError } from "@/src/lib/api-error";
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "../lib/s3-client"; import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
import { deleteS3Image } from "../lib/delete-image"; import { deleteS3Image } from "@/src/lib/delete-image";
import type { SpecialDeal } from "../db/types"; import type { SpecialDeal } from "@/src/db/types";
import { initializeAllStores } from '../stores/store-initializer'; import { initializeAllStores } from '@/src/stores/store-initializer';
type CreateDeal = { type CreateDeal = {
quantity: number; quantity: number;
@ -124,7 +124,6 @@ export const updateProduct = async (req: Request, res: Response) => {
const { id } = req.params; const { id } = req.params;
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body; const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
console.log({productQuantity})
const deals = dealsRaw ? JSON.parse(dealsRaw) : null; const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : []; const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];

View file

@ -1,6 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { createProduct, updateProduct } from "./product.controller"; import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
import uploadHandler from '../lib/upload-handler'; import uploadHandler from '@/src/lib/upload-handler';
const router = Router(); const router = Router();

View file

@ -1,6 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "./product-tags.controller"; import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
import uploadHandler from '../lib/upload-handler'; import uploadHandler from '@/src/lib/upload-handler';
const router = Router(); const router = Router();

View file

@ -1,8 +1,8 @@
import { eq, gt, and, sql, inArray } from "drizzle-orm"; import { eq, gt, and, sql, inArray } from "drizzle-orm";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { db } from "../db/db_index"; import { db } from "@/src/db/db_index"
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "../db/schema"; import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
import { generateSignedUrlsFromS3Urls } from "../lib/s3-client"; import { scaffoldAssetUrl } from "@/src/lib/s3-client"
/** /**
* Get next delivery date for a product * Get next delivery date for a product
@ -89,7 +89,7 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
productQuantity: product.productQuantity, productQuantity: product.productQuantity,
isOutOfStock: product.isOutOfStock, isOutOfStock: product.isOutOfStock,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []), images: scaffoldAssetUrl((product.images as string[]) || []),
}; };
}) })
); );

View file

@ -1,5 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { getAllProductsSummary } from "./common-product.controller"; import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
const router = Router(); const router = Router();

View file

@ -1,5 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import commonProductsRouter from "./common-product.router"; import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
const router = Router(); const router = Router();

View file

@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/node-postgres" import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator" import { migrate } from "drizzle-orm/node-postgres/migrator"
import path from "path" import path from "path"
import * as schema from "./schema" import * as schema from "@/src/db/schema"
const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema }) const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema })
// const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler'); // const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler');

View file

@ -2,13 +2,13 @@
* This was a one time script to change the composition of the signed urls * This was a one time script to change the composition of the signed urls
*/ */
import { db } from './db_index'; import { db } from '@/src/db/db_index'
import { import {
userDetails, userDetails,
productInfo, productInfo,
productTagInfo, productTagInfo,
complaints complaints
} from './schema'; } from '@/src/db/schema';
import { eq, not, isNull } from 'drizzle-orm'; import { eq, not, isNull } from 'drizzle-orm';
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net'; const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';

View file

@ -192,6 +192,7 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', {
freezeTime: timestamp('freeze_time').notNull(), freezeTime: timestamp('freeze_time').notNull(),
isActive: boolean('is_active').notNull().default(true), isActive: boolean('is_active').notNull().default(true),
isFlash: boolean('is_flash').notNull().default(false), isFlash: boolean('is_flash').notNull().default(false),
isCapacityFull: boolean('is_capacity_full').notNull().default(false),
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}), deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
groupIds: jsonb('group_ids').$defaultFn(() => []), groupIds: jsonb('group_ids').$defaultFn(() => []),
}); });
@ -390,6 +391,16 @@ export const couponApplicableProducts = mf.table('coupon_applicable_products', {
unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId), unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId),
})); }));
export const userIncidents = mf.table('user_incidents', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
orderId: integer('order_id').references(() => orders.id),
dateAdded: timestamp('date_added').notNull().defaultNow(),
adminComment: text('admin_comment'),
addedBy: integer('added_by').references(() => staffUsers.id),
negativityScore: integer('negativity_score'),
});
export const reservedCoupons = mf.table('reserved_coupons', { export const reservedCoupons = mf.table('reserved_coupons', {
id: integer().primaryKey().generatedAlwaysAsIdentity(), id: integer().primaryKey().generatedAlwaysAsIdentity(),
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(), secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
@ -419,6 +430,22 @@ export const notifCreds = mf.table('notif_creds', {
lastVerified: timestamp('last_verified'), lastVerified: timestamp('last_verified'),
}); });
export const unloggedUserTokens = mf.table('unlogged_user_tokens', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
token: varchar({ length: 500 }).notNull().unique(),
addedAt: timestamp('added_at').notNull().defaultNow(),
lastVerified: timestamp('last_verified'),
});
export const userNotifications = mf.table('user_notifications', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: varchar('title', { length: 255 }).notNull(),
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(),
@ -456,6 +483,7 @@ export const usersRelations = relations(users, ({ many, one }) => ({
applicableCoupons: many(couponApplicableUsers), applicableCoupons: many(couponApplicableUsers),
userDetails: one(userDetails), userDetails: one(userDetails),
notifCreds: many(notifCreds), notifCreds: many(notifCreds),
userIncidents: many(userIncidents),
})); }));
export const userCredsRelations = relations(userCreds, ({ one }) => ({ export const userCredsRelations = relations(userCreds, ({ one }) => ({
@ -525,6 +553,7 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
orderStatus: many(orderStatus), orderStatus: many(orderStatus),
refunds: many(refunds), refunds: many(refunds),
couponUsages: many(couponUsage), couponUsages: many(couponUsage),
userIncidents: many(userIncidents),
})); }));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({ export const orderItemsRelations = relations(orderItems, ({ one }) => ({
@ -588,6 +617,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),
@ -648,3 +681,9 @@ export const staffRolePermissionsRelations = relations(staffRolePermissions, ({
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }), role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }), permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
})); }));
export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
user: one(users, { fields: [userIncidents.userId], references: [users.id] }),
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }),
}));

View file

@ -1,8 +1,8 @@
import { db } from "./db_index"; import { db } from "@/src/db/db_index"
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "./schema"; import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "@/src/db/schema"
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { minOrderValue, deliveryCharge } from '../lib/env-exporter'; import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter'
import { CONST_KEYS } from '../lib/const-keys'; import { CONST_KEYS } from '@/src/lib/const-keys'
export async function seed() { export async function seed() {
console.log("Seeding database..."); console.log("Seeding database...");
@ -113,9 +113,10 @@ export async function seed() {
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 }, { key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 }, { key: CONST_KEYS.flashDeliveryCharge, value: 69 },
{ key: CONST_KEYS.popularItems, value: [] }, { key: CONST_KEYS.popularItems, 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

@ -14,7 +14,7 @@ import type {
productCategories, productCategories,
cartItems, cartItems,
coupons, coupons,
} from "./schema"; } from "@/src/db/schema";
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Address = InferSelectModel<typeof addresses>; export type Address = InferSelectModel<typeof addresses>;

View file

@ -1,5 +1,5 @@
import * as cron from 'node-cron'; import * as cron from 'node-cron';
import { checkPendingPayments, checkRefundStatuses } from './payment-status-checker'; import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
const runCombinedJob = async () => { const runCombinedJob = async () => {
const start = Date.now(); const start = Date.now();

View file

@ -1,8 +1,8 @@
import * as cron from 'node-cron'; import * as cron from 'node-cron';
import { db } from '../db/db_index'; import { db } from '@/src/db/db_index'
import { payments, orders, deliverySlotInfo, refunds } from '../db/schema'; import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
import { eq, and, gt, isNotNull } from 'drizzle-orm'; import { eq, and, gt, isNotNull } from 'drizzle-orm';
import { RazorpayPaymentService } from '../lib/payments-utils'; import { RazorpayPaymentService } from '@/src/lib/payments-utils'
interface PendingPaymentRecord { interface PendingPaymentRecord {
payment: typeof payments.$inferSelect; payment: typeof payments.$inferSelect;

View file

@ -1,9 +1,9 @@
import * as cron from 'node-cron'; import * as cron from 'node-cron';
import { db } from '../db/db_index'; import { db } from '@/src/db/db_index'
import { productInfo, keyValStore } from '../db/schema'; import { productInfo, keyValStore } from '@/src/db/schema'
import { inArray, eq } from 'drizzle-orm'; import { inArray, eq } from 'drizzle-orm';
import { CONST_KEYS } from '../lib/const-keys'; import { CONST_KEYS } from '@/src/lib/const-keys'
import { computeConstants } from '../lib/const-store'; import { computeConstants } from '@/src/lib/const-store'
const MUTTON_ITEMS = [ const MUTTON_ITEMS = [

View file

@ -1,5 +1,5 @@
import axiosParent from "axios"; import axiosParent from "axios";
import { phonePeBaseUrl } from "./env-exporter"; import { phonePeBaseUrl } from "@/src/lib/env-exporter"
export const phonepeAxios = axiosParent.create({ export const phonepeAxios = axiosParent.create({
baseURL: phonePeBaseUrl, baseURL: phonePeBaseUrl,

View file

@ -6,6 +6,7 @@ export const CONST_KEYS = {
flashDeliveryCharge: 'flashDeliveryCharge', flashDeliveryCharge: 'flashDeliveryCharge',
platformFeePercent: 'platformFeePercent', platformFeePercent: 'platformFeePercent',
taxRate: 'taxRate', taxRate: 'taxRate',
tester: 'tester',
minOrderAmountForCoupon: 'minOrderAmountForCoupon', minOrderAmountForCoupon: 'minOrderAmountForCoupon',
maxCouponDiscount: 'maxCouponDiscount', maxCouponDiscount: 'maxCouponDiscount',
flashDeliverySlotId: 'flashDeliverySlotId', flashDeliverySlotId: 'flashDeliverySlotId',
@ -14,6 +15,7 @@ export const CONST_KEYS = {
playStoreUrl: 'playStoreUrl', playStoreUrl: 'playStoreUrl',
appStoreUrl: 'appStoreUrl', appStoreUrl: 'appStoreUrl',
popularItems: 'popularItems', popularItems: 'popularItems',
allItemsOrder: 'allItemsOrder',
isFlashDeliveryEnabled: 'isFlashDeliveryEnabled', isFlashDeliveryEnabled: 'isFlashDeliveryEnabled',
supportMobile: 'supportMobile', supportMobile: 'supportMobile',
supportEmail: 'supportEmail', supportEmail: 'supportEmail',
@ -27,6 +29,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
flashDeliveryCharge: 'Flash Delivery Charge', flashDeliveryCharge: 'Flash Delivery Charge',
platformFeePercent: 'Platform Fee Percent', platformFeePercent: 'Platform Fee Percent',
taxRate: 'Tax Rate', taxRate: 'Tax Rate',
tester: 'Tester',
minOrderAmountForCoupon: 'Minimum Order Amount for Coupon', minOrderAmountForCoupon: 'Minimum Order Amount for Coupon',
maxCouponDiscount: 'Maximum Coupon Discount', maxCouponDiscount: 'Maximum Coupon Discount',
flashDeliverySlotId: 'Flash Delivery Slot ID', flashDeliverySlotId: 'Flash Delivery Slot ID',
@ -35,6 +38,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
playStoreUrl: 'Play Store URL', playStoreUrl: 'Play Store URL',
appStoreUrl: 'App Store URL', appStoreUrl: 'App Store URL',
popularItems: 'Popular Items', popularItems: 'Popular Items',
allItemsOrder: 'All Items Order',
isFlashDeliveryEnabled: 'Enable Flash Delivery', isFlashDeliveryEnabled: 'Enable Flash Delivery',
supportMobile: 'Support Mobile', supportMobile: 'Support Mobile',
supportEmail: 'Support Email', supportEmail: 'Support Email',

View file

@ -1,7 +1,7 @@
import { db } from '../db/db_index'; import { db } from '@/src/db/db_index'
import { keyValStore } from '../db/schema'; import { keyValStore } from '@/src/db/schema'
import redisClient from './redis-client'; import redisClient from '@/src/lib/redis-client'
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from './const-keys'; import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys'
const CONST_REDIS_PREFIX = 'const:'; const CONST_REDIS_PREFIX = 'const:';
@ -14,7 +14,7 @@ export const computeConstants = async (): Promise<void> => {
for (const constant of constants) { for (const constant of constants) {
const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`; const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`;
const value = JSON.stringify(constant.value); const value = JSON.stringify(constant.value);
console.log({redisKey, value}) // console.log({redisKey, value})
await redisClient.set(redisKey, value); await redisClient.set(redisKey, value);
} }

View file

@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "../db/db_index"; import { db } from "@/src/db/db_index"
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "./s3-client"; import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
import { s3Url } from "./env-exporter"; import { s3Url } from "@/src/lib/env-exporter"
function extractS3Key(url: string): string | null { function extractS3Key(url: string): string | null {
try { try {

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index'; import { db } from '@/src/db/db_index'
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema'; import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
/** /**

View file

@ -15,6 +15,8 @@ export const s3BucketName = process.env.S3_BUCKET_NAME as string
export const s3Region = process.env.S3_REGION as string export const s3Region = process.env.S3_REGION as string
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
export const s3Url = process.env.S3_URL as string export const s3Url = process.env.S3_URL as string
export const redisUrl = process.env.REDIS_URL as string export const redisUrl = process.env.REDIS_URL as string

View file

@ -1,4 +1,4 @@
import redisClient from './redis-client'; import redisClient from '@/src/lib/redis-client'
export async function enqueue(queueName: string, eventData: any): Promise<boolean> { export async function enqueue(queueName: string, eventData: any): Promise<boolean> {
try { try {

View file

@ -1,6 +1,6 @@
import { Expo } from "expo-server-sdk"; import { Expo } from "expo-server-sdk";
import { title } from "process"; import { title } from "process";
import { expoAccessToken } from "./env-exporter"; import { expoAccessToken } from "@/src/lib/env-exporter"
const expo = new Expo({ const expo = new Expo({
accessToken: expoAccessToken, accessToken: expoAccessToken,

View file

@ -1,7 +1,8 @@
import './notif-job'; import '@/src/lib/notif-job'
import { initializeAllStores } from '../stores/store-initializer'; import { initializeAllStores } from '@/src/stores/store-initializer'
import { startOrderHandler, startCancellationHandler, publishOrder } from './post-order-handler'; import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
import { deleteOrders } from './delete-orders'; import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
import { deleteOrders } from '@/src/lib/delete-orders'
/** /**
* Initialize all application services * Initialize all application services
@ -10,6 +11,7 @@ import { deleteOrders } from './delete-orders';
* - Const Store (syncs constants from DB to Redis) * - Const Store (syncs constants from DB to Redis)
* - Post Order Handler (Redis Pub/Sub subscriber) * - Post Order Handler (Redis Pub/Sub subscriber)
* - Cancellation Handler (Redis Pub/Sub subscriber for order cancellations) * - Cancellation Handler (Redis Pub/Sub subscriber for order cancellations)
* - User Negativity Store (caches user negativity scores in Redis)
* - Other services can be added here in the future * - Other services can be added here in the future
*/ */
export const initFunc = async (): Promise<void> => { export const initFunc = async (): Promise<void> => {
@ -18,6 +20,7 @@ export const initFunc = async (): Promise<void> => {
await Promise.all([ await Promise.all([
initializeAllStores(), initializeAllStores(),
initializeUserNegativityStore(),
startOrderHandler(), startOrderHandler(),
startCancellationHandler(), startCancellationHandler(),
]); ]);

View file

@ -1,5 +1,8 @@
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import { redisUrl } from './env-exporter'; import { Expo } from 'expo-server-sdk';
import { redisUrl } from '@/src/lib/env-exporter'
import { db } from '@/src/db/db_index'
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
import { import {
NOTIFS_QUEUE, NOTIFS_QUEUE,
ORDER_PLACED_MESSAGE, ORDER_PLACED_MESSAGE,
@ -9,26 +12,78 @@ import {
ORDER_DELIVERED_MESSAGE, ORDER_DELIVERED_MESSAGE,
ORDER_CANCELLED_MESSAGE, ORDER_CANCELLED_MESSAGE,
REFUND_INITIATED_MESSAGE REFUND_INITIATED_MESSAGE
} from './const-strings'; } from '@/src/lib/const-strings';
export const notificationQueue = new Queue(NOTIFS_QUEUE, { export const notificationQueue = new Queue(NOTIFS_QUEUE, {
connection: { url: redisUrl }, connection: { url: redisUrl },
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: 50, removeOnComplete: true,
removeOnFail: 100, removeOnFail: 10,
attempts: 3, attempts: 3,
}, },
}); });
export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => { export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
if (!job) return; if (!job) return;
console.log(`Processing notification job ${job.id}`);
// TODO: Implement sendPushNotification const { name, data } = job;
console.log(`Processing notification job ${job.id} - ${name}`);
if (name === 'send-admin-notification') {
await sendAdminNotification(data);
} else if (name === 'send-notification') {
// Handle legacy notification type
console.log('Legacy notification job - not implemented yet');
}
}, { }, {
connection: { url: redisUrl }, connection: { url: redisUrl },
concurrency: 5, concurrency: 5,
}); });
async function sendAdminNotification(data: {
token: string;
title: string;
body: string;
imageUrl: string | null;
}) {
const { token, title, body, imageUrl } = data;
// Validate Expo push token
if (!Expo.isExpoPushToken(token)) {
console.error(`Invalid Expo push token: ${token}`);
return;
}
// Generate signed URL for image if provided
const signedImageUrl = imageUrl ? await generateSignedUrlFromS3Url(imageUrl) : null;
// Send notification
const expo = new Expo();
const message = {
to: token,
sound: 'default',
title,
body,
data: { imageUrl },
...(signedImageUrl ? {
attachments: [
{
url: signedImageUrl,
contentType: 'image/jpeg',
}
]
} : {}),
};
try {
const [ticket] = await expo.sendPushNotificationsAsync([message]);
console.log(`Notification sent:`, ticket);
} catch (error) {
console.error(`Failed to send notification:`, error);
throw error;
}
}
notificationWorker.on('completed', (job) => { notificationWorker.on('completed', (job) => {
if (job) console.log(`Notification job ${job.id} completed`); if (job) console.log(`Notification job ${job.id} completed`);
}); });

View file

@ -1,6 +1,6 @@
import { db } from "../db/db_index"; import { db } from "@/src/db/db_index"
import { sendPushNotificationsMany } from "./expo-service"; import { sendPushNotificationsMany } from "@/src/lib/expo-service"
// import { usersTable, notifCredsTable, notificationTable } from "../db/schema"; // import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
// Core notification dispatch methods (renamed for clarity) // Core notification dispatch methods (renamed for clarity)

View file

@ -1,5 +1,5 @@
import { ApiError } from './api-error'; import { ApiError } from '@/src/lib/api-error'
import { otpSenderAuthToken } from './env-exporter'; import { otpSenderAuthToken } from '@/src/lib/env-exporter'
const otpStore = new Map<string, string>(); const otpStore = new Map<string, string>();

View file

@ -1,7 +1,7 @@
import Razorpay from "razorpay"; import Razorpay from "razorpay";
import { razorpayId, razorpaySecret } from "./env-exporter"; import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"
import { db } from "../db/db_index"; import { db } from "@/src/db/db_index"
import { payments } from "../db/schema"; import { payments } from "@/src/db/schema"
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]; type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];

View file

@ -1,7 +1,7 @@
import { db } from '../db/db_index'; import { db } from '@/src/db/db_index'
import { orders, orderStatus } from '../db/schema'; import { orders, orderStatus } from '@/src/db/schema'
import redisClient from './redis-client'; import redisClient from '@/src/lib/redis-client'
import { sendTelegramMessage } from './telegram-service'; import { sendTelegramMessage } from '@/src/lib/telegram-service'
import { inArray, eq } from 'drizzle-orm'; import { inArray, eq } from 'drizzle-orm';
const ORDER_CHANNEL = 'orders:placed'; const ORDER_CHANNEL = 'orders:placed';
@ -35,7 +35,10 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
message += '📦 <b>Items:</b>\n'; message += '📦 <b>Items:</b>\n';
order.orderItems?.forEach((item: any) => { order.orderItems?.forEach((item: any) => {
message += `${item.product?.name || 'Unknown'} x${item.quantity}\n`; const productQuantity = item.product?.productQuantity ?? 1
const unitNotation = item.product?.unit?.shortNotation || ''
const quantityWithUnit = unitNotation ? `${productQuantity}${unitNotation}` : `${productQuantity}`
message += `${item.product?.name || 'Unknown'} ${quantityWithUnit} x${item.quantity}\n`;
}); });
message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`; message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`;
@ -72,7 +75,12 @@ const formatCancellationMessage = (orderData: any, cancellationData: Cancellatio
📞 <b>Phone:</b> ${orderData.address?.phone || 'N/A'} 📞 <b>Phone:</b> ${orderData.address?.phone || 'N/A'}
📦 <b>Items:</b> 📦 <b>Items:</b>
${orderData.orderItems?.map((item: any) => `${item.product?.name || 'Unknown'} x${item.quantity}`).join('\n') || ' N/A'} ${orderData.orderItems?.map((item: any) => {
const productQuantity = item.product?.productQuantity ?? 1
const unitNotation = item.product?.unit?.shortNotation || ''
const quantityWithUnit = unitNotation ? `${productQuantity}${unitNotation}` : `${productQuantity}`
return `${item.product?.name || 'Unknown'} ${quantityWithUnit} x${item.quantity}`
}).join('\n') || ' N/A'}
💰 <b>Total:</b> ${orderData.totalAmount} 💰 <b>Total:</b> ${orderData.totalAmount}
💳 <b>Refund:</b> ${orderData.refundStatus === 'na' ? 'N/A (COD)' : orderData.refundStatus || 'Pending'} 💳 <b>Refund:</b> ${orderData.refundStatus === 'na' ? 'N/A (COD)' : orderData.refundStatus || 'Pending'}
@ -102,7 +110,7 @@ export const startOrderHandler = async (): Promise<void> => {
where: inArray(orders.id, orderIds), where: inArray(orders.id, orderIds),
with: { with: {
address: true, address: true,
orderItems: { with: { product: true } }, orderItems: { with: { product: { with: { unit: true } } } },
slot: true, slot: true,
}, },
}); });
@ -147,7 +155,7 @@ export const startCancellationHandler = async (): Promise<void> => {
where: eq(orders.id, cancellationData.orderId), where: eq(orders.id, cancellationData.orderId),
with: { with: {
address: true, address: true,
orderItems: { with: { product: true } }, orderItems: { with: { product: { with: { unit: true } } } },
refunds: true, refunds: true,
}, },
}); });

View file

@ -1,5 +1,5 @@
import { createClient, RedisClientType } from 'redis'; import { createClient, RedisClientType } from 'redis';
import { redisUrl } from './env-exporter'; import { redisUrl } from '@/src/lib/env-exporter'
class RedisClient { class RedisClient {
private client: RedisClientType; private client: RedisClientType;

View file

@ -1,4 +1,4 @@
import { db } from "../db/db_index"; import { db } from "@/src/db/db_index"
/** /**
* Constants for role names to avoid hardcoding and typos * Constants for role names to avoid hardcoding and typos

View file

@ -1,10 +1,10 @@
// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "../lib/env-exporter" // import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter"
import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3" import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import signedUrlCache from "./signed-url-cache" import signedUrlCache from "@/src/lib/signed-url-cache"
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName } from "./env-exporter"; import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
import { db } from "../db/db_index"; // Adjust path if needed import { db } from "@/src/db/db_index"; // Adjust path if needed
import { uploadUrlStatus } from "../db/schema"; import { uploadUrlStatus } from "@/src/db/schema"
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
const s3Client = new S3Client({ const s3Client = new S3Client({
@ -60,6 +60,22 @@ export async function deleteImageUtil({bucket = s3BucketName, keys}:{bucket?:str
} }
} }
export function scaffoldAssetUrl(input: string | null): string
export function scaffoldAssetUrl(input: (string | null)[]): string[]
export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] {
if (Array.isArray(input)) {
return input.map(key => scaffoldAssetUrl(key) as string);
}
if (!input) {
return '';
}
const normalizedKey = input.replace(/^\/+/, '');
const domain = assetsDomain.endsWith('/')
? assetsDomain.slice(0, -1)
: assetsDomain;
return `${domain}/${normalizedKey}`;
}
/** /**
* Generate a signed URL from an S3 URL * Generate a signed URL from an S3 URL

View file

@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { isDevMode, telegramBotToken, telegramChatIds } from './env-exporter'; import { isDevMode, telegramBotToken, telegramChatIds } from '@/src/lib/env-exporter'
const BOT_TOKEN = telegramBotToken; const BOT_TOKEN = telegramBotToken;
const CHAT_IDS = telegramChatIds; const CHAT_IDS = telegramChatIds;

View file

@ -1,11 +1,11 @@
import { Router, Request, Response, NextFunction } from "express"; import { Router, Request, Response, NextFunction } from "express";
import avRouter from "./admin-apis/av-router"; import avRouter from "@/src/apis/admin-apis/apis/av-router"
import { ApiError } from "./lib/api-error"; import { ApiError } from "@/src/lib/api-error"
import v1Router from "./v1-router"; import v1Router from "@/src/v1-router"
import testController from "./test-controller"; import testController from "@/src/test-controller"
import { authenticateUser } from "./middleware/auth.middleware"; import { authenticateUser } from "@/src/middleware/auth.middleware"
import { raiseComplaint } from "./uv-apis/user-rest.controller"; import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
import uploadHandler from "./lib/upload-handler"; import uploadHandler from "@/src/lib/upload-handler"
const router = Router(); const router = Router();

View file

@ -1,9 +1,9 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { db } from '../db/db_index'; import { db } from '@/src/db/db_index'
import { staffUsers, userDetails } from '../db/schema'; import { staffUsers, userDetails } from '@/src/db/schema'
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { ApiError } from '../lib/api-error'; import { ApiError } from '@/src/lib/api-error'
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
user?: { user?: {

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { ApiError } from '../lib/api-error'; import { ApiError } from '@/src/lib/api-error'
// Extend the Request interface to include user property // Extend the Request interface to include user property
declare global { declare global {

Some files were not shown because too many files have changed in this diff Show more