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