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

@ -50,4 +50,4 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
## Important Notes
- **Do not run build, compile, or migration commands** - These should be handled manually by developers
- Avoid running `npm run build`, `tsc`, `drizzle-kit generate`, or similar compilation/migration commands
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user

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"
}

File diff suppressed because one or more lines are too long

View file

@ -114,20 +114,34 @@ function CustomDrawerContent() {
<MaterialIcons name="code" size={size} color={color} />
)}
/>
<DrawerItem
label="Stores"
onPress={() => router.push("/(drawer)/stores" as any)}
icon={({ color, size }) => (
<MaterialIcons name="store" size={size} color={color} />
)}
/>
<DrawerItem
label="Logout"
onPress={() => logout()}
icon={({ color, size }) => (
<MaterialIcons name="logout" size={size} color={color} />
)}
/>
<DrawerItem
label="Stores"
onPress={() => router.push("/(drawer)/stores" as any)}
icon={({ color, size }) => (
<MaterialIcons name="store" size={size} color={color} />
)}
/>
<DrawerItem
label="User Management"
onPress={() => router.push("/(drawer)/user-management" as any)}
icon={({ color, size }) => (
<MaterialIcons name="people" size={size} color={color} />
)}
/>
<DrawerItem
label="Send Notifications"
onPress={() => router.push("/(drawer)/send-notifications" as any)}
icon={({ color, size }) => (
<MaterialIcons name="campaign" size={size} color={color} />
)}
/>
<DrawerItem
label="Logout"
onPress={() => logout()}
icon={({ color, size }) => (
<MaterialIcons name="logout" size={size} color={color} />
)}
/>
</DrawerContentScrollView>
);
}
@ -213,10 +227,11 @@ export default function Layout() {
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
</Drawer>
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="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>
);
}

View file

@ -1,25 +1,58 @@
import React, { useState } from "react";
import { View, Text, TouchableOpacity, Alert } from "react-native";
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, usePagination, ImageViewerURI } from "common-ui";
import React, { useState, useCallback, useMemo } from "react";
import { View, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
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";
export default function Complaints() {
const { currentPage, pageSize, PaginationComponent } = usePagination(5); // 5 complaints per page for testing
const { data, isLoading, error, refetch } = trpc.admin.complaint.getAll.useQuery({
page: currentPage,
limit: pageSize,
});
const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = trpc.admin.complaint.getAll.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
useMarkDataFetchers(() => {
refetch();
});
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
const complaints = data?.complaints || [];
const totalCount = data?.totalCount || 0;
const complaints = useMemo(() => {
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) => {
setSelectedComplaintId(id);
@ -50,109 +83,158 @@ export default function Complaints() {
);
};
if (isLoading) {
return (
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-gray-600`}>Loading complaints...</MyText>
</View>
);
}
if (isLoading) {
return (
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
<ActivityIndicator size="large" color="#3B82F6" />
<MyText style={tw`text-gray-500 mt-4`}>Loading complaints...</MyText>
</View>
);
}
if (error) {
return (
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-red-600`}>Error loading complaints</MyText>
</View>
);
}
if (isError) {
return (
<View style={tw`flex-1 justify-center items-center bg-gray-50 p-6`}>
<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>
);
}
return (
<View style={tw`flex-1`}>
<MyFlatList
style={tw`flex-1 bg-white`}
contentContainerStyle={tw`px-4 pb-6`}
data={complaints}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg`}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Complaint #{item.id}</MyText>
<MyText style={tw`text-base mb-2 text-gray-700`}>{item.text}</MyText>
return (
<View style={tw`flex-1 bg-gray-50`}>
<MyFlatList
style={tw`flex-1`}
contentContainerStyle={tw`px-4 py-4`}
data={complaints}
keyExtractor={(item) => item.id.toString()}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
renderItem={({ item }) => (
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-sm border border-gray-100`}>
<View style={tw`flex-row justify-between items-start mb-2`}>
<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>
{item.images && item.images.length > 0 && (
<View style={tw`mt-3 mb-3`}>
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>Attached Images:</MyText>
<View style={tw`flex-row flex-wrap gap-2`}>
{item.images.map((imageUri: string, index: number) => (
<ImageViewerURI
key={index}
uri={imageUri}
style={tw`w-16 h-16 rounded-lg border border-gray-200`}
/>
))}
</View>
<MyText style={tw`text-base text-gray-700 mb-3 leading-5`}>
{item.text}
</MyText>
{item.images && item.images.length > 0 && (
<View style={tw`mb-3`}>
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>
Attached Images:
</MyText>
<View style={tw`flex-row flex-wrap gap-2`}>
{item.images.map((imageUri: string, index: number) => (
<ImageViewerURI
key={index}
uri={imageUri}
style={tw`w-16 h-16 rounded-lg border border-gray-200`}
/>
))}
</View>
)}
</View>
)}
<View style={tw`flex-row items-center mb-2`}>
<TouchableOpacity
onPress={() =>
Alert.alert("User Page", "User page coming soon")
}
>
<MyText style={tw`text-sm text-blue-600 underline`}>
{item.userName}
</MyText>
</TouchableOpacity>
<MyText style={tw`text-sm text-gray-600 mx-2`}>|</MyText>
{item.orderId && (
<TouchableOpacity
onPress={() =>
Alert.alert("Order Page", "Order page coming soon")
}
>
<MyText style={tw`text-sm text-blue-600 underline`}>
Order #{item.orderId}
</MyText>
</TouchableOpacity>
)}
</View>
<MyText
style={tw`text-sm ${
item.status === "resolved" ? "text-green-600" : "text-red-600"
}`}
>
Status: {item.status}
</MyText>
{item.status === "pending" && (
<TouchableOpacity
onPress={() => handleMarkResolved(item.id)}
style={tw`mt-2 bg-blue-500 p-3 rounded-lg shadow-md`}
>
<MyText style={tw`text-white text-center font-semibold`}>Mark as Resolved</MyText>
</TouchableOpacity>
)}
<View style={tw`flex-row items-center gap-2 mb-3`}>
<MaterialIcons name="person" size={14} color="#6B7280" />
<TouchableOpacity
onPress={() => item.userId && handleUserPress(item.userId)}
>
<MyText style={tw`text-sm text-blue-600 underline`}>
{item.userName || item.userMobile || "Unknown User"}
</MyText>
</TouchableOpacity>
{item.orderId && (
<>
<MyText style={tw`text-sm text-gray-400`}>|</MyText>
<MaterialIcons name="shopping-bag" size={14} color="#6B7280" />
<TouchableOpacity
onPress={() => item.orderId && handleOrderPress(item.orderId)}
>
<MyText style={tw`text-sm text-blue-600 underline`}>
Order #{item.orderId}
</MyText>
</TouchableOpacity>
</>
)}
</View>
{item.status === "pending" && (
<TouchableOpacity
onPress={() => handleMarkResolved(item.id)}
style={tw`bg-blue-500 py-3 rounded-xl items-center shadow-sm mt-2`}
>
<MyText style={tw`text-white font-semibold`}>
Resolve Complaint
</MyText>
</TouchableOpacity>
)}
</View>
)}
ListEmptyComponent={
<View style={tw`flex-1 justify-center items-center py-10`}>
<MyText style={tw`text-gray-500 text-center`}>No complaints found</MyText>
</View>
}
/>
<PaginationComponent totalCount={totalCount} />
<ConfirmationDialog
open={dialogOpen}
positiveAction={handleConfirmResolve}
commentNeeded={true}
negativeAction={() => {
setDialogOpen(false);
setSelectedComplaintId(null);
}}
title="Mark as Resolved"
message="Add admin notes for this resolution:"
confirmText="Resolve"
cancelText="Cancel"
/>
</View>
ListEmptyComponent={
<View style={tw`flex-1 justify-center items-center py-20`}>
<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>
}
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
}
/>
<ConfirmationDialog
open={dialogOpen}
positiveAction={handleConfirmResolve}
commentNeeded={true}
negativeAction={() => {
setDialogOpen(false);
setSelectedComplaintId(null);
}}
title="Resolve Complaint"
message="Add admin notes for this resolution:"
confirmText="Resolve"
cancelText="Cancel"
isLoading={resolveComplaint.isPending}
/>
</View>
);
}

View file

@ -17,6 +17,13 @@ export default function Layout() {
headerShown: false,
}}
/>
<Stack.Screen
name="all-items-order"
options={{
title: "All Items Order",
headerShown: false,
}}
/>
</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',
appStoreUrl: 'App Store URL',
popularItems: 'Popular Items',
allItemsOrder: 'All Items Order',
isFlashDeliveryEnabled: 'Enable Flash Delivery',
supportMobile: 'Support Mobile',
supportEmail: 'Support Email',
@ -48,6 +49,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
// Special handling for popularItems - show navigation button instead of input
if (constant.key === 'popularItems') {
console.log('key is allItemsOrder')
return (
<View>
<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
if (typeof constant.value === 'boolean') {
return (
@ -134,6 +158,7 @@ export default function CustomizeApp() {
const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery();
const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation();
const handleSubmit = (values: ConstantFormData) => {
// Filter out constants that haven't changed
const changedConstants = values.constants.filter((constant, index) => {

View file

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

View file

@ -13,11 +13,12 @@ interface MenuItem {
icon: string;
description?: string;
route: string;
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings' | 'users';
iconColor?: string;
iconBg?: string;
badgeCount?: number;
onPress?: () => void;
testID?: string;
}
interface MenuItemComponentProps {
@ -100,6 +101,7 @@ export default function Dashboard() {
category: 'quick',
iconColor: '#06B6D4',
iconBg: '#CFFAFE',
testID: 'delivery-slots-menu-item',
},
{
title: 'Add Product',
@ -183,16 +185,34 @@ export default function Dashboard() {
iconColor: '#EAB308',
iconBg: '#FEF9C3',
},
{
title: 'App Constants',
icon: 'settings-applications',
description: 'Customize app settings',
route: '/(drawer)/customize-app',
category: 'settings',
iconColor: '#7C3AED',
iconBg: '#F3E8FF',
},
];
{
title: 'App Constants',
icon: 'settings-applications',
description: 'Customize app settings',
route: '/(drawer)/customize-app',
category: 'settings',
iconColor: '#7C3AED',
iconBg: '#F3E8FF',
},
{
title: 'User Management',
icon: 'people',
description: 'View and manage all users',
route: '/(drawer)/user-management',
category: 'users',
iconColor: '#0EA5E9',
iconBg: '#E0F2FE',
},
{
title: 'Send Notifications',
icon: 'campaign',
description: 'Send push notifications to users',
route: '/(drawer)/send-notifications',
category: 'users',
iconColor: '#8B5CF6',
iconBg: '#F3E8FF',
},
];
const quickActions = menuItems.filter(item => item.category === 'quick');
@ -200,6 +220,7 @@ export default function Dashboard() {
{ key: 'orders', title: 'Orders', icon: 'receipt-long' },
{ key: 'products', title: 'Products & Inventory', icon: 'inventory' },
{ key: 'marketing', title: 'Marketing & Promotions', icon: 'campaign' },
{ key: 'users', title: 'User Management', icon: 'people' },
{ key: 'settings', title: 'Settings & Configuration', icon: 'settings' },
];
@ -226,6 +247,8 @@ export default function Dashboard() {
{quickActions.map((item) => (
<Pressable
key={`quick-${item.route}`}
testID={item.testID}
accessibilityLabel={item.testID}
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
style={({ pressed }) => [
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="delivery-sequences" options={{ title: 'Delivery Sequences' }} />
<Stack.Screen name="orders" options={{ title: 'Orders' }} />
<Stack.Screen name="order-details" options={{ title: 'Order Details' }} />
</Stack>
);
}

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
import dayjs from "dayjs";
import CancelOrderDialog from "@/components/CancelOrderDialog";
import { UserIncidentsView } from "@/components/UserIncidentsView";
export default function OrderDetails() {
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) {
return (
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
@ -267,6 +278,23 @@ export default function OrderDetails() {
</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.status !== "cancelled" && (
<View
@ -348,12 +376,17 @@ export default function OrderDetails() {
)}
{/* 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`}
>
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
Customer Details
</MyText>
<View style={tw`flex-row items-center justify-between mb-4`}>
<MyText style={tw`text-base font-bold text-gray-900`}>
Customer Details
</MyText>
<MaterialIcons name="chevron-right" size={20} color="#6B7280" />
</View>
<View style={tw`flex-row items-center mb-4`}>
<View
@ -363,7 +396,7 @@ export default function OrderDetails() {
</View>
<View>
<MyText style={tw`text-sm font-bold text-gray-900`}>
{order.customerName}
{order.customerName || 'Unknown User'}
</MyText>
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
</View>
@ -404,7 +437,7 @@ export default function OrderDetails() {
</View>
</View>
</View>
</View>
</TouchableOpacity>
{/* Order Items */}
<View
@ -486,6 +519,40 @@ export default function OrderDetails() {
-{discountAmount}
</MyText>
</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 items-center`}>
@ -544,6 +611,14 @@ export default function OrderDetails() {
</View>
)}
{/* User Incidents Section */}
{order.userId && (
<UserIncidentsView
userId={order.userId}
orderId={order.id}
/>
)}
{/* Coupon Applied Section */}
{order.couponCode && (
<View

View file

@ -56,7 +56,11 @@ interface OrderType {
orderId: string;
readableId: number;
customerName: string | null;
customerMobile?: string | null;
address: string;
addressId: number;
latitude: number | null;
longitude: number | null;
totalAmount: number;
deliveryCharge: number;
items: {
@ -82,6 +86,7 @@ interface OrderType {
discountAmount?: number;
adminNotes?: string | null;
userNotes?: string | null;
userNegativityScore?: number;
}
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 handleOrderPress = () => {
router.push(`/order-details/${order.orderId}` as any);
router.push(`/manage-orders/order-details/${order.orderId}` as any);
};
const handleMenuOption = () => {
setMenuOpen(false);
router.push(`/order-details/${order.orderId}` as any);
router.push(`/manage-orders/order-details/${order.orderId}` as any);
};
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-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'}`}>
{order.customerName || 'Unknown Customer'}
<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 || order.customerMobile || 'Unknown Customer'}
</MyText>
<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>
@ -186,6 +191,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
<MyText style={tw`text-xs text-gray-500 ml-1`}>
{dayjs(order.createdAt).format('MMM D, h:mm A')}
</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>
@ -359,11 +370,11 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
isDelivered: order.isDelivered,
isFlashDelivery: order.isFlashDelivery,
address: order.address,
addressId: 0,
addressId: order.addressId,
adminNotes: order.adminNotes,
userNotes: order.userNotes,
latitude: null,
longitude: null,
latitude: order.latitude,
longitude: order.longitude,
status: order.status,
}}
onViewDetails={handleMenuOption}
@ -377,7 +388,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
setMenuOpen(false);
setCancelDialogOpen(true);
}}
onAttachLocation={() => {}}
onAttachLocation={() => refetch()}
onWhatsApp={() => {}}
onDial={() => {}}
/>

View file

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

View file

@ -186,7 +186,7 @@ export default function RebalanceOrders() {
setDialogOpen={setDialogOpen}
/>
)}
contentContainerStyle={tw`pb-24 flex-1`} // Space for floating button
contentContainerStyle={tw`pb-24`} // Space for floating button
showsVerticalScrollIndicator={false}
onRefresh={handleRefresh}
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 { MaterialCommunityIcons, Entypo } from '@expo/vector-icons';
import { View, TouchableOpacity, FlatList, Alert } from 'react-native';
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity } from 'common-ui';
import { View, TouchableOpacity, FlatList, Alert, ActivityIndicator } from 'react-native';
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity, Checkbox } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import { useRouter } from 'expo-router';
import dayjs from 'dayjs';
@ -12,6 +12,7 @@ interface SlotItemProps {
router: any;
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>;
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetch: () => void;
}
const SlotItemComponent: React.FC<SlotItemProps> = ({
@ -19,6 +20,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
router,
setDialogProducts,
setDialogOpen,
refetch,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
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 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 (
<TouchableOpacity
onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${slot.id}`)}
@ -55,10 +80,15 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
<MyText style={tw`text-xs font-bold text-pink1`}>Edit</MyText>
</View>
</TouchableOpacity>
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
</View>
<TouchableOpacity
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
</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
onPress={() => setMenuOpen(true)}
style={tw`ml-2 p-1`}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
@ -68,33 +98,75 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
</View>
</View>
{/* Replicate Menu Dialog */}
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
<View style={tw`p-4`}>
<MyText style={tw`text-lg font-bold mb-4`}>Slot #{slot.id} Actions</MyText>
{/* Replicate Menu Dialog */}
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
<View style={tw`p-4`}>
<MyText style={tw`text-lg font-bold mb-4`}>Slot #{slot.id} Actions</MyText>
{/* Capacity Toggle */}
<TouchableOpacity
onPress={() => {
setMenuOpen(false);
router.push(`/slots/add?baseslot=${slot.id}` as any);
}}
onPress={handleCapacityToggle}
disabled={updateSlotCapacity.isPending}
style={tw`py-4 border-b border-gray-200`}
>
<View style={tw`flex-row items-center`}>
<MaterialCommunityIcons name="content-copy" size={20} color="#4B5563" style={tw`mr-3`} />
<MyText style={tw`text-base text-gray-800`}>Replicate Slot</MyText>
<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
onPress={() => setMenuOpen(false)}
style={tw`py-4 mt-2`}
>
<View style={tw`flex-row items-center`}>
<MaterialCommunityIcons name="close" size={20} color="#EF4444" style={tw`mr-3`} />
<MyText style={tw`text-base text-red-500`}>Cancel</MyText>
</View>
</TouchableOpacity>
</View>
</BottomDialog>
<TouchableOpacity
onPress={() => {
setMenuOpen(false);
router.push(`/slots/add?baseslot=${slot.id}` as any);
}}
style={tw`py-4 border-b border-gray-200`}
>
<View style={tw`flex-row items-center`}>
<MaterialCommunityIcons name="content-copy" size={20} color="#4B5563" style={tw`mr-3`} />
<MyText style={tw`text-base text-gray-800`}>Replicate Slot</MyText>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setMenuOpen(false)}
style={tw`py-4 mt-2`}
>
<View style={tw`flex-row items-center`}>
<MaterialCommunityIcons name="close" size={20} color="#EF4444" style={tw`mr-3`} />
<MyText style={tw`text-base text-red-500`}>Cancel</MyText>
</View>
</TouchableOpacity>
</View>
</BottomDialog>
{/* Divider */}
<View style={tw`h-[1px] bg-gray-100 mb-4`} />
@ -193,6 +265,7 @@ export default function Slots() {
router={router}
setDialogProducts={setDialogProducts}
setDialogOpen={setDialogOpen}
refetch={refetch}
/>
)}
contentContainerStyle={tw`p-4`}
@ -202,6 +275,8 @@ export default function Slots() {
{/* FAB for Add New Slot */}
<MyTouchableOpacity
testID="add-slot-fab"
accessibilityLabel="add-slot-fab"
onPress={() => router.push('/slots/add' as any)}
activeOpacity={0.95}
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 handleUserPress = (userId: string) => {
router.push(`/user-details/${userId}`);
router.push(`/(drawer)/user-management/${userId}`);
};
return (

View file

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

View file

@ -26,7 +26,6 @@ export default function LoginScreen() {
}
};
console.log('from the login page')
return (
@ -52,6 +51,8 @@ export default function LoginScreen() {
autoCapitalize="none"
autoCorrect={false}
style={{ marginBottom: 20 }}
testID="login-name-input"
accessibilityLabel="login-name-input"
/>
<MyTextInput
@ -63,6 +64,8 @@ export default function LoginScreen() {
autoCapitalize="none"
autoCorrect={false}
style={{ marginBottom: 30 }}
testID="login-password-input"
accessibilityLabel="login-password-input"
/>
{loginError && (
@ -84,8 +87,10 @@ export default function LoginScreen() {
disabled={isLoggingIn}
fullWidth
style={{ marginBottom: 20 }}
testID="login-button"
accessibilityLabel="login-button"
/>
</View>
</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 (
<BottomDialog open={open} onClose={onClose}>
<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`} />
</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
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
onPress={() => {

View file

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

View file

@ -73,6 +73,11 @@ export default function SlotForm({
return;
}
if (values.freezeTime > values.deliveryTime) {
Alert.alert('Error', 'Freeze time must be before or equal to delivery time');
return;
}
const slotData = {
deliveryTime: values.deliveryTime.toISOString(),
freezeTime: values.freezeTime.toISOString(),
@ -143,12 +148,22 @@ export default function SlotForm({
<View style={tw`mb-4`}>
<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 style={tw`mb-4`}>
<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 style={tw`mb-4`}>
@ -215,6 +230,8 @@ export default function SlotForm({
</FieldArray>
<TouchableOpacity
testID="create-slot-button"
accessibilityLabel="create-slot-button"
onPress={() => handleSubmit()}
disabled={isPending}
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) => {
try {
console.log({values})
const submitData = {
snippetCode: values.snippetCode,

View file

@ -24,6 +24,7 @@
"@trpc/react-query": "^11.6.0",
"axios": "^1.11.0",
"buffer": "^6.0.3",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"expo": "~53.0.22",
"expo-blur": "~14.1.5",

View file

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

View file

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

View file

@ -1,7 +1,6 @@
ENV_MODE=PROD
DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
PHONE_PE_CLIENT_VERSION=1
@ -21,6 +20,7 @@ S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
S3_BUCKET_NAME=meatfarmer
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
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@57.128.212.174:6379
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,
"tag": "0071_moaning_shadow_king",
"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 path from "path";
import fs from "fs";
import { db } from './src/db/db_index';
import { staffUsers, userDetails } from './src/db/schema';
import { db } from '@/src/db/db_index';
import { staffUsers, userDetails } from '@/src/db/schema';
import { eq } from 'drizzle-orm';
import mainRouter from './src/main-router';
import initFunc from './src/lib/init';
import mainRouter from '@/src/main-router';
import initFunc from '@/src/lib/init';
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 jwt from 'jsonwebtoken'
import signedUrlCache from 'src/lib/signed-url-cache';
import { seed } from 'src/db/seed';
import './src/jobs/jobs-index';
import { startAutomatedJobs } from './src/lib/automatedJobs';
import signedUrlCache from '@/src/lib/signed-url-cache';
import { seed } from '@/src/db/seed';
import '@/src/jobs/jobs-index';
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
// seed()
seed()
initFunc()
startAutomatedJobs()
@ -163,6 +163,15 @@ if (fs.existsSync(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
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
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 });
});
app.listen(4000, () => {
app.listen(4000, '::', () => {
console.log("Server is running on http://localhost:4000/api/mobile/");
});

View file

@ -5,7 +5,7 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"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",
"db:push": "drizzle-kit push:pg",
"db:seed": "tsx src/db/seed.ts",
@ -42,7 +42,6 @@
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"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",
"redis": "^5.9.0",
"zod": "^4.1.12"
@ -55,6 +54,7 @@
"rimraf": "^6.1.2",
"ts-node-dev": "^2.0.0",
"tsx": "^4.20.5",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.2"
}
}

View file

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

View file

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

View file

@ -1,12 +1,12 @@
import { Request, Response } from "express";
import { db } from "../db/db_index";
import { productInfo, units, specialDeals, productTags } from "../db/schema";
import { db } from "@/src/db/db_index";
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm";
import { ApiError } from "../lib/api-error";
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "../lib/s3-client";
import { deleteS3Image } from "../lib/delete-image";
import type { SpecialDeal } from "../db/types";
import { initializeAllStores } from '../stores/store-initializer';
import { ApiError } from "@/src/lib/api-error";
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
import { deleteS3Image } from "@/src/lib/delete-image";
import type { SpecialDeal } from "@/src/db/types";
import { initializeAllStores } from '@/src/stores/store-initializer';
type CreateDeal = {
quantity: number;
@ -124,7 +124,6 @@ export const updateProduct = async (req: Request, res: Response) => {
const { id } = req.params;
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 imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];

View file

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

View file

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

View file

@ -1,8 +1,8 @@
import { eq, gt, and, sql, inArray } from "drizzle-orm";
import { Request, Response } from "express";
import { db } from "../db/db_index";
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "../db/schema";
import { generateSignedUrlsFromS3Urls } from "../lib/s3-client";
import { db } from "@/src/db/db_index"
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
/**
* Get next delivery date for a product
@ -89,7 +89,7 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
productQuantity: product.productQuantity,
isOutOfStock: product.isOutOfStock,
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 { getAllProductsSummary } from "./common-product.controller";
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
const router = Router();

View file

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

View file

@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
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('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
*/
import { db } from './db_index';
import { db } from '@/src/db/db_index'
import {
userDetails,
productInfo,
productTagInfo,
complaints
} from './schema';
} from '@/src/db/schema';
import { eq, not, isNull } from 'drizzle-orm';
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';
@ -122,4 +122,4 @@ runMigration()
.catch((error) => {
console.error('Process failed:', error);
process.exit(1);
});
});

View file

@ -192,6 +192,7 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', {
freezeTime: timestamp('freeze_time').notNull(),
isActive: boolean('is_active').notNull().default(true),
isFlash: boolean('is_flash').notNull().default(false),
isCapacityFull: boolean('is_capacity_full').notNull().default(false),
deliverySequence: jsonb('delivery_sequence').$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),
}));
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', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
@ -419,6 +430,22 @@ export const notifCreds = mf.table('notif_creds', {
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', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
roleName: staffRoleEnum('role_name').notNull(),
@ -456,6 +483,7 @@ export const usersRelations = relations(users, ({ many, one }) => ({
applicableCoupons: many(couponApplicableUsers),
userDetails: one(userDetails),
notifCreds: many(notifCreds),
userIncidents: many(userIncidents),
}));
export const userCredsRelations = relations(userCreds, ({ one }) => ({
@ -525,6 +553,7 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
orderStatus: many(orderStatus),
refunds: many(refunds),
couponUsages: many(couponUsage),
userIncidents: many(userIncidents),
}));
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] }),
}));
export const userNotificationsRelations = relations(userNotifications, ({}) => ({
// No relations needed for now
}));
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
products: many(productInfo),
@ -648,3 +681,9 @@ export const staffRolePermissionsRelations = relations(staffRolePermissions, ({
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.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 { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "./schema";
import { db } from "@/src/db/db_index"
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "@/src/db/schema"
import { eq } from "drizzle-orm";
import { minOrderValue, deliveryCharge } from '../lib/env-exporter';
import { CONST_KEYS } from '../lib/const-keys';
import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter'
import { CONST_KEYS } from '@/src/lib/const-keys'
export async function seed() {
console.log("Seeding database...");
@ -113,9 +113,10 @@ export async function seed() {
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 },
{ key: CONST_KEYS.popularItems, value: [] },
{ key: CONST_KEYS.allItemsOrder, value: [] },
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
{ key: CONST_KEYS.appStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
{ key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' },
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
@ -134,4 +135,4 @@ export async function seed() {
}
console.log("Seeding completed.");
}
}

View file

@ -14,7 +14,7 @@ import type {
productCategories,
cartItems,
coupons,
} from "./schema";
} from "@/src/db/schema";
export type User = InferSelectModel<typeof users>;
export type Address = InferSelectModel<typeof addresses>;
@ -44,4 +44,4 @@ export type OrderWithItems = Order & {
export type CartItemWithProduct = CartItem & {
product: ProductInfo;
};
};

View file

@ -1,5 +1,5 @@
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 start = Date.now();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index';
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema';
import { db } from '@/src/db/db_index'
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
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 assetsDomain = process.env.ASSETS_DOMAIN as string;
export const s3Url = process.env.S3_URL as string
export const redisUrl = process.env.REDIS_URL as string
@ -46,4 +48,4 @@ export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string;
export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || [];
export const isDevMode = (process.env.ENV_MODE as string) === 'dev';
export const isDevMode = (process.env.ENV_MODE as string) === 'dev';

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> {
try {

View file

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

View file

@ -1,7 +1,8 @@
import './notif-job';
import { initializeAllStores } from '../stores/store-initializer';
import { startOrderHandler, startCancellationHandler, publishOrder } from './post-order-handler';
import { deleteOrders } from './delete-orders';
import '@/src/lib/notif-job'
import { initializeAllStores } from '@/src/stores/store-initializer'
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
import { deleteOrders } from '@/src/lib/delete-orders'
/**
* Initialize all application services
@ -10,6 +11,7 @@ import { deleteOrders } from './delete-orders';
* - Const Store (syncs constants from DB to Redis)
* - Post Order Handler (Redis Pub/Sub subscriber)
* - 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
*/
export const initFunc = async (): Promise<void> => {
@ -18,6 +20,7 @@ export const initFunc = async (): Promise<void> => {
await Promise.all([
initializeAllStores(),
initializeUserNegativityStore(),
startOrderHandler(),
startCancellationHandler(),
]);

View file

@ -1,5 +1,8 @@
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 {
NOTIFS_QUEUE,
ORDER_PLACED_MESSAGE,
@ -9,26 +12,78 @@ import {
ORDER_DELIVERED_MESSAGE,
ORDER_CANCELLED_MESSAGE,
REFUND_INITIATED_MESSAGE
} from './const-strings';
} from '@/src/lib/const-strings';
export const notificationQueue = new Queue(NOTIFS_QUEUE, {
connection: { url: redisUrl },
defaultJobOptions: {
removeOnComplete: 50,
removeOnFail: 100,
removeOnComplete: true,
removeOnFail: 10,
attempts: 3,
},
});
export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
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 },
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) => {
if (job) console.log(`Notification job ${job.id} completed`);
});
@ -108,4 +163,4 @@ export async function sendRefundInitiatedNotification(userId: number, orderId?:
process.on('SIGTERM', async () => {
await notificationQueue.close();
await notificationWorker.close();
});
});

View file

@ -1,6 +1,6 @@
import { db } from "../db/db_index";
import { sendPushNotificationsMany } from "./expo-service";
// import { usersTable, notifCredsTable, notificationTable } from "../db/schema";
import { db } from "@/src/db/db_index"
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm";
// Core notification dispatch methods (renamed for clarity)
@ -244,4 +244,4 @@ export const sendNotifToSingleUser = dispatchUserNotification;
/**
* @deprecated Use notifyNewOffer() or other purpose-specific methods instead
*/
export const sendNotifToManyUsers = dispatchBulkNotification;
export const sendNotifToManyUsers = dispatchBulkNotification;

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { db } from '../db/db_index';
import { orders, orderStatus } from '../db/schema';
import redisClient from './redis-client';
import { sendTelegramMessage } from './telegram-service';
import { db } from '@/src/db/db_index'
import { orders, orderStatus } from '@/src/db/schema'
import redisClient from '@/src/lib/redis-client'
import { sendTelegramMessage } from '@/src/lib/telegram-service'
import { inArray, eq } from 'drizzle-orm';
const ORDER_CHANNEL = 'orders:placed';
@ -35,7 +35,10 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
message += '📦 <b>Items:</b>\n';
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`;
@ -72,7 +75,12 @@ const formatCancellationMessage = (orderData: any, cancellationData: Cancellatio
📞 <b>Phone:</b> ${orderData.address?.phone || 'N/A'}
📦 <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>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),
with: {
address: true,
orderItems: { with: { product: true } },
orderItems: { with: { product: { with: { unit: true } } } },
slot: true,
},
});
@ -147,7 +155,7 @@ export const startCancellationHandler = async (): Promise<void> => {
where: eq(orders.id, cancellationData.orderId),
with: {
address: true,
orderItems: { with: { product: true } },
orderItems: { with: { product: { with: { unit: true } } } },
refunds: true,
},
});

View file

@ -1,5 +1,5 @@
import { createClient, RedisClientType } from 'redis';
import { redisUrl } from './env-exporter';
import { redisUrl } from '@/src/lib/env-exporter'
class RedisClient {
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

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 { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import signedUrlCache from "./signed-url-cache"
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName } from "./env-exporter";
import { db } from "../db/db_index"; // Adjust path if needed
import { uploadUrlStatus } from "../db/schema";
import signedUrlCache from "@/src/lib/signed-url-cache"
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
import { db } from "@/src/db/db_index"; // Adjust path if needed
import { uploadUrlStatus } from "@/src/db/schema"
import { and, eq } from 'drizzle-orm';
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
@ -202,4 +218,4 @@ export async function claimUploadUrl(url: string): Promise<void> {
console.error('Error claiming upload URL:', error);
throw new Error('Failed to claim upload URL');
}
}
}

View file

@ -1,5 +1,5 @@
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 CHAT_IDS = telegramChatIds;

View file

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

View file

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

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