Compare commits
58 commits
before-not
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ed889a34f | ||
|
|
3ddc939a48 | ||
|
|
24252b717b | ||
|
|
78305e1670 | ||
|
|
1a3fe7826f | ||
|
|
79bf6782f5 | ||
| e5f80c9237 | |||
|
|
2f65e9ae80 | ||
|
|
e10e94bf72 | ||
| b881ebd19b | |||
|
|
e5e26d9d5b | ||
|
|
728ed3fa31 | ||
|
|
d08020ff80 | ||
|
|
1df3d8ff16 | ||
|
|
5e550104d3 | ||
|
|
1a4a2aadc5 | ||
|
|
a4fcdf77dc | ||
|
|
8fc603db0a | ||
|
|
bbf5d1657b | ||
|
|
72475f7f71 | ||
|
|
8d702ed2ff | ||
|
|
32feef5621 | ||
|
|
1a74efdd3c | ||
|
|
ffaade32d6 | ||
|
|
dc644aef7e | ||
|
|
ed7318f9ee | ||
|
|
7fa44712bf | ||
|
|
5bd0f8ded7 | ||
|
|
6bcf080593 | ||
|
|
6c2b7f9bfd | ||
|
|
1dca7a3454 | ||
|
|
04ea8c9284 | ||
|
|
a875e63751 | ||
|
|
b2a35176dd | ||
|
|
10d13408d3 | ||
|
|
d4afa75eaf | ||
|
|
40a98e38f5 | ||
|
|
a1aee3262b | ||
|
|
da47a0a014 | ||
|
|
83e733fdd1 | ||
|
|
e546c52c05 | ||
|
|
8fe3e4a301 | ||
|
|
002b73cf87 | ||
|
|
bce754d0a1 | ||
|
|
31395e5cc7 | ||
|
|
2a106b5467 | ||
|
|
ffa4a0ed44 | ||
|
|
37f5d48bbb | ||
|
|
3487501d72 | ||
|
|
637c90a771 | ||
|
|
dc11e77707 | ||
|
|
c7412d774a | ||
|
|
ee0b71fcd3 | ||
|
|
5b19a0486c | ||
|
|
d599c2e004 | ||
|
|
55c41fa0af | ||
|
|
d1d7db55a0 | ||
|
|
78e90fd398 |
224 changed files with 31370 additions and 2021 deletions
8
.expo/README.md
Normal file
8
.expo/README.md
Normal 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
3
.expo/devices.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
4
.expo/prebuild/cached-packages.json
Normal file
4
.expo/prebuild/cached-packages.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"dependencies": "c63a16a85154f1ea03750b1df53dcdee0200585f",
|
||||||
|
"devDependencies": "0a1ec1c6df1c9d5100926df058dd0824b1293819"
|
||||||
|
}
|
||||||
6
app.json
6
app.json
|
|
@ -1,3 +1,7 @@
|
||||||
{
|
{
|
||||||
"expo": {}
|
"expo": {
|
||||||
|
"ios": {
|
||||||
|
"bundleIdentifier": "com.mohammedshafiuddin54.meat-farmer-monorepo"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"dependencies": "091948e86692e0cce7744b6b0543448538c3125a",
|
"dependencies": "4650ceb7c30aaa4d5fd17b9577e186af7a84b50d",
|
||||||
"devDependencies": "b3b38265f32b99a8299270a292f38ca26288d53d"
|
"devDependencies": "b3b38265f32b99a8299270a292f38ca26288d53d"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -120,6 +120,20 @@ function CustomDrawerContent() {
|
||||||
icon={({ color, size }) => (
|
icon={({ color, size }) => (
|
||||||
<MaterialIcons name="store" size={size} color={color} />
|
<MaterialIcons name="store" size={size} color={color} />
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="User Management"
|
||||||
|
onPress={() => router.push("/(drawer)/user-management" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="people" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Send Notifications"
|
||||||
|
onPress={() => router.push("/(drawer)/send-notifications" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="campaign" size={size} color={color} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<DrawerItem
|
<DrawerItem
|
||||||
label="Logout"
|
label="Logout"
|
||||||
|
|
@ -215,8 +229,9 @@ export default function Layout() {
|
||||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||||
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||||
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
|
|
||||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||||
|
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||||
|
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,58 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
import { View, Text, TouchableOpacity, Alert } from "react-native";
|
import { View, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
|
||||||
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, usePagination, ImageViewerURI } from "common-ui";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, ImageViewerURI } from "common-ui";
|
||||||
import { trpc } from "@/src/trpc-client";
|
import { trpc } from "@/src/trpc-client";
|
||||||
|
|
||||||
export default function Complaints() {
|
export default function Complaints() {
|
||||||
const { currentPage, pageSize, PaginationComponent } = usePagination(5); // 5 complaints per page for testing
|
const router = useRouter();
|
||||||
const { data, isLoading, error, refetch } = trpc.admin.complaint.getAll.useQuery({
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
page: currentPage,
|
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
|
||||||
limit: pageSize,
|
|
||||||
});
|
const {
|
||||||
const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = trpc.admin.complaint.getAll.useInfiniteQuery(
|
||||||
|
{ limit: 20 },
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
useMarkDataFetchers(() => {
|
useMarkDataFetchers(() => {
|
||||||
refetch();
|
refetch();
|
||||||
});
|
});
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
|
||||||
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const complaints = data?.complaints || [];
|
const complaints = useMemo(() => {
|
||||||
const totalCount = data?.totalCount || 0;
|
const allComplaints = data?.pages.flatMap((page) => page.complaints) || [];
|
||||||
|
return allComplaints.filter(
|
||||||
|
(complaint, index, self) =>
|
||||||
|
index === self.findIndex((c) => c.id === complaint.id)
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const handleUserPress = useCallback((userId: number) => {
|
||||||
|
router.push(`/(drawer)/user-management/${userId}`);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleOrderPress = useCallback((orderId: number) => {
|
||||||
|
router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const handleMarkResolved = (id: number) => {
|
const handleMarkResolved = (id: number) => {
|
||||||
setSelectedComplaintId(id);
|
setSelectedComplaintId(id);
|
||||||
|
|
@ -52,35 +85,72 @@ export default function Complaints() {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
||||||
<MyText style={tw`text-gray-600`}>Loading complaints...</MyText>
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4`}>Loading complaints...</MyText>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
<View style={tw`flex-1 justify-center items-center bg-gray-50 p-6`}>
|
||||||
<MyText style={tw`text-red-600`}>Error loading complaints</MyText>
|
<MaterialIcons name="error-outline" size={48} color="#EF4444" />
|
||||||
|
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mt-2 mb-6`}>
|
||||||
|
{error?.message || "Failed to load complaints"}
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => refetch()}
|
||||||
|
style={tw`bg-blue-600 px-6 py-3 rounded-full`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
<MyFlatList
|
<MyFlatList
|
||||||
style={tw`flex-1 bg-white`}
|
style={tw`flex-1`}
|
||||||
contentContainerStyle={tw`px-4 pb-6`}
|
contentContainerStyle={tw`px-4 py-4`}
|
||||||
data={complaints}
|
data={complaints}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg`}>
|
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-sm border border-gray-100`}>
|
||||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Complaint #{item.id}</MyText>
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
<MyText style={tw`text-base mb-2 text-gray-700`}>{item.text}</MyText>
|
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
||||||
|
Complaint #{item.id}
|
||||||
|
</MyText>
|
||||||
|
<View
|
||||||
|
style={tw`px-2.5 py-1 rounded-full ${
|
||||||
|
item.status === "resolved"
|
||||||
|
? "bg-green-100 border border-green-200"
|
||||||
|
: "bg-amber-100 border border-amber-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-xs font-semibold ${
|
||||||
|
item.status === "resolved" ? "text-green-700" : "text-amber-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.status === "resolved" ? "Resolved" : "Pending"}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<MyText style={tw`text-base text-gray-700 mb-3 leading-5`}>
|
||||||
|
{item.text}
|
||||||
|
</MyText>
|
||||||
|
|
||||||
{item.images && item.images.length > 0 && (
|
{item.images && item.images.length > 0 && (
|
||||||
<View style={tw`mt-3 mb-3`}>
|
<View style={tw`mb-3`}>
|
||||||
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>Attached Images:</MyText>
|
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>
|
||||||
|
Attached Images:
|
||||||
|
</MyText>
|
||||||
<View style={tw`flex-row flex-wrap gap-2`}>
|
<View style={tw`flex-row flex-wrap gap-2`}>
|
||||||
{item.images.map((imageUri: string, index: number) => (
|
{item.images.map((imageUri: string, index: number) => (
|
||||||
<ImageViewerURI
|
<ImageViewerURI
|
||||||
|
|
@ -93,53 +163,64 @@ export default function Complaints() {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mb-2`}>
|
<View style={tw`flex-row items-center gap-2 mb-3`}>
|
||||||
|
<MaterialIcons name="person" size={14} color="#6B7280" />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() => item.userId && handleUserPress(item.userId)}
|
||||||
Alert.alert("User Page", "User page coming soon")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-sm text-blue-600 underline`}>
|
<MyText style={tw`text-sm text-blue-600 underline`}>
|
||||||
{item.userName}
|
{item.userName || item.userMobile || "Unknown User"}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<MyText style={tw`text-sm text-gray-600 mx-2`}>|</MyText>
|
|
||||||
{item.orderId && (
|
{item.orderId && (
|
||||||
|
<>
|
||||||
|
<MyText style={tw`text-sm text-gray-400`}>|</MyText>
|
||||||
|
<MaterialIcons name="shopping-bag" size={14} color="#6B7280" />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() => item.orderId && handleOrderPress(item.orderId)}
|
||||||
Alert.alert("Order Page", "Order page coming soon")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-sm text-blue-600 underline`}>
|
<MyText style={tw`text-sm text-blue-600 underline`}>
|
||||||
Order #{item.orderId}
|
Order #{item.orderId}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<MyText
|
|
||||||
style={tw`text-sm ${
|
|
||||||
item.status === "resolved" ? "text-green-600" : "text-red-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Status: {item.status}
|
|
||||||
</MyText>
|
|
||||||
{item.status === "pending" && (
|
{item.status === "pending" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleMarkResolved(item.id)}
|
onPress={() => handleMarkResolved(item.id)}
|
||||||
style={tw`mt-2 bg-blue-500 p-3 rounded-lg shadow-md`}
|
style={tw`bg-blue-500 py-3 rounded-xl items-center shadow-sm mt-2`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-center font-semibold`}>Mark as Resolved</MyText>
|
<MyText style={tw`text-white font-semibold`}>
|
||||||
|
Resolve Complaint
|
||||||
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={tw`flex-1 justify-center items-center py-10`}>
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||||
<MyText style={tw`text-gray-500 text-center`}>No complaints found</MyText>
|
<View style={tw`bg-white p-6 rounded-full shadow-sm mb-4`}>
|
||||||
|
<MaterialIcons name="inbox" size={48} color="#D1D5DB" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-gray-900 text-lg font-bold`}>
|
||||||
|
No complaints
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mt-2`}>
|
||||||
|
All complaints will appear here
|
||||||
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View style={tw`py-4 items-center flex-row justify-center`}>
|
||||||
|
<ActivityIndicator size="small" color="#3B82F6" />
|
||||||
|
<MyText style={tw`text-gray-500 ml-2`}>Loading more...</MyText>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<PaginationComponent totalCount={totalCount} />
|
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
positiveAction={handleConfirmResolve}
|
positiveAction={handleConfirmResolve}
|
||||||
|
|
@ -148,10 +229,11 @@ export default function Complaints() {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setSelectedComplaintId(null);
|
setSelectedComplaintId(null);
|
||||||
}}
|
}}
|
||||||
title="Mark as Resolved"
|
title="Resolve Complaint"
|
||||||
message="Add admin notes for this resolution:"
|
message="Add admin notes for this resolution:"
|
||||||
confirmText="Resolve"
|
confirmText="Resolve"
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
|
isLoading={resolveComplaint.isPending}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ export default function Layout() {
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="all-items-order"
|
||||||
|
options={{
|
||||||
|
title: "All Items Order",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
391
apps/admin-ui/app/(drawer)/customize-app/all-items-order.tsx
Normal file
391
apps/admin-ui/app/(drawer)/customize-app/all-items-order.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -31,6 +31,7 @@ const CONST_LABELS: Record<string, string> = {
|
||||||
playStoreUrl: 'Play Store URL',
|
playStoreUrl: 'Play Store URL',
|
||||||
appStoreUrl: 'App Store URL',
|
appStoreUrl: 'App Store URL',
|
||||||
popularItems: 'Popular Items',
|
popularItems: 'Popular Items',
|
||||||
|
allItemsOrder: 'All Items Order',
|
||||||
isFlashDeliveryEnabled: 'Enable Flash Delivery',
|
isFlashDeliveryEnabled: 'Enable Flash Delivery',
|
||||||
supportMobile: 'Support Mobile',
|
supportMobile: 'Support Mobile',
|
||||||
supportEmail: 'Support Email',
|
supportEmail: 'Support Email',
|
||||||
|
|
@ -48,6 +49,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
|
|
||||||
// Special handling for popularItems - show navigation button instead of input
|
// Special handling for popularItems - show navigation button instead of input
|
||||||
if (constant.key === 'popularItems') {
|
if (constant.key === 'popularItems') {
|
||||||
|
console.log('key is allItemsOrder')
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||||
|
|
@ -67,6 +69,28 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for allItemsOrder - show navigation button instead of input
|
||||||
|
if (constant.key === 'allItemsOrder') {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||||
|
{CONST_LABELS[constant.key] || constant.key}
|
||||||
|
</MyText>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => router.push('/(drawer)/customize-app/all-items-order')}
|
||||||
|
style={tw`bg-green-50 border-2 border-dashed border-green-200 p-4 rounded-lg flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="reorder" size={20} color="#16a34a" style={tw`mr-2`} />
|
||||||
|
<MyText style={tw`text-green-700 font-medium`}>
|
||||||
|
Manage All Visible Items ({Array.isArray(constant.value) ? constant.value.length : 0} items)
|
||||||
|
</MyText>
|
||||||
|
<MaterialIcons name="chevron-right" size={20} color="#16a34a" style={tw`ml-2`} />
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle boolean values - show checkbox
|
// Handle boolean values - show checkbox
|
||||||
if (typeof constant.value === 'boolean') {
|
if (typeof constant.value === 'boolean') {
|
||||||
return (
|
return (
|
||||||
|
|
@ -134,6 +158,7 @@ export default function CustomizeApp() {
|
||||||
const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery();
|
const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery();
|
||||||
const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation();
|
const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation();
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = (values: ConstantFormData) => {
|
const handleSubmit = (values: ConstantFormData) => {
|
||||||
// Filter out constants that haven't changed
|
// Filter out constants that haven't changed
|
||||||
const changedConstants = values.constants.filter((constant, index) => {
|
const changedConstants = values.constants.filter((constant, index) => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TouchableOpacity,
|
|
||||||
Alert,
|
Alert,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import DraggableFlatList, {
|
import DraggableFlatList, {
|
||||||
RenderItemParams,
|
RenderItemParams,
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
tw,
|
tw,
|
||||||
BottomDialog,
|
BottomDialog,
|
||||||
BottomDropdown,
|
BottomDropdown,
|
||||||
|
MyTouchableOpacity,
|
||||||
} from "common-ui";
|
} from "common-ui";
|
||||||
import ProductsSelector from "../../../components/ProductsSelector";
|
import ProductsSelector from "../../../components/ProductsSelector";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
|
@ -27,8 +29,8 @@ interface PopularProduct {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
shortDescription: string | null;
|
shortDescription: string | null;
|
||||||
price: string;
|
price: number;
|
||||||
marketPrice: string | null;
|
marketPrice: number | null;
|
||||||
unit: string;
|
unit: string;
|
||||||
incrementStep: number;
|
incrementStep: number;
|
||||||
productQuantity: number;
|
productQuantity: number;
|
||||||
|
|
@ -119,7 +121,7 @@ export default function CustomizePopularItems() {
|
||||||
const [popularProducts, setPopularProducts] = useState<PopularProduct[]>([]);
|
const [popularProducts, setPopularProducts] = useState<PopularProduct[]>([]);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
const [selectedProductIds, setSelectedProductIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// Get current popular items from constants
|
// Get current popular items from constants
|
||||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
||||||
|
|
@ -182,14 +184,20 @@ export default function CustomizePopularItems() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProduct = () => {
|
const handleAddProduct = () => {
|
||||||
if (selectedProductId) {
|
if (selectedProductIds.length > 0) {
|
||||||
const product = allProducts?.products.find(p => p.id === selectedProductId);
|
const newProducts = selectedProductIds
|
||||||
if (product && !popularProducts.find(p => p.id === product.id)) {
|
.map(id => allProducts?.products.find(p => p.id === id))
|
||||||
setPopularProducts(prev => [...prev, product as PopularProduct]);
|
.filter((product): product is NonNullable<typeof product> =>
|
||||||
|
product !== undefined && !popularProducts.find(p => p.id === product.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newProducts.length > 0) {
|
||||||
|
setPopularProducts(prev => [...prev, ...newProducts as PopularProduct[]]);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
setSelectedProductId(null);
|
|
||||||
setShowAddDialog(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelectedProductIds([]);
|
||||||
|
setShowAddDialog(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -293,20 +301,19 @@ export default function CustomizePopularItems() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<View style={[tw`flex-1 bg-gray-50 relative`]}>
|
||||||
<View style={tw`flex-1 bg-gray-50`}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
||||||
<TouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
style={tw`p-2 -ml-4`}
|
style={tw`p-2 -ml-4`}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||||
</TouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
|
|
||||||
<MyText style={tw`text-xl font-bold text-gray-900`}>Popular Items</MyText>
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Popular Items</MyText>
|
||||||
|
|
||||||
<TouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={!hasChanges || updateConstants.isPending}
|
disabled={!hasChanges || updateConstants.isPending}
|
||||||
style={tw`px-4 py-2 rounded-lg ${
|
style={tw`px-4 py-2 rounded-lg ${
|
||||||
|
|
@ -322,7 +329,7 @@ export default function CustomizePopularItems() {
|
||||||
} font-semibold`}>
|
} font-semibold`}>
|
||||||
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
@ -356,35 +363,41 @@ export default function CustomizePopularItems() {
|
||||||
)}
|
)}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={true}
|
||||||
contentContainerStyle={tw`pb-8`}
|
scrollEnabled={true}
|
||||||
|
contentContainerStyle={{ paddingBottom: 80 }}
|
||||||
|
containerStyle={tw`flex-1`}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FAB for Add Product */}
|
{/* FAB for Add Product - Fixed position */}
|
||||||
<View style={tw`absolute bottom-4 right-4`}>
|
<View style={tw`absolute bottom-12 right-6 z-50`}>
|
||||||
<TouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => setShowAddDialog(true)}
|
onPress={() => setShowAddDialog(true)}
|
||||||
style={tw`bg-blue-600 p-4 rounded-full shadow-lg`}
|
style={tw`bg-blue-600 p-4 rounded-full shadow-lg elevation-5`}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="add" size={24} color="white" />
|
<MaterialIcons name="add" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Add Product Dialog */}
|
{/* Add Product Dialog */}
|
||||||
<BottomDialog
|
<BottomDialog
|
||||||
open={showAddDialog}
|
open={showAddDialog}
|
||||||
onClose={() => setShowAddDialog(false)}
|
onClose={() => {
|
||||||
|
setShowAddDialog(false);
|
||||||
|
setSelectedProductIds([]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View style={tw`pb-8 pt-2 px-4`}>
|
<View style={tw`pb-8 pt-2 px-4`}>
|
||||||
<View style={tw`items-center mb-6`}>
|
<View style={tw`items-center mb-6`}>
|
||||||
<View style={tw`w-12 h-1.5 bg-gray-200 rounded-full mb-4`} />
|
<View style={tw`w-12 h-1.5 bg-gray-200 rounded-full mb-4`} />
|
||||||
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
||||||
Add Popular Item
|
Add Popular Items
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyText style={tw`text-sm text-gray-500`}>
|
<MyText style={tw`text-sm text-gray-500`}>
|
||||||
Select a product to add to popular items
|
Select products to add to popular items
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -398,41 +411,43 @@ export default function CustomizePopularItems() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ProductsSelector
|
<ProductsSelector
|
||||||
value={selectedProductId || 0}
|
value={selectedProductIds}
|
||||||
onChange={(val) => setSelectedProductId(val as number)}
|
onChange={(val) => setSelectedProductIds(val as number[])}
|
||||||
multiple={false}
|
multiple={true}
|
||||||
label="Select Product"
|
label="Select Products"
|
||||||
placeholder="Choose a product..."
|
placeholder="Choose products..."
|
||||||
labelFormat={(product) => `${product.name} - ₹${product.price}`}
|
labelFormat={(product) => `${product.name} - ₹${product.price}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={tw`flex-row gap-3 mt-6`}>
|
<View style={tw`flex-row gap-3 mt-6`}>
|
||||||
<TouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => setShowAddDialog(false)}
|
onPress={() => setShowAddDialog(false)}
|
||||||
style={tw`flex-1 bg-gray-100 p-3 rounded-lg`}
|
style={tw`flex-1 bg-gray-100 p-3 rounded-lg`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-gray-700 text-center font-semibold`}>
|
<MyText style={tw`text-gray-700 text-center font-semibold`}>
|
||||||
Cancel
|
Cancel
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={handleAddProduct}
|
onPress={handleAddProduct}
|
||||||
disabled={!selectedProductId}
|
disabled={selectedProductIds.length === 0}
|
||||||
style={tw`flex-1 ${
|
style={tw`flex-1 ${
|
||||||
selectedProductId ? 'bg-blue-600' : 'bg-gray-300'
|
selectedProductIds.length > 0 ? 'bg-blue-600' : 'bg-gray-300'
|
||||||
} p-3 rounded-lg`}
|
} p-3 rounded-lg`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-center font-semibold`}>
|
<MyText style={tw`text-white text-center font-semibold`}>
|
||||||
Add Product
|
{selectedProductIds.length > 0
|
||||||
|
? `Add ${selectedProductIds.length} Product${selectedProductIds.length > 1 ? 's' : ''}`
|
||||||
|
: 'Add Products'}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</BottomDialog>
|
</BottomDialog>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</AppContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -13,11 +13,12 @@ interface MenuItem {
|
||||||
icon: string;
|
icon: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
route: string;
|
route: string;
|
||||||
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
|
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings' | 'users';
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
iconBg?: string;
|
iconBg?: string;
|
||||||
badgeCount?: number;
|
badgeCount?: number;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItemComponentProps {
|
interface MenuItemComponentProps {
|
||||||
|
|
@ -100,6 +101,7 @@ export default function Dashboard() {
|
||||||
category: 'quick',
|
category: 'quick',
|
||||||
iconColor: '#06B6D4',
|
iconColor: '#06B6D4',
|
||||||
iconBg: '#CFFAFE',
|
iconBg: '#CFFAFE',
|
||||||
|
testID: 'delivery-slots-menu-item',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Add Product',
|
title: 'Add Product',
|
||||||
|
|
@ -192,6 +194,24 @@ export default function Dashboard() {
|
||||||
iconColor: '#7C3AED',
|
iconColor: '#7C3AED',
|
||||||
iconBg: '#F3E8FF',
|
iconBg: '#F3E8FF',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'User Management',
|
||||||
|
icon: 'people',
|
||||||
|
description: 'View and manage all users',
|
||||||
|
route: '/(drawer)/user-management',
|
||||||
|
category: 'users',
|
||||||
|
iconColor: '#0EA5E9',
|
||||||
|
iconBg: '#E0F2FE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Send Notifications',
|
||||||
|
icon: 'campaign',
|
||||||
|
description: 'Send push notifications to users',
|
||||||
|
route: '/(drawer)/send-notifications',
|
||||||
|
category: 'users',
|
||||||
|
iconColor: '#8B5CF6',
|
||||||
|
iconBg: '#F3E8FF',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const quickActions = menuItems.filter(item => item.category === 'quick');
|
const quickActions = menuItems.filter(item => item.category === 'quick');
|
||||||
|
|
@ -200,6 +220,7 @@ export default function Dashboard() {
|
||||||
{ key: 'orders', title: 'Orders', icon: 'receipt-long' },
|
{ key: 'orders', title: 'Orders', icon: 'receipt-long' },
|
||||||
{ key: 'products', title: 'Products & Inventory', icon: 'inventory' },
|
{ key: 'products', title: 'Products & Inventory', icon: 'inventory' },
|
||||||
{ key: 'marketing', title: 'Marketing & Promotions', icon: 'campaign' },
|
{ key: 'marketing', title: 'Marketing & Promotions', icon: 'campaign' },
|
||||||
|
{ key: 'users', title: 'User Management', icon: 'people' },
|
||||||
{ key: 'settings', title: 'Settings & Configuration', icon: 'settings' },
|
{ key: 'settings', title: 'Settings & Configuration', icon: 'settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -226,6 +247,8 @@ export default function Dashboard() {
|
||||||
{quickActions.map((item) => (
|
{quickActions.map((item) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={`quick-${item.route}`}
|
key={`quick-${item.route}`}
|
||||||
|
testID={item.testID}
|
||||||
|
accessibilityLabel={item.testID}
|
||||||
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
|
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
tw`bg-white rounded-xl p-3 shadow-sm border border-gray-100 items-center`,
|
tw`bg-white rounded-xl p-3 shadow-sm border border-gray-100 items-center`,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export default function Layout() {
|
||||||
<Stack.Screen name="index" options={{ title: 'Manage Orders' }} />
|
<Stack.Screen name="index" options={{ title: 'Manage Orders' }} />
|
||||||
<Stack.Screen name="delivery-sequences" options={{ title: 'Delivery Sequences' }} />
|
<Stack.Screen name="delivery-sequences" options={{ title: 'Delivery Sequences' }} />
|
||||||
<Stack.Screen name="orders" options={{ title: 'Orders' }} />
|
<Stack.Screen name="orders" options={{ title: 'Orders' }} />
|
||||||
|
<Stack.Screen name="order-details" options={{ title: 'Order Details' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -726,7 +726,7 @@ export default function DeliverySequences() {
|
||||||
}}
|
}}
|
||||||
onViewDetails={() => {
|
onViewDetails={() => {
|
||||||
if (selectedOrder) {
|
if (selectedOrder) {
|
||||||
router.push(`/order-details/${selectedOrder.id}`);
|
router.push(`/manage-orders/order-details/${selectedOrder.id}`);
|
||||||
}
|
}
|
||||||
setShowOrderMenu(false);
|
setShowOrderMenu(false);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export default function ManageOrders() {
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const target = getNavigationTarget();
|
const target = getNavigationTarget();
|
||||||
if (target) {
|
if (target) {
|
||||||
router.replace(target as any);
|
router.push(target as any);
|
||||||
}
|
}
|
||||||
}, [router, getNavigationTarget])
|
}, [router, getNavigationTarget])
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
|
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import CancelOrderDialog from "@/components/CancelOrderDialog";
|
import CancelOrderDialog from "@/components/CancelOrderDialog";
|
||||||
|
import { UserIncidentsView } from "@/components/UserIncidentsView";
|
||||||
|
|
||||||
export default function OrderDetails() {
|
export default function OrderDetails() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
|
@ -82,6 +83,16 @@ export default function OrderDetails() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const removeDeliveryChargeMutation = trpc.admin.order.removeDeliveryCharge.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert("Success", "Delivery charge has been removed");
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert("Error", error.message || "Failed to remove delivery charge");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
||||||
|
|
@ -267,6 +278,23 @@ export default function OrderDetails() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Cancellation Reason */}
|
||||||
|
{order.status === "cancelled" && order.cancelReason && (
|
||||||
|
<View
|
||||||
|
style={tw`bg-red-50 p-5 rounded-2xl border border-red-100 mb-4`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<MaterialIcons name="cancel" size={18} color="#DC2626" />
|
||||||
|
<MyText style={tw`text-sm font-bold text-red-800 ml-2`}>
|
||||||
|
Cancellation Reason
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-red-900 leading-5`}>
|
||||||
|
{order.cancelReason}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Order Progress (Simplified Timeline) */}
|
{/* Order Progress (Simplified Timeline) */}
|
||||||
{order.status !== "cancelled" && (
|
{order.status !== "cancelled" && (
|
||||||
<View
|
<View
|
||||||
|
|
@ -348,12 +376,17 @@ export default function OrderDetails() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Customer Details */}
|
{/* Customer Details */}
|
||||||
<View
|
<TouchableOpacity
|
||||||
|
onPress={() => order.userId && router.push(`/(drawer)/user-management/${order.userId}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
|
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
|
<View style={tw`flex-row items-center justify-between mb-4`}>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900`}>
|
||||||
Customer Details
|
Customer Details
|
||||||
</MyText>
|
</MyText>
|
||||||
|
<MaterialIcons name="chevron-right" size={20} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
<View
|
<View
|
||||||
|
|
@ -363,7 +396,7 @@ export default function OrderDetails() {
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<MyText style={tw`text-sm font-bold text-gray-900`}>
|
<MyText style={tw`text-sm font-bold text-gray-900`}>
|
||||||
{order.customerName}
|
{order.customerName || 'Unknown User'}
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
|
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -404,7 +437,7 @@ export default function OrderDetails() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Order Items */}
|
{/* Order Items */}
|
||||||
<View
|
<View
|
||||||
|
|
@ -486,6 +519,40 @@ export default function OrderDetails() {
|
||||||
-₹{discountAmount}
|
-₹{discountAmount}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
{order.deliveryCharge > 0 && (
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-2`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MyText style={tw`text-gray-600 font-medium`}>
|
||||||
|
Delivery Charge
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Remove Delivery Cost',
|
||||||
|
'Are you sure you want to remove the delivery cost from this order?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => removeDeliveryChargeMutation.mutate({ orderId: order.id }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={removeDeliveryChargeMutation.isPending}
|
||||||
|
style={tw`ml-2 px-2 py-1 bg-red-100 rounded-md`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-xs font-bold text-red-600`}>
|
||||||
|
{removeDeliveryChargeMutation.isPending ? 'Removing...' : 'Remove'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-gray-600 font-medium`}>
|
||||||
|
₹{order.deliveryCharge}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={tw`flex-row justify-between items-center pt-2 border-t border-gray-200`}>
|
<View style={tw`flex-row justify-between items-center pt-2 border-t border-gray-200`}>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
|
|
@ -544,6 +611,14 @@ export default function OrderDetails() {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* User Incidents Section */}
|
||||||
|
{order.userId && (
|
||||||
|
<UserIncidentsView
|
||||||
|
userId={order.userId}
|
||||||
|
orderId={order.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Coupon Applied Section */}
|
{/* Coupon Applied Section */}
|
||||||
{order.couponCode && (
|
{order.couponCode && (
|
||||||
<View
|
<View
|
||||||
|
|
@ -56,7 +56,11 @@ interface OrderType {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
readableId: number;
|
readableId: number;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
customerMobile?: string | null;
|
||||||
address: string;
|
address: string;
|
||||||
|
addressId: number;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
deliveryCharge: number;
|
deliveryCharge: number;
|
||||||
items: {
|
items: {
|
||||||
|
|
@ -82,6 +86,7 @@ interface OrderType {
|
||||||
discountAmount?: number;
|
discountAmount?: number;
|
||||||
adminNotes?: string | null;
|
adminNotes?: string | null;
|
||||||
userNotes?: string | null;
|
userNotes?: string | null;
|
||||||
|
userNegativityScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
||||||
|
|
@ -100,12 +105,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
|
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
|
||||||
|
|
||||||
const handleOrderPress = () => {
|
const handleOrderPress = () => {
|
||||||
router.push(`/order-details/${order.orderId}` as any);
|
router.push(`/manage-orders/order-details/${order.orderId}` as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMenuOption = () => {
|
const handleMenuOption = () => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
router.push(`/order-details/${order.orderId}` as any);
|
router.push(`/manage-orders/order-details/${order.orderId}` as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkPackaged = (isPackaged: boolean) => {
|
const handleMarkPackaged = (isPackaged: boolean) => {
|
||||||
|
|
@ -168,8 +173,8 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
<View style={tw`flex-row justify-between items-start`}>
|
<View style={tw`flex-row justify-between items-start`}>
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1`}>
|
||||||
<View style={tw`flex-row items-center mb-1`}>
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
<MyText style={tw`font-bold text-lg mr-2 ${order.status === 'cancelled' ? 'text-red-600' : 'text-gray-900'}`}>
|
<MyText style={tw`font-bold text-lg mr-2 ${order.status === 'cancelled' ? 'text-red-600' : (order.userNegativityScore && order.userNegativityScore > 0 ? 'text-yellow-600' : 'text-gray-900')}`}>
|
||||||
{order.customerName || 'Unknown Customer'}
|
{order.customerName || order.customerMobile || 'Unknown Customer'}
|
||||||
</MyText>
|
</MyText>
|
||||||
<View style={tw`bg-gray-200 px-2 py-0.5 rounded mr-2`}>
|
<View style={tw`bg-gray-200 px-2 py-0.5 rounded mr-2`}>
|
||||||
<MyText style={tw`text-xs font-medium text-gray-600`}>#{order.readableId}</MyText>
|
<MyText style={tw`text-xs font-medium text-gray-600`}>#{order.readableId}</MyText>
|
||||||
|
|
@ -186,6 +191,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
||||||
{dayjs(order.createdAt).format('MMM D, h:mm A')}
|
{dayjs(order.createdAt).format('MMM D, h:mm A')}
|
||||||
</MyText>
|
</MyText>
|
||||||
|
{order.userNegativityScore && order.userNegativityScore > 0 && (
|
||||||
|
<View style={tw`flex-row items-center ml-2`}>
|
||||||
|
<MaterialIcons name="warning" size={14} color="#CA8A04" />
|
||||||
|
<MyText style={tw`text-xs text-yellow-600 font-semibold ml-1`}>Negative Customer</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -359,11 +370,11 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
isDelivered: order.isDelivered,
|
isDelivered: order.isDelivered,
|
||||||
isFlashDelivery: order.isFlashDelivery,
|
isFlashDelivery: order.isFlashDelivery,
|
||||||
address: order.address,
|
address: order.address,
|
||||||
addressId: 0,
|
addressId: order.addressId,
|
||||||
adminNotes: order.adminNotes,
|
adminNotes: order.adminNotes,
|
||||||
userNotes: order.userNotes,
|
userNotes: order.userNotes,
|
||||||
latitude: null,
|
latitude: order.latitude,
|
||||||
longitude: null,
|
longitude: order.longitude,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
}}
|
}}
|
||||||
onViewDetails={handleMenuOption}
|
onViewDetails={handleMenuOption}
|
||||||
|
|
@ -377,7 +388,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setCancelDialogOpen(true);
|
setCancelDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
onAttachLocation={() => {}}
|
onAttachLocation={() => refetch()}
|
||||||
onWhatsApp={() => {}}
|
onWhatsApp={() => {}}
|
||||||
onDial={() => {}}
|
onDial={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ export default function EditProduct() {
|
||||||
tagIds: values.tagIds,
|
tagIds: values.tagIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log({payload})
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.entries(payload).forEach(([key, value]) => {
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ export default function RebalanceOrders() {
|
||||||
setDialogOpen={setDialogOpen}
|
setDialogOpen={setDialogOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={tw`pb-24 flex-1`} // Space for floating button
|
contentContainerStyle={tw`pb-24`} // Space for floating button
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
|
|
|
||||||
274
apps/admin-ui/app/(drawer)/send-notifications/index.tsx
Normal file
274
apps/admin-ui/app/(drawer)/send-notifications/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MaterialCommunityIcons, Entypo } from '@expo/vector-icons';
|
import { MaterialCommunityIcons, Entypo } from '@expo/vector-icons';
|
||||||
import { View, TouchableOpacity, FlatList, Alert } from 'react-native';
|
import { View, TouchableOpacity, FlatList, Alert, ActivityIndicator } from 'react-native';
|
||||||
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity } from 'common-ui';
|
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity, Checkbox } from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
@ -12,6 +12,7 @@ interface SlotItemProps {
|
||||||
router: any;
|
router: any;
|
||||||
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>;
|
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>;
|
||||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
refetch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SlotItemComponent: React.FC<SlotItemProps> = ({
|
const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||||
|
|
@ -19,6 +20,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||||
router,
|
router,
|
||||||
setDialogProducts,
|
setDialogProducts,
|
||||||
setDialogOpen,
|
setDialogOpen,
|
||||||
|
refetch,
|
||||||
}) => {
|
}) => {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || [];
|
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || [];
|
||||||
|
|
@ -28,6 +30,29 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||||
const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
|
const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
|
||||||
const statusText = isActive ? 'Active' : 'Inactive';
|
const statusText = isActive ? 'Active' : 'Inactive';
|
||||||
|
|
||||||
|
const updateSlotCapacity = trpc.admin.slots.updateSlotCapacity.useMutation();
|
||||||
|
|
||||||
|
const handleCapacityToggle = () => {
|
||||||
|
updateSlotCapacity.mutate(
|
||||||
|
{ slotId: slot.id, isCapacityFull: !slot.isCapacityFull },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
refetch();
|
||||||
|
Alert.alert(
|
||||||
|
'Success',
|
||||||
|
slot.isCapacityFull
|
||||||
|
? 'Slot capacity reset. It will now be visible to users.'
|
||||||
|
: 'Slot marked as full capacity. It will be hidden from users.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update slot capacity');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${slot.id}`)}
|
onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${slot.id}`)}
|
||||||
|
|
@ -58,6 +83,11 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||||
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
|
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
|
||||||
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
|
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
{slot.isCapacityFull && (
|
||||||
|
<View style={tw`px-2 py-1 rounded-full bg-red-500 ml-2`}>
|
||||||
|
<MyText style={tw`text-xs font-bold text-white`}>FULL</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setMenuOpen(true)}
|
onPress={() => setMenuOpen(true)}
|
||||||
style={tw`ml-2 p-1`}
|
style={tw`ml-2 p-1`}
|
||||||
|
|
@ -72,6 +102,48 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||||
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
||||||
<View style={tw`p-4`}>
|
<View style={tw`p-4`}>
|
||||||
<MyText style={tw`text-lg font-bold mb-4`}>Slot #{slot.id} Actions</MyText>
|
<MyText style={tw`text-lg font-bold mb-4`}>Slot #{slot.id} Actions</MyText>
|
||||||
|
|
||||||
|
{/* Capacity Toggle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCapacityToggle}
|
||||||
|
disabled={updateSlotCapacity.isPending}
|
||||||
|
style={tw`py-4 border-b border-gray-200`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center justify-between`}>
|
||||||
|
<View style={tw`flex-row items-center flex-1`}>
|
||||||
|
{updateSlotCapacity.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color="#EF4444" style={tw`mr-3`} />
|
||||||
|
) : (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={slot.isCapacityFull ? "package-variant-closed" : "package-variant"}
|
||||||
|
size={20}
|
||||||
|
color={slot.isCapacityFull ? "#EF4444" : "#4B5563"}
|
||||||
|
style={tw`mr-3`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-base text-gray-800`}>Mark as Full Capacity</MyText>
|
||||||
|
<MyText style={tw`text-xs text-gray-500 mt-0.5`}>
|
||||||
|
{slot.isCapacityFull
|
||||||
|
? "Slot is hidden from users"
|
||||||
|
: "Hidden from users when full"}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{updateSlotCapacity.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color="#EF4444" />
|
||||||
|
) : (
|
||||||
|
<Checkbox
|
||||||
|
checked={slot.isCapacityFull}
|
||||||
|
onPress={handleCapacityToggle}
|
||||||
|
size={22}
|
||||||
|
fillColor="#EF4444"
|
||||||
|
checkColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
|
|
@ -193,6 +265,7 @@ export default function Slots() {
|
||||||
router={router}
|
router={router}
|
||||||
setDialogProducts={setDialogProducts}
|
setDialogProducts={setDialogProducts}
|
||||||
setDialogOpen={setDialogOpen}
|
setDialogOpen={setDialogOpen}
|
||||||
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={tw`p-4`}
|
contentContainerStyle={tw`p-4`}
|
||||||
|
|
@ -202,6 +275,8 @@ export default function Slots() {
|
||||||
|
|
||||||
{/* FAB for Add New Slot */}
|
{/* FAB for Add New Slot */}
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
|
testID="add-slot-fab"
|
||||||
|
accessibilityLabel="add-slot-fab"
|
||||||
onPress={() => router.push('/slots/add' as any)}
|
onPress={() => router.push('/slots/add' as any)}
|
||||||
activeOpacity={0.95}
|
activeOpacity={0.95}
|
||||||
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
258
apps/admin-ui/app/(drawer)/user-management/[id].tsx
Normal file
258
apps/admin-ui/app/(drawer)/user-management/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
apps/admin-ui/app/(drawer)/user-management/index.tsx
Normal file
268
apps/admin-ui/app/(drawer)/user-management/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,7 @@ export default function Users() {
|
||||||
const users = data?.pages.flatMap(page => page.users) || [];
|
const users = data?.pages.flatMap(page => page.users) || [];
|
||||||
|
|
||||||
const handleUserPress = (userId: string) => {
|
const handleUserPress = (userId: string) => {
|
||||||
router.push(`/user-details/${userId}`);
|
router.push(`/(drawer)/user-management/${userId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ export default function Layout() {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<StaffAuthProvider>
|
<StaffAuthProvider>
|
||||||
<SafeAreaView edges={['left', 'right', 'bottom']} style={{ flex: 1, backgroundColor: '#fff' }}>
|
<SafeAreaView
|
||||||
|
edges={['left', 'right', 'bottom']}
|
||||||
|
style={{ flex: 1, backgroundColor: '#fff' }}
|
||||||
|
testID="app-root"
|
||||||
|
>
|
||||||
<RefreshProvider queryClient={queryClient}>
|
<RefreshProvider queryClient={queryClient}>
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
</RefreshProvider>
|
</RefreshProvider>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ export default function LoginScreen() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('from the login page')
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -52,6 +51,8 @@ export default function LoginScreen() {
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
style={{ marginBottom: 20 }}
|
style={{ marginBottom: 20 }}
|
||||||
|
testID="login-name-input"
|
||||||
|
accessibilityLabel="login-name-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
|
|
@ -63,6 +64,8 @@ export default function LoginScreen() {
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
style={{ marginBottom: 30 }}
|
style={{ marginBottom: 30 }}
|
||||||
|
testID="login-password-input"
|
||||||
|
accessibilityLabel="login-password-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loginError && (
|
{loginError && (
|
||||||
|
|
@ -84,6 +87,8 @@ export default function LoginScreen() {
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{ marginBottom: 20 }}
|
style={{ marginBottom: 20 }}
|
||||||
|
testID="login-button"
|
||||||
|
accessibilityLabel="login-button"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,18 @@ export function OrderOptionsMenu({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenInMaps = () => {
|
||||||
|
if (order.latitude && order.longitude) {
|
||||||
|
const url = `https://www.google.com/maps/search/?api=1&query=${order.latitude},${order.longitude}`;
|
||||||
|
Linking.openURL(url);
|
||||||
|
} else {
|
||||||
|
Alert.alert('No location coordinates available');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCoordinates = order.latitude !== null && order.latitude !== undefined &&
|
||||||
|
order.longitude !== null && order.longitude !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomDialog open={open} onClose={onClose}>
|
<BottomDialog open={open} onClose={onClose}>
|
||||||
<View style={{ maxHeight: SCREEN_HEIGHT * 0.7 }}>
|
<View style={{ maxHeight: SCREEN_HEIGHT * 0.7 }}>
|
||||||
|
|
@ -257,6 +269,29 @@ export function OrderOptionsMenu({
|
||||||
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
|
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{hasCoordinates && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
|
onPress={() => {
|
||||||
|
handleOpenInMaps();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="map" size={20} color="#2563eb" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Open in Maps
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
View delivery location on Google Maps
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ export default function ProductsSelector({
|
||||||
{showGroups && groups.length > 0 && (
|
{showGroups && groups.length > 0 && (
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<BottomDropdown
|
<BottomDropdown
|
||||||
|
testID="product-groups-dropdown"
|
||||||
label="Select Product Groups"
|
label="Select Product Groups"
|
||||||
options={groupOptions}
|
options={groupOptions}
|
||||||
value={selectedGroupIds.map(id => id.toString())}
|
value={selectedGroupIds.map(id => id.toString())}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,11 @@ export default function SlotForm({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.freezeTime > values.deliveryTime) {
|
||||||
|
Alert.alert('Error', 'Freeze time must be before or equal to delivery time');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const slotData = {
|
const slotData = {
|
||||||
deliveryTime: values.deliveryTime.toISOString(),
|
deliveryTime: values.deliveryTime.toISOString(),
|
||||||
freezeTime: values.freezeTime.toISOString(),
|
freezeTime: values.freezeTime.toISOString(),
|
||||||
|
|
@ -143,12 +148,22 @@ export default function SlotForm({
|
||||||
|
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<Text style={tw`text-lg font-semibold mb-2`}>Delivery Date & Time</Text>
|
<Text style={tw`text-lg font-semibold mb-2`}>Delivery Date & Time</Text>
|
||||||
<DateTimePickerMod value={values.deliveryTime} setValue={(value) => setFieldValue('deliveryTime', value)} />
|
<DateTimePickerMod
|
||||||
|
dateTestID="delivery-date-picker"
|
||||||
|
timeTestID="delivery-time-picker"
|
||||||
|
value={values.deliveryTime}
|
||||||
|
setValue={(value) => setFieldValue('deliveryTime', value)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<Text style={tw`text-lg font-semibold mb-2`}>Freeze Date & Time</Text>
|
<Text style={tw`text-lg font-semibold mb-2`}>Freeze Date & Time</Text>
|
||||||
<DateTimePickerMod value={values.freezeTime} setValue={(value) => setFieldValue('freezeTime', value)} />
|
<DateTimePickerMod
|
||||||
|
dateTestID="freeze-date-picker"
|
||||||
|
timeTestID="freeze-time-picker"
|
||||||
|
value={values.freezeTime}
|
||||||
|
setValue={(value) => setFieldValue('freezeTime', value)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
|
|
@ -215,6 +230,8 @@ export default function SlotForm({
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
testID="create-slot-button"
|
||||||
|
accessibilityLabel="create-slot-button"
|
||||||
onPress={() => handleSubmit()}
|
onPress={() => handleSubmit()}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
style={tw`${isPending ? 'bg-pink2' : 'bg-pink1'} p-3 rounded-lg items-center mt-6 pb-4`}
|
style={tw`${isPending ? 'bg-pink2' : 'bg-pink1'} p-3 rounded-lg items-center mt-6 pb-4`}
|
||||||
|
|
|
||||||
109
apps/admin-ui/components/UserIncidentDialog.tsx
Normal file
109
apps/admin-ui/components/UserIncidentDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/admin-ui/components/UserIncidentsView.tsx
Normal file
206
apps/admin-ui/components/UserIncidentsView.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,6 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
||||||
},
|
},
|
||||||
onSubmit: async (values) => {
|
onSubmit: async (values) => {
|
||||||
try {
|
try {
|
||||||
console.log({values})
|
|
||||||
|
|
||||||
const submitData = {
|
const submitData = {
|
||||||
snippetCode: values.snippetCode,
|
snippetCode: values.snippetCode,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"expo": "~53.0.22",
|
"expo": "~53.0.22",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createTRPCProxyClient, httpBatchLink, TRPCClientError } from '@trpc/client';
|
import { createTRPCProxyClient, httpBatchLink, TRPCClientError } from '@trpc/client';
|
||||||
import { createTRPCReact } from '@trpc/react-query';
|
import { createTRPCReact } from '@trpc/react-query';
|
||||||
import {AppRouter} from '../../backend/src/trpc/router'
|
import { AppRouter } from '@backend/trpc/router'
|
||||||
import { BASE_API_URL } from 'common-ui';
|
import { BASE_API_URL } from 'common-ui';
|
||||||
import { getJWT } from '@/hooks/useJWT';
|
import { getJWT } from '@/hooks/useJWT';
|
||||||
import { FORCE_LOGOUT_EVENT } from 'common-ui/src/lib/const-strs';
|
import { FORCE_LOGOUT_EVENT } from 'common-ui/src/lib/const-strs';
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./*"
|
"./*",
|
||||||
|
"../backend/*"
|
||||||
|
],
|
||||||
|
"@backend/*": [
|
||||||
|
"../backend/src/*"
|
||||||
],
|
],
|
||||||
"shared-types": ["../shared-types"],
|
"shared-types": ["../shared-types"],
|
||||||
"common-ui": ["../../packages/ui"],
|
"common-ui": ["../../packages/ui"],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
|
||||||
ENV_MODE=PROD
|
ENV_MODE=PROD
|
||||||
DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
||||||
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||||
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||||
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||||
PHONE_PE_CLIENT_VERSION=1
|
PHONE_PE_CLIENT_VERSION=1
|
||||||
|
|
@ -21,6 +20,7 @@ S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
|
||||||
S3_BUCKET_NAME=meatfarmer
|
S3_BUCKET_NAME=meatfarmer
|
||||||
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
||||||
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
||||||
|
ASSETS_DOMAIN=https://assets.freshyo.in/
|
||||||
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
|
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
|
||||||
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
|
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
|
||||||
APP_URL=http://localhost:4000
|
APP_URL=http://localhost:4000
|
||||||
|
|
|
||||||
1
apps/backend/assets/public/demo.txt
Normal file
1
apps/backend/assets/public/demo.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
This is a demo file.
|
||||||
BIN
apps/backend/assets/public/halal.jpg
Normal file
BIN
apps/backend/assets/public/halal.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/backend/assets/public/preservs.jpg
Normal file
BIN
apps/backend/assets/public/preservs.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
File diff suppressed because one or more lines are too long
0
apps/backend/creds/demo.txt
Normal file
0
apps/backend/creds/demo.txt
Normal file
13
apps/backend/creds/fcm-v1-account.json
Normal file
13
apps/backend/creds/fcm-v1-account.json
Normal 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
0
apps/backend/demo.json
Normal file
7
apps/backend/drizzle/0072_flowery_deathbird.sql
Normal file
7
apps/backend/drizzle/0072_flowery_deathbird.sql
Normal 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
|
||||||
|
);
|
||||||
1
apps/backend/drizzle/0073_faithful_gravity.sql
Normal file
1
apps/backend/drizzle/0073_faithful_gravity.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "mf"."user_notifications" ADD COLUMN "title" varchar(255) NOT NULL;
|
||||||
1
apps/backend/drizzle/0074_outgoing_black_cat.sql
Normal file
1
apps/backend/drizzle/0074_outgoing_black_cat.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "mf"."delivery_slot_info" ADD COLUMN "is_capacity_full" boolean DEFAULT false NOT NULL;
|
||||||
7
apps/backend/drizzle/0075_cuddly_rocket_racer.sql
Normal file
7
apps/backend/drizzle/0075_cuddly_rocket_racer.sql
Normal 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")
|
||||||
|
);
|
||||||
13
apps/backend/drizzle/0076_sturdy_wolverine.sql
Normal file
13
apps/backend/drizzle/0076_sturdy_wolverine.sql
Normal 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;
|
||||||
3685
apps/backend/drizzle/meta/0072_snapshot.json
Normal file
3685
apps/backend/drizzle/meta/0072_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3691
apps/backend/drizzle/meta/0073_snapshot.json
Normal file
3691
apps/backend/drizzle/meta/0073_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3698
apps/backend/drizzle/meta/0074_snapshot.json
Normal file
3698
apps/backend/drizzle/meta/0074_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3755
apps/backend/drizzle/meta/0075_snapshot.json
Normal file
3755
apps/backend/drizzle/meta/0075_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3865
apps/backend/drizzle/meta/0076_snapshot.json
Normal file
3865
apps/backend/drizzle/meta/0076_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -505,6 +505,41 @@
|
||||||
"when": 1770321591876,
|
"when": 1770321591876,
|
||||||
"tag": "0071_moaning_shadow_king",
|
"tag": "0071_moaning_shadow_king",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770546741428,
|
||||||
|
"tag": "0072_flowery_deathbird",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 73,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770561175889,
|
||||||
|
"tag": "0073_faithful_gravity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 74,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771674555093,
|
||||||
|
"tag": "0074_outgoing_black_cat",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 75,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772196660983,
|
||||||
|
"tag": "0075_cuddly_rocket_racer",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 76,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772637259874,
|
||||||
|
"tag": "0076_sturdy_wolverine",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -5,21 +5,21 @@ import cors from "cors";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { db } from './src/db/db_index';
|
import { db } from '@/src/db/db_index';
|
||||||
import { staffUsers, userDetails } from './src/db/schema';
|
import { staffUsers, userDetails } from '@/src/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import mainRouter from './src/main-router';
|
import mainRouter from '@/src/main-router';
|
||||||
import initFunc from './src/lib/init';
|
import initFunc from '@/src/lib/init';
|
||||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
||||||
import { appRouter } from './src/trpc/router';
|
import { appRouter } from '@/src/trpc/router';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import signedUrlCache from 'src/lib/signed-url-cache';
|
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||||
import { seed } from 'src/db/seed';
|
import { seed } from '@/src/db/seed';
|
||||||
import './src/jobs/jobs-index';
|
import '@/src/jobs/jobs-index';
|
||||||
import { startAutomatedJobs } from './src/lib/automatedJobs';
|
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||||
|
|
||||||
// seed()
|
seed()
|
||||||
initFunc()
|
initFunc()
|
||||||
startAutomatedJobs()
|
startAutomatedJobs()
|
||||||
|
|
||||||
|
|
@ -163,6 +163,15 @@ if (fs.existsSync(fallbackUiIndex)) {
|
||||||
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
|
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve /assets/public folder at /assets route
|
||||||
|
const assetsPublicDir = path.resolve(__dirname, './assets/public');
|
||||||
|
if (fs.existsSync(assetsPublicDir)) {
|
||||||
|
app.use('/assets', express.static(assetsPublicDir));
|
||||||
|
console.log('Serving /assets from', assetsPublicDir);
|
||||||
|
} else {
|
||||||
|
console.warn('Assets public folder not found at', assetsPublicDir);
|
||||||
|
}
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -171,6 +180,6 @@ app.use((err: any, req: express.Request, res: express.Response, next: express.Ne
|
||||||
res.status(status).json({ message });
|
res.status(status).json({ message });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(4000, () => {
|
app.listen(4000, '::', () => {
|
||||||
console.log("Server is running on http://localhost:4000/api/mobile/");
|
console.log("Server is running on http://localhost:4000/api/mobile/");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"migrate": "drizzle-kit generate:pg",
|
"migrate": "drizzle-kit generate:pg",
|
||||||
"build": "rimraf ./dist && tsc --project tsconfig.json",
|
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
||||||
"build2": "rimraf ./dist && tsc",
|
"build2": "rimraf ./dist && tsc",
|
||||||
"db:push": "drizzle-kit push:pg",
|
"db:push": "drizzle-kit push:pg",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
|
@ -42,7 +42,6 @@
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-sdk-node": "https://phonepe.mycloudrepo.io/public/repositories/phonepe-pg-sdk-node/releases/v2/phonepe-pg-sdk-node.tgz",
|
|
||||||
"razorpay": "^2.9.6",
|
"razorpay": "^2.9.6",
|
||||||
"redis": "^5.9.0",
|
"redis": "^5.9.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|
@ -55,6 +54,7 @@
|
||||||
"rimraf": "^6.1.2",
|
"rimraf": "^6.1.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
|
"tsc-alias": "^1.8.16",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authenticateStaff } from "../middleware/staff-auth";
|
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
||||||
import productRouter from "./product.router";
|
import productRouter from "@/src/apis/admin-apis/apis/product.router"
|
||||||
import tagRouter from "./tag.router";
|
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { db } from "../db/db_index";
|
import { db } from "@/src/db/db_index";
|
||||||
import { productTagInfo } from "../db/schema";
|
import { productTagInfo } from "@/src/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { ApiError } from "../lib/api-error";
|
import { ApiError } from "@/src/lib/api-error";
|
||||||
import { imageUploadS3, generateSignedUrlFromS3Url } from "../lib/s3-client";
|
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
|
||||||
import { deleteS3Image } from "../lib/delete-image";
|
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||||
import { initializeAllStores } from '../stores/store-initializer';
|
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new product tag
|
* Create a new product tag
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { db } from "../db/db_index";
|
import { db } from "@/src/db/db_index";
|
||||||
import { productInfo, units, specialDeals, productTags } from "../db/schema";
|
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { ApiError } from "../lib/api-error";
|
import { ApiError } from "@/src/lib/api-error";
|
||||||
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "../lib/s3-client";
|
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
||||||
import { deleteS3Image } from "../lib/delete-image";
|
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||||
import type { SpecialDeal } from "../db/types";
|
import type { SpecialDeal } from "@/src/db/types";
|
||||||
import { initializeAllStores } from '../stores/store-initializer';
|
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||||
|
|
||||||
type CreateDeal = {
|
type CreateDeal = {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
@ -124,7 +124,6 @@ export const updateProduct = async (req: Request, res: Response) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
||||||
|
|
||||||
console.log({productQuantity})
|
|
||||||
|
|
||||||
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
||||||
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { createProduct, updateProduct } from "./product.controller";
|
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
|
||||||
import uploadHandler from '../lib/upload-handler';
|
import uploadHandler from '@/src/lib/upload-handler';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "./product-tags.controller";
|
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
|
||||||
import uploadHandler from '../lib/upload-handler';
|
import uploadHandler from '@/src/lib/upload-handler';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
0
apps/backend/src/apis/admin-apis/dataAccessors/demo.txt
Normal file
0
apps/backend/src/apis/admin-apis/dataAccessors/demo.txt
Normal file
|
|
@ -1,8 +1,8 @@
|
||||||
import { eq, gt, and, sql, inArray } from "drizzle-orm";
|
import { eq, gt, and, sql, inArray } from "drizzle-orm";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { db } from "../db/db_index";
|
import { db } from "@/src/db/db_index"
|
||||||
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "../db/schema";
|
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
|
||||||
import { generateSignedUrlsFromS3Urls } from "../lib/s3-client";
|
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get next delivery date for a product
|
* Get next delivery date for a product
|
||||||
|
|
@ -89,7 +89,7 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
|
||||||
productQuantity: product.productQuantity,
|
productQuantity: product.productQuantity,
|
||||||
isOutOfStock: product.isOutOfStock,
|
isOutOfStock: product.isOutOfStock,
|
||||||
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
||||||
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
|
images: scaffoldAssetUrl((product.images as string[]) || []),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { getAllProductsSummary } from "./common-product.controller";
|
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import commonProductsRouter from "./common-product.router";
|
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
0
apps/backend/src/apis/common-apis/dataAccessors/demo.txt
Normal file
0
apps/backend/src/apis/common-apis/dataAccessors/demo.txt
Normal file
|
|
@ -1,7 +1,7 @@
|
||||||
import { drizzle } from "drizzle-orm/node-postgres"
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import * as schema from "./schema"
|
import * as schema from "@/src/db/schema"
|
||||||
|
|
||||||
const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema })
|
const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema })
|
||||||
// const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler');
|
// const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler');
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
* This was a one time script to change the composition of the signed urls
|
* This was a one time script to change the composition of the signed urls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db } from './db_index';
|
import { db } from '@/src/db/db_index'
|
||||||
import {
|
import {
|
||||||
userDetails,
|
userDetails,
|
||||||
productInfo,
|
productInfo,
|
||||||
productTagInfo,
|
productTagInfo,
|
||||||
complaints
|
complaints
|
||||||
} from './schema';
|
} from '@/src/db/schema';
|
||||||
import { eq, not, isNull } from 'drizzle-orm';
|
import { eq, not, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';
|
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', {
|
||||||
freezeTime: timestamp('freeze_time').notNull(),
|
freezeTime: timestamp('freeze_time').notNull(),
|
||||||
isActive: boolean('is_active').notNull().default(true),
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
isFlash: boolean('is_flash').notNull().default(false),
|
isFlash: boolean('is_flash').notNull().default(false),
|
||||||
|
isCapacityFull: boolean('is_capacity_full').notNull().default(false),
|
||||||
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
||||||
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
||||||
});
|
});
|
||||||
|
|
@ -390,6 +391,16 @@ export const couponApplicableProducts = mf.table('coupon_applicable_products', {
|
||||||
unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId),
|
unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const userIncidents = mf.table('user_incidents', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
orderId: integer('order_id').references(() => orders.id),
|
||||||
|
dateAdded: timestamp('date_added').notNull().defaultNow(),
|
||||||
|
adminComment: text('admin_comment'),
|
||||||
|
addedBy: integer('added_by').references(() => staffUsers.id),
|
||||||
|
negativityScore: integer('negativity_score'),
|
||||||
|
});
|
||||||
|
|
||||||
export const reservedCoupons = mf.table('reserved_coupons', {
|
export const reservedCoupons = mf.table('reserved_coupons', {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
|
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
|
||||||
|
|
@ -419,6 +430,22 @@ export const notifCreds = mf.table('notif_creds', {
|
||||||
lastVerified: timestamp('last_verified'),
|
lastVerified: timestamp('last_verified'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const unloggedUserTokens = mf.table('unlogged_user_tokens', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
token: varchar({ length: 500 }).notNull().unique(),
|
||||||
|
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||||
|
lastVerified: timestamp('last_verified'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userNotifications = mf.table('user_notifications', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
title: varchar('title', { length: 255 }).notNull(),
|
||||||
|
imageUrl: varchar('image_url', { length: 500 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
body: text('body').notNull(),
|
||||||
|
applicableUsers: jsonb('applicable_users'),
|
||||||
|
});
|
||||||
|
|
||||||
export const staffRoles = mf.table('staff_roles', {
|
export const staffRoles = mf.table('staff_roles', {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
roleName: staffRoleEnum('role_name').notNull(),
|
roleName: staffRoleEnum('role_name').notNull(),
|
||||||
|
|
@ -456,6 +483,7 @@ export const usersRelations = relations(users, ({ many, one }) => ({
|
||||||
applicableCoupons: many(couponApplicableUsers),
|
applicableCoupons: many(couponApplicableUsers),
|
||||||
userDetails: one(userDetails),
|
userDetails: one(userDetails),
|
||||||
notifCreds: many(notifCreds),
|
notifCreds: many(notifCreds),
|
||||||
|
userIncidents: many(userIncidents),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const userCredsRelations = relations(userCreds, ({ one }) => ({
|
export const userCredsRelations = relations(userCreds, ({ one }) => ({
|
||||||
|
|
@ -525,6 +553,7 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||||
orderStatus: many(orderStatus),
|
orderStatus: many(orderStatus),
|
||||||
refunds: many(refunds),
|
refunds: many(refunds),
|
||||||
couponUsages: many(couponUsage),
|
couponUsages: many(couponUsage),
|
||||||
|
userIncidents: many(userIncidents),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
|
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
|
||||||
|
|
@ -588,6 +617,10 @@ export const notifCredsRelations = relations(notifCreds, ({ one }) => ({
|
||||||
user: one(users, { fields: [notifCreds.userId], references: [users.id] }),
|
user: one(users, { fields: [notifCreds.userId], references: [users.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const userNotificationsRelations = relations(userNotifications, ({}) => ({
|
||||||
|
// No relations needed for now
|
||||||
|
}));
|
||||||
|
|
||||||
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
|
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
|
||||||
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
||||||
products: many(productInfo),
|
products: many(productInfo),
|
||||||
|
|
@ -648,3 +681,9 @@ export const staffRolePermissionsRelations = relations(staffRolePermissions, ({
|
||||||
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
|
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
|
||||||
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
|
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [userIncidents.userId], references: [users.id] }),
|
||||||
|
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
|
||||||
|
addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { db } from "./db_index";
|
import { db } from "@/src/db/db_index"
|
||||||
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "./schema";
|
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "@/src/db/schema"
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { minOrderValue, deliveryCharge } from '../lib/env-exporter';
|
import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter'
|
||||||
import { CONST_KEYS } from '../lib/const-keys';
|
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||||
|
|
||||||
export async function seed() {
|
export async function seed() {
|
||||||
console.log("Seeding database...");
|
console.log("Seeding database...");
|
||||||
|
|
@ -113,9 +113,10 @@ export async function seed() {
|
||||||
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
|
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
|
||||||
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 },
|
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 },
|
||||||
{ key: CONST_KEYS.popularItems, value: [] },
|
{ key: CONST_KEYS.popularItems, value: [] },
|
||||||
|
{ key: CONST_KEYS.allItemsOrder, value: [] },
|
||||||
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
|
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
|
||||||
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
||||||
{ key: CONST_KEYS.appStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
{ key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' },
|
||||||
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
|
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
|
||||||
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
|
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
|
||||||
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type {
|
||||||
productCategories,
|
productCategories,
|
||||||
cartItems,
|
cartItems,
|
||||||
coupons,
|
coupons,
|
||||||
} from "./schema";
|
} from "@/src/db/schema";
|
||||||
|
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Address = InferSelectModel<typeof addresses>;
|
export type Address = InferSelectModel<typeof addresses>;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as cron from 'node-cron';
|
import * as cron from 'node-cron';
|
||||||
import { checkPendingPayments, checkRefundStatuses } from './payment-status-checker';
|
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
|
||||||
|
|
||||||
const runCombinedJob = async () => {
|
const runCombinedJob = async () => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import * as cron from 'node-cron';
|
import * as cron from 'node-cron';
|
||||||
import { db } from '../db/db_index';
|
import { db } from '@/src/db/db_index'
|
||||||
import { payments, orders, deliverySlotInfo, refunds } from '../db/schema';
|
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
|
||||||
import { eq, and, gt, isNotNull } from 'drizzle-orm';
|
import { eq, and, gt, isNotNull } from 'drizzle-orm';
|
||||||
import { RazorpayPaymentService } from '../lib/payments-utils';
|
import { RazorpayPaymentService } from '@/src/lib/payments-utils'
|
||||||
|
|
||||||
interface PendingPaymentRecord {
|
interface PendingPaymentRecord {
|
||||||
payment: typeof payments.$inferSelect;
|
payment: typeof payments.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import * as cron from 'node-cron';
|
import * as cron from 'node-cron';
|
||||||
import { db } from '../db/db_index';
|
import { db } from '@/src/db/db_index'
|
||||||
import { productInfo, keyValStore } from '../db/schema';
|
import { productInfo, keyValStore } from '@/src/db/schema'
|
||||||
import { inArray, eq } from 'drizzle-orm';
|
import { inArray, eq } from 'drizzle-orm';
|
||||||
import { CONST_KEYS } from '../lib/const-keys';
|
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||||
import { computeConstants } from '../lib/const-store';
|
import { computeConstants } from '@/src/lib/const-store'
|
||||||
|
|
||||||
|
|
||||||
const MUTTON_ITEMS = [
|
const MUTTON_ITEMS = [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axiosParent from "axios";
|
import axiosParent from "axios";
|
||||||
import { phonePeBaseUrl } from "./env-exporter";
|
import { phonePeBaseUrl } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
export const phonepeAxios = axiosParent.create({
|
export const phonepeAxios = axiosParent.create({
|
||||||
baseURL: phonePeBaseUrl,
|
baseURL: phonePeBaseUrl,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export const CONST_KEYS = {
|
||||||
flashDeliveryCharge: 'flashDeliveryCharge',
|
flashDeliveryCharge: 'flashDeliveryCharge',
|
||||||
platformFeePercent: 'platformFeePercent',
|
platformFeePercent: 'platformFeePercent',
|
||||||
taxRate: 'taxRate',
|
taxRate: 'taxRate',
|
||||||
|
tester: 'tester',
|
||||||
minOrderAmountForCoupon: 'minOrderAmountForCoupon',
|
minOrderAmountForCoupon: 'minOrderAmountForCoupon',
|
||||||
maxCouponDiscount: 'maxCouponDiscount',
|
maxCouponDiscount: 'maxCouponDiscount',
|
||||||
flashDeliverySlotId: 'flashDeliverySlotId',
|
flashDeliverySlotId: 'flashDeliverySlotId',
|
||||||
|
|
@ -14,6 +15,7 @@ export const CONST_KEYS = {
|
||||||
playStoreUrl: 'playStoreUrl',
|
playStoreUrl: 'playStoreUrl',
|
||||||
appStoreUrl: 'appStoreUrl',
|
appStoreUrl: 'appStoreUrl',
|
||||||
popularItems: 'popularItems',
|
popularItems: 'popularItems',
|
||||||
|
allItemsOrder: 'allItemsOrder',
|
||||||
isFlashDeliveryEnabled: 'isFlashDeliveryEnabled',
|
isFlashDeliveryEnabled: 'isFlashDeliveryEnabled',
|
||||||
supportMobile: 'supportMobile',
|
supportMobile: 'supportMobile',
|
||||||
supportEmail: 'supportEmail',
|
supportEmail: 'supportEmail',
|
||||||
|
|
@ -27,6 +29,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
|
||||||
flashDeliveryCharge: 'Flash Delivery Charge',
|
flashDeliveryCharge: 'Flash Delivery Charge',
|
||||||
platformFeePercent: 'Platform Fee Percent',
|
platformFeePercent: 'Platform Fee Percent',
|
||||||
taxRate: 'Tax Rate',
|
taxRate: 'Tax Rate',
|
||||||
|
tester: 'Tester',
|
||||||
minOrderAmountForCoupon: 'Minimum Order Amount for Coupon',
|
minOrderAmountForCoupon: 'Minimum Order Amount for Coupon',
|
||||||
maxCouponDiscount: 'Maximum Coupon Discount',
|
maxCouponDiscount: 'Maximum Coupon Discount',
|
||||||
flashDeliverySlotId: 'Flash Delivery Slot ID',
|
flashDeliverySlotId: 'Flash Delivery Slot ID',
|
||||||
|
|
@ -35,6 +38,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
|
||||||
playStoreUrl: 'Play Store URL',
|
playStoreUrl: 'Play Store URL',
|
||||||
appStoreUrl: 'App Store URL',
|
appStoreUrl: 'App Store URL',
|
||||||
popularItems: 'Popular Items',
|
popularItems: 'Popular Items',
|
||||||
|
allItemsOrder: 'All Items Order',
|
||||||
isFlashDeliveryEnabled: 'Enable Flash Delivery',
|
isFlashDeliveryEnabled: 'Enable Flash Delivery',
|
||||||
supportMobile: 'Support Mobile',
|
supportMobile: 'Support Mobile',
|
||||||
supportEmail: 'Support Email',
|
supportEmail: 'Support Email',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { db } from '../db/db_index';
|
import { db } from '@/src/db/db_index'
|
||||||
import { keyValStore } from '../db/schema';
|
import { keyValStore } from '@/src/db/schema'
|
||||||
import redisClient from './redis-client';
|
import redisClient from '@/src/lib/redis-client'
|
||||||
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from './const-keys';
|
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys'
|
||||||
|
|
||||||
const CONST_REDIS_PREFIX = 'const:';
|
const CONST_REDIS_PREFIX = 'const:';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const computeConstants = async (): Promise<void> => {
|
||||||
for (const constant of constants) {
|
for (const constant of constants) {
|
||||||
const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`;
|
const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`;
|
||||||
const value = JSON.stringify(constant.value);
|
const value = JSON.stringify(constant.value);
|
||||||
console.log({redisKey, value})
|
// console.log({redisKey, value})
|
||||||
|
|
||||||
await redisClient.set(redisKey, value);
|
await redisClient.set(redisKey, value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "../db/db_index";
|
import { db } from "@/src/db/db_index"
|
||||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "./s3-client";
|
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
||||||
import { s3Url } from "./env-exporter";
|
import { s3Url } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
function extractS3Key(url: string): string | null {
|
function extractS3Key(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { db } from '../db/db_index';
|
import { db } from '@/src/db/db_index'
|
||||||
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema';
|
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ export const s3BucketName = process.env.S3_BUCKET_NAME as string
|
||||||
|
|
||||||
export const s3Region = process.env.S3_REGION as string
|
export const s3Region = process.env.S3_REGION as string
|
||||||
|
|
||||||
|
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
||||||
|
|
||||||
export const s3Url = process.env.S3_URL as string
|
export const s3Url = process.env.S3_URL as string
|
||||||
|
|
||||||
export const redisUrl = process.env.REDIS_URL as string
|
export const redisUrl = process.env.REDIS_URL as string
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import redisClient from './redis-client';
|
import redisClient from '@/src/lib/redis-client'
|
||||||
|
|
||||||
export async function enqueue(queueName: string, eventData: any): Promise<boolean> {
|
export async function enqueue(queueName: string, eventData: any): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Expo } from "expo-server-sdk";
|
import { Expo } from "expo-server-sdk";
|
||||||
import { title } from "process";
|
import { title } from "process";
|
||||||
import { expoAccessToken } from "./env-exporter";
|
import { expoAccessToken } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
const expo = new Expo({
|
const expo = new Expo({
|
||||||
accessToken: expoAccessToken,
|
accessToken: expoAccessToken,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import './notif-job';
|
import '@/src/lib/notif-job'
|
||||||
import { initializeAllStores } from '../stores/store-initializer';
|
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||||
import { startOrderHandler, startCancellationHandler, publishOrder } from './post-order-handler';
|
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
|
||||||
import { deleteOrders } from './delete-orders';
|
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
|
||||||
|
import { deleteOrders } from '@/src/lib/delete-orders'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all application services
|
* Initialize all application services
|
||||||
|
|
@ -10,6 +11,7 @@ import { deleteOrders } from './delete-orders';
|
||||||
* - Const Store (syncs constants from DB to Redis)
|
* - Const Store (syncs constants from DB to Redis)
|
||||||
* - Post Order Handler (Redis Pub/Sub subscriber)
|
* - Post Order Handler (Redis Pub/Sub subscriber)
|
||||||
* - Cancellation Handler (Redis Pub/Sub subscriber for order cancellations)
|
* - Cancellation Handler (Redis Pub/Sub subscriber for order cancellations)
|
||||||
|
* - User Negativity Store (caches user negativity scores in Redis)
|
||||||
* - Other services can be added here in the future
|
* - Other services can be added here in the future
|
||||||
*/
|
*/
|
||||||
export const initFunc = async (): Promise<void> => {
|
export const initFunc = async (): Promise<void> => {
|
||||||
|
|
@ -18,6 +20,7 @@ export const initFunc = async (): Promise<void> => {
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
initializeAllStores(),
|
initializeAllStores(),
|
||||||
|
initializeUserNegativityStore(),
|
||||||
startOrderHandler(),
|
startOrderHandler(),
|
||||||
startCancellationHandler(),
|
startCancellationHandler(),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { Queue, Worker } from 'bullmq';
|
import { Queue, Worker } from 'bullmq';
|
||||||
import { redisUrl } from './env-exporter';
|
import { Expo } from 'expo-server-sdk';
|
||||||
|
import { redisUrl } from '@/src/lib/env-exporter'
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||||
import {
|
import {
|
||||||
NOTIFS_QUEUE,
|
NOTIFS_QUEUE,
|
||||||
ORDER_PLACED_MESSAGE,
|
ORDER_PLACED_MESSAGE,
|
||||||
|
|
@ -9,26 +12,78 @@ import {
|
||||||
ORDER_DELIVERED_MESSAGE,
|
ORDER_DELIVERED_MESSAGE,
|
||||||
ORDER_CANCELLED_MESSAGE,
|
ORDER_CANCELLED_MESSAGE,
|
||||||
REFUND_INITIATED_MESSAGE
|
REFUND_INITIATED_MESSAGE
|
||||||
} from './const-strings';
|
} from '@/src/lib/const-strings';
|
||||||
|
|
||||||
export const notificationQueue = new Queue(NOTIFS_QUEUE, {
|
export const notificationQueue = new Queue(NOTIFS_QUEUE, {
|
||||||
connection: { url: redisUrl },
|
connection: { url: redisUrl },
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
removeOnComplete: 50,
|
removeOnComplete: true,
|
||||||
removeOnFail: 100,
|
removeOnFail: 10,
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
|
export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
console.log(`Processing notification job ${job.id}`);
|
|
||||||
// TODO: Implement sendPushNotification
|
const { name, data } = job;
|
||||||
|
console.log(`Processing notification job ${job.id} - ${name}`);
|
||||||
|
|
||||||
|
if (name === 'send-admin-notification') {
|
||||||
|
await sendAdminNotification(data);
|
||||||
|
} else if (name === 'send-notification') {
|
||||||
|
// Handle legacy notification type
|
||||||
|
console.log('Legacy notification job - not implemented yet');
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
connection: { url: redisUrl },
|
connection: { url: redisUrl },
|
||||||
concurrency: 5,
|
concurrency: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function sendAdminNotification(data: {
|
||||||
|
token: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
}) {
|
||||||
|
const { token, title, body, imageUrl } = data;
|
||||||
|
|
||||||
|
// Validate Expo push token
|
||||||
|
if (!Expo.isExpoPushToken(token)) {
|
||||||
|
console.error(`Invalid Expo push token: ${token}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate signed URL for image if provided
|
||||||
|
const signedImageUrl = imageUrl ? await generateSignedUrlFromS3Url(imageUrl) : null;
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
const expo = new Expo();
|
||||||
|
const message = {
|
||||||
|
to: token,
|
||||||
|
sound: 'default',
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: { imageUrl },
|
||||||
|
...(signedImageUrl ? {
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
url: signedImageUrl,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [ticket] = await expo.sendPushNotificationsAsync([message]);
|
||||||
|
console.log(`Notification sent:`, ticket);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send notification:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notificationWorker.on('completed', (job) => {
|
notificationWorker.on('completed', (job) => {
|
||||||
if (job) console.log(`Notification job ${job.id} completed`);
|
if (job) console.log(`Notification job ${job.id} completed`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { db } from "../db/db_index";
|
import { db } from "@/src/db/db_index"
|
||||||
import { sendPushNotificationsMany } from "./expo-service";
|
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
|
||||||
// import { usersTable, notifCredsTable, notificationTable } from "../db/schema";
|
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
// Core notification dispatch methods (renamed for clarity)
|
// Core notification dispatch methods (renamed for clarity)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ApiError } from './api-error';
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
import { otpSenderAuthToken } from './env-exporter';
|
import { otpSenderAuthToken } from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
const otpStore = new Map<string, string>();
|
const otpStore = new Map<string, string>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Razorpay from "razorpay";
|
import Razorpay from "razorpay";
|
||||||
import { razorpayId, razorpaySecret } from "./env-exporter";
|
import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"
|
||||||
import { db } from "../db/db_index";
|
import { db } from "@/src/db/db_index"
|
||||||
import { payments } from "../db/schema";
|
import { payments } from "@/src/db/schema"
|
||||||
|
|
||||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { db } from '../db/db_index';
|
import { db } from '@/src/db/db_index'
|
||||||
import { orders, orderStatus } from '../db/schema';
|
import { orders, orderStatus } from '@/src/db/schema'
|
||||||
import redisClient from './redis-client';
|
import redisClient from '@/src/lib/redis-client'
|
||||||
import { sendTelegramMessage } from './telegram-service';
|
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
||||||
import { inArray, eq } from 'drizzle-orm';
|
import { inArray, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
const ORDER_CHANNEL = 'orders:placed';
|
const ORDER_CHANNEL = 'orders:placed';
|
||||||
|
|
@ -35,7 +35,10 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
||||||
|
|
||||||
message += '📦 <b>Items:</b>\n';
|
message += '📦 <b>Items:</b>\n';
|
||||||
order.orderItems?.forEach((item: any) => {
|
order.orderItems?.forEach((item: any) => {
|
||||||
message += ` • ${item.product?.name || 'Unknown'} x${item.quantity}\n`;
|
const productQuantity = item.product?.productQuantity ?? 1
|
||||||
|
const unitNotation = item.product?.unit?.shortNotation || ''
|
||||||
|
const quantityWithUnit = unitNotation ? `${productQuantity}${unitNotation}` : `${productQuantity}`
|
||||||
|
message += ` • ${item.product?.name || 'Unknown'} ${quantityWithUnit} x${item.quantity}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`;
|
message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`;
|
||||||
|
|
@ -72,7 +75,12 @@ const formatCancellationMessage = (orderData: any, cancellationData: Cancellatio
|
||||||
📞 <b>Phone:</b> ${orderData.address?.phone || 'N/A'}
|
📞 <b>Phone:</b> ${orderData.address?.phone || 'N/A'}
|
||||||
|
|
||||||
📦 <b>Items:</b>
|
📦 <b>Items:</b>
|
||||||
${orderData.orderItems?.map((item: any) => ` • ${item.product?.name || 'Unknown'} x${item.quantity}`).join('\n') || ' N/A'}
|
${orderData.orderItems?.map((item: any) => {
|
||||||
|
const productQuantity = item.product?.productQuantity ?? 1
|
||||||
|
const unitNotation = item.product?.unit?.shortNotation || ''
|
||||||
|
const quantityWithUnit = unitNotation ? `${productQuantity}${unitNotation}` : `${productQuantity}`
|
||||||
|
return ` • ${item.product?.name || 'Unknown'} ${quantityWithUnit} x${item.quantity}`
|
||||||
|
}).join('\n') || ' N/A'}
|
||||||
|
|
||||||
💰 <b>Total:</b> ₹${orderData.totalAmount}
|
💰 <b>Total:</b> ₹${orderData.totalAmount}
|
||||||
💳 <b>Refund:</b> ${orderData.refundStatus === 'na' ? 'N/A (COD)' : orderData.refundStatus || 'Pending'}
|
💳 <b>Refund:</b> ${orderData.refundStatus === 'na' ? 'N/A (COD)' : orderData.refundStatus || 'Pending'}
|
||||||
|
|
@ -102,7 +110,7 @@ export const startOrderHandler = async (): Promise<void> => {
|
||||||
where: inArray(orders.id, orderIds),
|
where: inArray(orders.id, orderIds),
|
||||||
with: {
|
with: {
|
||||||
address: true,
|
address: true,
|
||||||
orderItems: { with: { product: true } },
|
orderItems: { with: { product: { with: { unit: true } } } },
|
||||||
slot: true,
|
slot: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -147,7 +155,7 @@ export const startCancellationHandler = async (): Promise<void> => {
|
||||||
where: eq(orders.id, cancellationData.orderId),
|
where: eq(orders.id, cancellationData.orderId),
|
||||||
with: {
|
with: {
|
||||||
address: true,
|
address: true,
|
||||||
orderItems: { with: { product: true } },
|
orderItems: { with: { product: { with: { unit: true } } } },
|
||||||
refunds: true,
|
refunds: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createClient, RedisClientType } from 'redis';
|
import { createClient, RedisClientType } from 'redis';
|
||||||
import { redisUrl } from './env-exporter';
|
import { redisUrl } from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
class RedisClient {
|
class RedisClient {
|
||||||
private client: RedisClientType;
|
private client: RedisClientType;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { db } from "../db/db_index";
|
import { db } from "@/src/db/db_index"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for role names to avoid hardcoding and typos
|
* Constants for role names to avoid hardcoding and typos
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "../lib/env-exporter"
|
// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter"
|
||||||
import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
|
import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||||
import signedUrlCache from "./signed-url-cache"
|
import signedUrlCache from "@/src/lib/signed-url-cache"
|
||||||
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName } from "./env-exporter";
|
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
||||||
import { db } from "../db/db_index"; // Adjust path if needed
|
import { db } from "@/src/db/db_index"; // Adjust path if needed
|
||||||
import { uploadUrlStatus } from "../db/schema";
|
import { uploadUrlStatus } from "@/src/db/schema"
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
|
|
@ -60,6 +60,22 @@ export async function deleteImageUtil({bucket = s3BucketName, keys}:{bucket?:str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scaffoldAssetUrl(input: string | null): string
|
||||||
|
export function scaffoldAssetUrl(input: (string | null)[]): string[]
|
||||||
|
export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input.map(key => scaffoldAssetUrl(key) as string);
|
||||||
|
}
|
||||||
|
if (!input) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const normalizedKey = input.replace(/^\/+/, '');
|
||||||
|
const domain = assetsDomain.endsWith('/')
|
||||||
|
? assetsDomain.slice(0, -1)
|
||||||
|
: assetsDomain;
|
||||||
|
return `${domain}/${normalizedKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a signed URL from an S3 URL
|
* Generate a signed URL from an S3 URL
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { isDevMode, telegramBotToken, telegramChatIds } from './env-exporter';
|
import { isDevMode, telegramBotToken, telegramChatIds } from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
const BOT_TOKEN = telegramBotToken;
|
const BOT_TOKEN = telegramBotToken;
|
||||||
const CHAT_IDS = telegramChatIds;
|
const CHAT_IDS = telegramChatIds;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Router, Request, Response, NextFunction } from "express";
|
import { Router, Request, Response, NextFunction } from "express";
|
||||||
import avRouter from "./admin-apis/av-router";
|
import avRouter from "@/src/apis/admin-apis/apis/av-router"
|
||||||
import { ApiError } from "./lib/api-error";
|
import { ApiError } from "@/src/lib/api-error"
|
||||||
import v1Router from "./v1-router";
|
import v1Router from "@/src/v1-router"
|
||||||
import testController from "./test-controller";
|
import testController from "@/src/test-controller"
|
||||||
import { authenticateUser } from "./middleware/auth.middleware";
|
import { authenticateUser } from "@/src/middleware/auth.middleware"
|
||||||
import { raiseComplaint } from "./uv-apis/user-rest.controller";
|
import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
|
||||||
import uploadHandler from "./lib/upload-handler";
|
import uploadHandler from "@/src/lib/upload-handler"
|
||||||
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { db } from '../db/db_index';
|
import { db } from '@/src/db/db_index'
|
||||||
import { staffUsers, userDetails } from '../db/schema';
|
import { staffUsers, userDetails } from '@/src/db/schema'
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { ApiError } from '../lib/api-error';
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
user?: {
|
user?: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { ApiError } from '../lib/api-error';
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
|
|
||||||
// Extend the Request interface to include user property
|
// Extend the Request interface to include user property
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue