Compare commits

..

No commits in common. "main" and "before-notif" have entirely different histories.

1042 changed files with 20156 additions and 629897 deletions

View file

@ -1,8 +0,0 @@
**/node_modules
**/dist
apps/users-ui/app
apps/users-ui/src
apps/admin-ui/app
apps/users-ui/src
**/package-lock.json

View file

@ -1,8 +0,0 @@
> 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.

View file

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

View file

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

2
.gitignore vendored
View file

@ -7,9 +7,7 @@ yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
*.apk
**/.wrangler/*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View file

@ -1,6 +1,7 @@
# Agent Instructions for Meat Farmer Monorepo
## Important instructions
- Don't try to build the code or run or compile it. Just make changes and leave the rest for the user.
- Don't run any drizzle migrations. User will handle it.
## Code Style Guidelines
@ -47,4 +48,6 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
- Database: Drizzle ORM with PostgreSQL
## 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

View file

@ -1,4 +0,0 @@
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
- remove slots from products cache
- remove redundant product details like name, description etc from the slots api

View file

@ -1,36 +1,32 @@
# Optimized Dockerfile for backend and fallback-ui services (project root)
# 1. ---- Base Bun image
FROM oven/bun:1.3.10 AS base
# 1. ---- Base Node image
FROM node:20-slim AS base
WORKDIR /app
# 2. ---- Pruner ----
FROM base AS pruner
WORKDIR /app
# Copy config files first for better caching
COPY package.json turbo.json ./
COPY package.json package-lock.json turbo.json ./
COPY apps/backend/package.json ./apps/backend/
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
COPY packages/shared/ ./packages/shared
COPY packages/ui/package.json ./packages/ui/
RUN bun install -g turbo
RUN npm install -g turbo
COPY . .
RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker
# RUN find . -path "./node_modules" -prune -o -print
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
# 3. ---- Builder ----
FROM base AS builder
WORKDIR /app
# Copy package files first to cache bun install
# Copy package files first to cache npm install
COPY --from=pruner /app/out/json/ .
#COPY --from=pruner /app/out/bun.lock ./bun.lock
#RUN cat ./bun.lock
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
COPY --from=pruner /app/turbo.json .
RUN bun install
RUN npm ci
# Copy source code after dependencies are installed
COPY --from=pruner /app/out/full/ .
RUN bunx turbo run build --filter=fallback-ui... --filter=backend...
RUN find . -path "./node_modules" -prune -o -print
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
# 4. ---- Runner ----
FROM base AS runner
@ -38,15 +34,12 @@ WORKDIR /app
ENV NODE_ENV=production
# Copy package files and install production deps
COPY --from=pruner /app/out/json/ .
#COPY --from=pruner /app/out/bun.lock ./bun.lock
RUN bun install --production
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
RUN npm ci --production --omit=dev
# Copy built applications
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
COPY --from=builder /app/packages/shared ./packages/shared
# RUN ls -R
RUN find . -path "./node_modules" -prune -o -print
EXPOSE 4000
CMD ["bun", "apps/backend/dist/apps/backend/index.js"]
RUN npm i -g bun
CMD ["bun", "apps/backend/dist/index.js"]
# CMD ["node", "apps/backend/dist/index.js"]

View file

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

View file

@ -1,83 +0,0 @@
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
'$0': 'jest',
config: 'e2e/jest.config.js'
},
jest: {
setupTimeout: 120000
}
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YOUR_APP.app',
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/YOUR_APP.app',
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Release -sdk iphonesimulator -derivedDataPath ios/build'
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
reversePorts: [
8081
]
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release'
}
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 15'
}
},
attached: {
type: 'android.attached',
device: {
adbName: '.*'
}
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_3a_API_30_x86'
}
}
},
configurations: {
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug'
},
'ios.sim.release': {
device: 'simulator',
app: 'ios.release'
},
'android.att.debug': {
device: 'attached',
app: 'android.debug'
},
'android.att.release': {
device: 'attached',
app: 'android.release'
},
'android.emu.debug': {
device: 'emulator',
app: 'android.debug'
},
'android.emu.release': {
device: 'emulator',
app: 'android.release'
}
}
};

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

View file

@ -63,21 +63,7 @@
"backgroundColor": "#fff0f6"
},
"edgeToEdgeEnabled": true,
"package": "in.freshyo.adminui",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "ui.freshyo.in",
"pathPrefix": "/manage-orders/order-details"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
"package": "in.freshyo.adminui"
},
"web": {
"bundler": "metro",

View file

@ -114,34 +114,20 @@ 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="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} />
)}
/>
<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} />
)}
/>
</DrawerContentScrollView>
);
}
@ -226,11 +212,11 @@ export default function Layout() {
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
<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="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>
<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>
);
}

View file

@ -0,0 +1,108 @@
import React, { useState } from 'react'
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
import { BottomDialog , tw } from 'common-ui'
import { trpc } from '@/src/trpc-client'
import AddressZoneForm from '@/components/AddressZoneForm'
import AddressPlaceForm from '@/components/AddressPlaceForm'
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
const AddressManagement: React.FC = () => {
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
const createZone = trpc.admin.address.createZone.useMutation({
onSuccess: () => {
refetchZones()
setDialogOpen(false)
},
})
const createArea = trpc.admin.address.createArea.useMutation({
onSuccess: () => {
refetchAreas()
setDialogOpen(false)
},
})
const handleAddZone = () => {
setDialogType('zone')
setDialogOpen(true)
}
const handleAddPlace = () => {
setDialogType('place')
setDialogOpen(true)
}
const toggleZone = (zoneId: number) => {
setExpandedZones(prev => {
const newSet = new Set(prev)
if (newSet.has(zoneId)) {
newSet.delete(zoneId)
} else {
newSet.add(zoneId)
}
return newSet
})
}
const groupedAreas = areas?.reduce((acc, area) => {
if (area.zoneId) {
if (!acc[area.zoneId]) acc[area.zoneId] = []
acc[area.zoneId].push(area)
}
return acc
}, {} as Record<number, typeof areas[0][]>) || {}
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
return (
<View style={tw`flex-1 bg-white`}>
<View style={tw`flex-row justify-between p-4`}>
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
<Text style={tw`text-white`}>Add Zone</Text>
</TouchableOpacity>
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
<Text style={tw`text-white`}>Add Place</Text>
</TouchableOpacity>
</View>
<ScrollView style={tw`flex-1 p-4`}>
{zones?.map(zone => (
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
</TouchableOpacity>
{expandedZones.has(zone.id) && (
<View style={tw`p-3`}>
{groupedAreas[zone.id]?.map(area => (
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
</View>
)}
</View>
))}
<View style={tw`mt-6`}>
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
{unzonedAreas.map(area => (
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
))}
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
</View>
</ScrollView>
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
</BottomDialog>
</View>
)
}
export default AddressManagement

View file

@ -1,58 +1,25 @@
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 React, { useState } from "react";
import { View, Text, TouchableOpacity, Alert } from "react-native";
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, usePagination, ImageViewerURI } from "common-ui";
import { trpc } from "@/src/trpc-client";
export default function Complaints() {
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,
}
);
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();
useMarkDataFetchers(() => {
refetch();
});
const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
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 complaints = data?.complaints || [];
const totalCount = data?.totalCount || 0;
const handleMarkResolved = (id: number) => {
setSelectedComplaintId(id);
@ -83,158 +50,109 @@ export default function Complaints() {
);
};
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 (isLoading) {
return (
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-gray-600`}>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>
);
}
if (error) {
return (
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-red-600`}>Error loading complaints</MyText>
</View>
);
}
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>
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>
<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`}
/>
))}
{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>
</View>
</View>
)}
<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 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>
)}
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>
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>
);
}

View file

@ -9,20 +9,6 @@ export default function CreateCoupon() {
const router = useRouter();
const createCoupon = trpc.admin.coupon.create.useMutation();
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation();
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const handleCreateCoupon = (values: any) => {
console.log('Form values:', values); // Debug log
@ -41,9 +27,7 @@ export default function CreateCoupon() {
if (isLoading) return; // Prevent double submission
mutation.mutate(payload, {
onSuccess: async () => {
await refetchCoupons()
await refetchReservedCoupons()
onSuccess: () => {
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
{ text: 'OK', onPress: () => router.back() }
]);

View file

@ -12,21 +12,7 @@ export default function EditCoupon() {
const { id } = useLocalSearchParams();
const couponId = parseInt(id as string);
const { data: coupon, isLoading, refetch } = trpc.admin.coupon.getById.useQuery({ id: couponId });
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { data: coupon, isLoading } = trpc.admin.coupon.getById.useQuery({ id: couponId });
const updateCoupon = trpc.admin.coupon.update.useMutation();
const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
@ -38,10 +24,7 @@ export default function EditCoupon() {
delete updates.targetUsers;
updateCoupon.mutate({ id: couponId, updates }, {
onSuccess: async () => {
await refetch()
await refetchCoupons()
await refetchReservedCoupons()
onSuccess: () => {
Alert.alert('Success', 'Coupon updated successfully', [
{ text: 'OK', onPress: () => router.back() }
]);

View file

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

View file

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

View file

@ -6,7 +6,14 @@ import { trpc } from '../../../src/trpc-client';
import { useRouter } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
type ConstantFormData = Record<string, any>
interface ConstantFormData {
constants: ConstantItem[];
}
interface ConstantItem {
key: string;
value: any;
}
const CONST_LABELS: Record<string, string> = {
minRegularOrderValue: 'Minimum Regular Order Value',
@ -24,51 +31,27 @@ 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',
};
const CONST_VISIBILITY: Record<string, boolean> = {
minRegularOrderValue: true,
freeDeliveryThreshold: true,
deliveryCharge: true,
flashFreeDeliveryThreshold: true,
flashDeliveryCharge: true,
platformFeePercent: true,
taxRate: false,
minOrderAmountForCoupon: true,
maxCouponDiscount: false,
flashDeliverySlotId: true,
readableOrderId: false,
versionNum: true,
playStoreUrl: true,
appStoreUrl: true,
popularItems: true,
allItemsOrder: true,
isFlashDeliveryEnabled: true,
supportMobile: true,
supportEmail: true,
tester: false,
};
interface ConstantInputProps {
constantKey: string;
value: any;
constant: ConstantItem;
setFieldValue: (field: string, value: any) => void;
index: number;
router: any;
}
const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFieldValue, router }) => {
const fieldName = constantKey
const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue, index, router }) => {
const fieldName = `constants.${index}.value`;
// Special handling for popularItems - show navigation button instead of input
if (constantKey === 'popularItems') {
if (constant.key === 'popularItems') {
return (
<View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
{CONST_LABELS[constantKey] || constantKey}
{CONST_LABELS[constant.key] || constant.key}
</MyText>
<MyTouchableOpacity
onPress={() => router.push('/(drawer)/customize-app/popular-items')}
@ -76,7 +59,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFi
>
<MaterialIcons name="edit" size={20} color="#3b82f6" style={tw`mr-2`} />
<MyText style={tw`text-blue-700 font-medium`}>
Manage Popular Items ({Array.isArray(value) ? value.length : 0} items)
Manage Popular Items ({Array.isArray(constant.value) ? constant.value.length : 0} items)
</MyText>
<MaterialIcons name="chevron-right" size={20} color="#3b82f6" style={tw`ml-2`} />
</MyTouchableOpacity>
@ -84,43 +67,21 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFi
);
}
// Special handling for allItemsOrder - show navigation button instead of input
if (constantKey === 'allItemsOrder') {
return (
<View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
{CONST_LABELS[constantKey] || constantKey}
</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(value) ? 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 value === 'boolean') {
if (typeof constant.value === 'boolean') {
return (
<View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
{CONST_LABELS[constantKey] || constantKey}
{CONST_LABELS[constant.key] || constant.key}
</MyText>
<View style={tw`flex-row items-center`}>
<Checkbox
checked={value}
onPress={() => setFieldValue(fieldName, !value)}
checked={constant.value}
onPress={() => setFieldValue(fieldName, !constant.value)}
size={28}
/>
<MyText style={tw`ml-3 text-gray-700`}>
{value ? 'Enabled' : 'Disabled'}
{constant.value ? 'Enabled' : 'Disabled'}
</MyText>
</View>
</View>
@ -128,11 +89,11 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFi
}
// Handle different value types
if (typeof value === 'number') {
if (typeof constant.value === 'number') {
return (
<MyTextInput
topLabel={CONST_LABELS[constantKey] || constantKey}
value={value.toString()}
topLabel={CONST_LABELS[constant.key] || constant.key}
value={constant.value.toString()}
onChangeText={(value) => {
const numValue = parseFloat(value);
setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue);
@ -143,11 +104,11 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFi
);
}
if (Array.isArray(value)) {
if (Array.isArray(constant.value)) {
return (
<MyTextInput
topLabel={CONST_LABELS[constantKey] || constantKey}
value={value.join(', ')}
topLabel={CONST_LABELS[constant.key] || constant.key}
value={constant.value.join(', ')}
onChangeText={(value) => {
const arrayValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
setFieldValue(fieldName, arrayValue);
@ -160,12 +121,9 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFi
// Default to string
return (
<MyTextInput
topLabel={CONST_LABELS[constantKey] || constantKey}
// value={value === null || value === undefined ? '' : String(value)}
value={value}
onChangeText={(value) => {
setFieldValue(fieldName, value)
}}
topLabel={CONST_LABELS[constant.key] || constant.key}
value={String(constant.value)}
onChangeText={(value) => setFieldValue(fieldName, value)}
placeholder="Enter value"
/>
);
@ -176,16 +134,12 @@ 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 = (constants || []).filter((constant) => {
const nextValue = values[constant.key]
return JSON.stringify(nextValue) !== JSON.stringify(constant.value)
}).map((constant) => ({
key: constant.key,
value: values[constant.key],
}))
const changedConstants = values.constants.filter((constant, index) => {
const original = constants?.[index];
return original && JSON.stringify(constant.value) !== JSON.stringify(original.value);
});
if (changedConstants.length === 0) {
Alert.alert('No Changes', 'No constants were modified.');
@ -223,10 +177,9 @@ export default function CustomizeApp() {
);
}
const initialValues: ConstantFormData = constants.reduce((acc, constant) => {
acc[constant.key] = constant.value ?? ''
return acc
}, {} as ConstantFormData)
const initialValues: ConstantFormData = {
constants: constants.map(c => ({ key: c.key, value: c.value ?? '' } as ConstantItem)),
};
@ -241,22 +194,11 @@ export default function CustomizeApp() {
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
{({ handleSubmit, values, setFieldValue }) => (
<View>
{constants.map((constant) => {
if (!CONST_VISIBILITY[constant.key]) {
return null
}
return (
<View key={constant.key} style={tw`mb-4`}>
<ConstantInput
constantKey={constant.key}
value={values[constant.key]}
setFieldValue={setFieldValue}
router={router}
/>
</View>
)
})}
{values.constants.map((constant, index) => (
<View key={constant.key} style={tw`mb-4`}>
<ConstantInput constant={constant} setFieldValue={setFieldValue} index={index} router={router} />
</View>
))}
<MyTouchableOpacity
onPress={() => handleSubmit()}

View file

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

View file

@ -21,9 +21,6 @@ export default function CreateBanner() {
};
const createBannerMutation = trpc.admin.banner.createBanner.useMutation();
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
enabled: false,
});
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
if (!imageUrl) {
@ -42,7 +39,6 @@ export default function CreateBanner() {
redirectUrl: values.redirectUrl || undefined,
});
await refetchBanners()
Alert.alert('Success', 'Banner created successfully', [
{
text: 'OK',

View file

@ -31,9 +31,6 @@ export default function EditBanner() {
const {data: bannerData } = trpc.admin.banner.getBanner.useQuery({
id: parseInt(bannerId)
});
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
enabled: false,
});
const [banner, setBanner] = useState<typeof bannerData>(undefined);
@ -103,7 +100,6 @@ export default function EditBanner() {
redirectUrl: values.redirectUrl || undefined,
});
await refetchBanners()
Alert.alert('Success', 'Banner updated successfully', [
{
text: 'OK',

View file

@ -13,12 +13,11 @@ interface MenuItem {
icon: string;
description?: string;
route: string;
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings' | 'users';
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
iconColor?: string;
iconBg?: string;
badgeCount?: number;
onPress?: () => void;
testID?: string;
}
interface MenuItemComponentProps {
@ -74,7 +73,7 @@ export default function Dashboard() {
const menuItems: MenuItem[] = [
{
title: 'Manage Orderss',
title: 'Manage Orders',
icon: 'shopping-bag',
description: 'View and manage customer orders',
route: '/(drawer)/manage-orders',
@ -101,7 +100,6 @@ export default function Dashboard() {
category: 'quick',
iconColor: '#06B6D4',
iconBg: '#CFFAFE',
testID: 'delivery-slots-menu-item',
},
{
title: 'Add Product',
@ -176,34 +174,25 @@ export default function Dashboard() {
iconColor: '#F97316',
iconBg: '#FFEDD5',
},
{
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',
},
];
{
title: 'Address Management',
icon: 'location-on',
description: 'Manage service areas',
route: '/(drawer)/address-management',
category: 'settings',
iconColor: '#EAB308',
iconBg: '#FEF9C3',
},
{
title: 'App Constants',
icon: 'settings-applications',
description: 'Customize app settings',
route: '/(drawer)/customize-app',
category: 'settings',
iconColor: '#7C3AED',
iconBg: '#F3E8FF',
},
];
const quickActions = menuItems.filter(item => item.category === 'quick');
@ -211,7 +200,6 @@ 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' },
];
@ -238,8 +226,6 @@ export default function Dashboard() {
{quickActions.map((item) => (
<Pressable
key={`quick-${item.route}`}
testID={item.testID}
accessibilityLabel={item.testID}
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
style={({ pressed }) => [
tw`bg-white rounded-xl p-3 shadow-sm border border-gray-100 items-center`,

View file

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

View file

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

View file

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

View file

@ -56,11 +56,7 @@ 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: {
@ -86,7 +82,6 @@ interface OrderType {
discountAmount?: number;
adminNotes?: string | null;
userNotes?: string | null;
userNegativityScore?: number;
}
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
@ -105,12 +100,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
const handleOrderPress = () => {
router.push(`/manage-orders/order-details/${order.orderId}` as any);
router.push(`/order-details/${order.orderId}` as any);
};
const handleMenuOption = () => {
setMenuOpen(false);
router.push(`/manage-orders/order-details/${order.orderId}` as any);
router.push(`/order-details/${order.orderId}` as any);
};
const handleMarkPackaged = (isPackaged: boolean) => {
@ -173,8 +168,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' : (order.userNegativityScore && order.userNegativityScore > 0 ? 'text-yellow-600' : 'text-gray-900')}`}>
{order.customerName || order.customerMobile || 'Unknown Customer'}
<MyText style={tw`font-bold text-lg mr-2 ${order.status === 'cancelled' ? 'text-red-600' : 'text-gray-900'}`}>
{order.customerName || '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>
@ -191,12 +186,6 @@ 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>
@ -370,11 +359,11 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
isDelivered: order.isDelivered,
isFlashDelivery: order.isFlashDelivery,
address: order.address,
addressId: order.addressId,
addressId: 0,
adminNotes: order.adminNotes,
userNotes: order.userNotes,
latitude: order.latitude,
longitude: order.longitude,
latitude: null,
longitude: null,
status: order.status,
}}
onViewDetails={handleMenuOption}
@ -388,7 +377,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
setMenuOpen(false);
setCancelDialogOpen(true);
}}
onAttachLocation={() => refetch()}
onAttachLocation={() => {}}
onWhatsApp={() => {}}
onDial={() => {}}
/>

View file

@ -13,7 +13,6 @@ 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 }>();
@ -63,8 +62,7 @@ export default function OrderDetails() {
onSuccess: (result) => {
Alert.alert(
"Success",
`Refund initiated successfully!\n\nAmount: `
// `Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
`Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
);
setInitiateRefundDialogOpen(false);
},
@ -84,16 +82,6 @@ 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`}>
@ -279,23 +267,6 @@ 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
@ -377,17 +348,12 @@ export default function OrderDetails() {
)}
{/* Customer Details */}
<TouchableOpacity
onPress={() => order.userId && router.push(`/(drawer)/user-management/${order.userId}`)}
activeOpacity={0.7}
<View
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
>
<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>
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
Customer Details
</MyText>
<View style={tw`flex-row items-center mb-4`}>
<View
@ -397,7 +363,7 @@ export default function OrderDetails() {
</View>
<View>
<MyText style={tw`text-sm font-bold text-gray-900`}>
{order.customerName || 'Unknown User'}
{order.customerName}
</MyText>
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
</View>
@ -438,7 +404,7 @@ export default function OrderDetails() {
</View>
</View>
</View>
</TouchableOpacity>
</View>
{/* Order Items */}
<View
@ -520,40 +486,6 @@ 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`}>
@ -612,14 +544,6 @@ export default function OrderDetails() {
</View>
)}
{/* User Incidents Section */}
{order.userId && (
<UserIncidentsView
userId={order.userId}
orderId={order.id}
/>
)}
{/* Coupon Applied Section */}
{order.couponCode && (
<View

View file

@ -1,10 +1,10 @@
import React from 'react';
import { View, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import { AppContainer, MyText, tw } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import { useCreateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData {
tagName: string;
@ -15,51 +15,50 @@ interface TagFormData {
export default function AddTag() {
const router = useRouter();
const createTag = trpc.admin.product.createProductTag.useMutation();
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
});
const { mutate: createTag, isPending: isCreating } = useCreateTag();
const { data: storesData } = trpc.admin.store.getStores.useQuery();
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
try {
let imageUrl: string | null | undefined;
let uploadUrls: string[] = []
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
const formData = new FormData();
const newImage = images.find((image) => image.mimeType !== null)
if (newImage) {
const response = await fetch(newImage.imgUrl)
const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
}
await createTag.mutateAsync({
tagName: values.tagName,
tagDescription: values.tagDescription || undefined,
imageUrl,
isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores,
uploadUrls,
})
await refetchTags()
Alert.alert('Success', 'Tag created successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
])
} catch (error: any) {
const errorMessage = error.message || 'Failed to create tag'
Alert.alert('Error', errorMessage)
// Add text fields
formData.append('tagName', values.tagName);
if (values.tagDescription) {
formData.append('tagDescription', values.tagDescription);
}
}
formData.append('isDashboardTag', values.isDashboardTag.toString());
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : 'image/jpeg';
formData.append('image', {
uri: image.uri,
name: filename,
type,
} as any);
}
createTag(formData, {
onSuccess: (data) => {
Alert.alert('Success', 'Tag created successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
]);
},
onError: (error: any) => {
const errorMessage = error.message || 'Failed to create tag';
Alert.alert('Error', errorMessage);
},
});
};
const initialValues: TagFormData = {
tagName: '',
@ -77,8 +76,8 @@ export default function AddTag() {
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={createTag.isPending || isUploading}
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
isLoading={isCreating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/>
</View>
</AppContainer>

View file

@ -1,10 +1,10 @@
import React from 'react';
import { View, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import { AppContainer, MyText, tw } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData {
tagName: string;
@ -19,60 +19,53 @@ export default function EditTag() {
const { tagId } = useLocalSearchParams<{ tagId: string }>();
const tagIdNum = tagId ? parseInt(tagId) : null;
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
{ id: tagIdNum || 0 },
{ enabled: !!tagIdNum }
)
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
});
const updateTag = trpc.admin.product.updateProductTag.useMutation();
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
const { data: storesData } = trpc.admin.store.getStores.useQuery();
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
if (!tagIdNum) return;
try {
let imageUrl: string | null | undefined
let uploadUrls: string[] = []
const formData = new FormData();
const newImage = images.find((image) => image.mimeType !== null)
if (newImage) {
const response = await fetch(newImage.imgUrl)
const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
} else if (removedExisting) {
imageUrl = null
}
await updateTag.mutateAsync({
id: tagIdNum,
tagName: values.tagName,
tagDescription: values.tagDescription || undefined,
imageUrl,
isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores,
uploadUrls,
})
await refetchTags()
Alert.alert('Success', 'Tag updated successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
])
} catch (error: any) {
const errorMessage = error.message || 'Failed to update tag'
Alert.alert('Error', errorMessage)
// Add text fields
formData.append('tagName', values.tagName);
if (values.tagDescription) {
formData.append('tagDescription', values.tagDescription);
}
}
formData.append('isDashboardTag', values.isDashboardTag.toString());
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : 'image/jpeg';
formData.append('image', {
uri: image.uri,
name: filename,
type,
} as any);
}
updateTag({ id: tagIdNum, formData }, {
onSuccess: (data) => {
Alert.alert('Success', 'Tag updated successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
]);
},
onError: (error: any) => {
const errorMessage = error.message || 'Failed to update tag';
Alert.alert('Error', errorMessage);
},
});
};
if (isLoadingTag) {
return (
@ -99,7 +92,7 @@ export default function EditTag() {
tagName: tag.tagName,
tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag,
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
relatedStores: tag.relatedStores || [],
existingImageUrl: tag.imageUrl || undefined,
};
@ -113,8 +106,8 @@ export default function EditTag() {
initialValues={initialValues}
existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit}
isLoading={updateTag.isPending || isUploading}
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
isLoading={isUpdating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/>
</View>
</AppContainer>

View file

@ -5,20 +5,10 @@ import { useRouter } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { TagMenu } from '@/src/components/TagMenu';
import { trpc } from '@/src/trpc-client';
interface TagItemData {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores?: unknown;
createdAt: string | Date;
}
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
interface TagItemProps {
item: TagItemData;
item: Tag;
onDeleteSuccess: () => void;
}
@ -70,7 +60,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
export default function ProductTags() {
const router = useRouter();
const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery();
const { data: tagsData, isLoading, error, refetch } = useGetTags();
const [refreshing, setRefreshing] = useState(false);
const tags = tagsData?.tags || [];

View file

@ -1,56 +1,62 @@
import React from 'react';
import { Alert } from 'react-native';
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
import { AppContainer } from 'common-ui';
import ProductForm from '@/src/components/ProductForm';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
export default function AddProduct() {
const createProduct = trpc.admin.product.createProduct.useMutation();
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
enabled: false,
});
const { upload, isUploading } = useUploadToObjectStorage();
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => {
try {
let uploadUrls: string[] = [];
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
const payload: CreateProductPayload = {
name: values.name,
shortDescription: values.shortDescription,
longDescription: values.longDescription,
unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId),
price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1,
productQuantity: values.productQuantity || 1,
};
if (images.length > 0) {
const blobs = await Promise.all(
images.map(async (img) => {
const response = await fetch(img.url);
const blob = await response.blob();
return { blob, mimeType: img.mimeType || 'image/jpeg' };
})
);
const result = await upload({ images: blobs, contextString: 'product_info' });
uploadUrls = result.presignedUrls;
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
formData.append(key, value as string);
}
});
await createProduct.mutateAsync({
name: values.name,
shortDescription: values.shortDescription,
longDescription: values.longDescription,
unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId),
price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1,
productQuantity: values.productQuantity || 1,
isSuspended: values.isSuspended || false,
isFlashAvailable: values.isFlashAvailable || false,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
uploadUrls,
tagIds: values.tagIds || [],
// Append tag IDs
if (values.tagIds && values.tagIds.length > 0) {
values.tagIds.forEach((tagId: number) => {
formData.append('tagIds', tagId.toString());
});
await refetchProducts();
Alert.alert('Success', 'Product created successfully!');
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to create product');
}
// Append images
if (images) {
images.forEach((image, index) => {
if (image.uri) {
formData.append('images', {
uri: image.uri,
name: `image-${index}.jpg`,
// type: 'image/jpeg',
type: image.mimeType as any,
} as any);
}
});
}
createProduct(formData, {
onSuccess: (data) => {
Alert.alert('Success', 'Product created successfully!');
// Reset form or navigate
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to create product');
},
});
};
const initialValues = {
@ -75,7 +81,8 @@ export default function AddProduct() {
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={createProduct.isPending || isUploading}
isLoading={isCreating}
existingImages={[]}
/>
</AppContainer>
);

View file

@ -6,7 +6,6 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
import { Formik } from 'formik';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
@ -24,9 +23,10 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const [adminResponse, setAdminResponse] = useState('');
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
const respondToReview = trpc.admin.product.respondToReview.useMutation();
const { upload } = useUploadToObjectStorage();
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
@ -62,16 +62,37 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const handleSubmit = async (adminResponse: string) => {
try {
const { keys, presignedUrls } = await upload({
images: selectedImages,
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
contextString: 'review',
mimeTypes,
});
const keys = generatedUrls.map(url => {
const u = new URL(url);
const rawKey = u.pathname.replace(/^\/+/, "");
const decodedKey = decodeURIComponent(rawKey);
const parts = decodedKey.split('/');
parts.shift();
return parts.join('/');
});
setUploadUrls(generatedUrls);
for (let i = 0; i < generatedUrls.length; i++) {
const uploadUrl = generatedUrls[i];
const { blob, mimeType } = selectedImages[i];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
});
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
await respondToReview.mutateAsync({
reviewId,
adminResponse,
adminResponseImages: keys,
uploadUrls: presignedUrls,
uploadUrls: generatedUrls,
});
Alert.alert('Success', 'Response submitted');
@ -79,7 +100,9 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
setAdminResponse('');
setSelectedImages([]);
setDisplayImages([]);
setUploadUrls([]);
} catch (error:any) {
Alert.alert('Error', error.message || 'Failed to submit response.');
}
};

View file

@ -1,74 +1,96 @@
import React, { useRef } from 'react';
import { View, Alert } from 'react-native';
import { View, Text, Alert } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
import { useUpdateProduct } from '@/src/api-hooks/product.api';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
export default function EditProduct() {
const { id } = useLocalSearchParams();
const productId = Number(id);
const productFormRef = useRef<ProductFormRef>(null);
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
{ id: productId },
{ enabled: !!productId }
);
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
enabled: false,
});
const updateProduct = trpc.admin.product.updateProduct.useMutation();
const { upload, isUploading } = useUploadToObjectStorage();
//
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
useManualRefresh(() => refetch());
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => {
try {
// New images have mimeType !== null, existing images have mimeType === null
const newImages = images.filter(img => img.mimeType !== null);
let uploadUrls: string[] = [];
if (newImages.length > 0) {
const blobs = await Promise.all(
newImages.map(async (img) => {
const response = await fetch(img.url);
const blob = await response.blob();
return { blob, mimeType: img.mimeType || 'image/jpeg' };
})
);
const result = await upload({ images: blobs, contextString: 'product_info' });
uploadUrls = result.presignedUrls;
}
await updateProduct.mutateAsync({
id: productId,
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
const payload = {
name: values.name,
shortDescription: values.shortDescription,
longDescription: values.longDescription,
unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId),
price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1,
productQuantity: values.productQuantity || 1,
isSuspended: values.isSuspended || false,
isFlashAvailable: values.isFlashAvailable || false,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null,
uploadUrls,
imagesToDelete,
tagIds: values.tagIds || [],
});
price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1,
productQuantity: values.productQuantity || 1,
deals: values.deals?.filter((deal: any) =>
deal.quantity && deal.price && deal.validTill
).map((deal: any) => ({
quantity: parseInt(deal.quantity),
price: parseFloat(deal.price),
validTill: deal.validTill instanceof Date
? deal.validTill.toISOString().split('T')[0]
: deal.validTill, // Convert Date to YYYY-MM-DD string
})),
tagIds: values.tagIds,
};
await refetch();
await refetchProducts();
Alert.alert('Success', 'Product updated successfully!');
productFormRef.current?.clearImages();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to update product');
console.log({payload})
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (key === 'deals' && Array.isArray(value)) {
formData.append(key, JSON.stringify(value));
} else if (key === 'tagIds' && Array.isArray(value)) {
value.forEach(tagId => {
formData.append('tagIds', tagId.toString());
});
} else if (value !== undefined && value !== null) {
formData.append(key, value as string);
}
});
// Add new images
if (newImages && newImages.length > 0) {
newImages.forEach((image, index) => {
if (image.uri) {
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
formData.append('images', {
uri: image.uri,
name: fileName,
type: 'image/jpeg',
} as any);
}
});
}
// Add images to delete
if (imagesToDelete && imagesToDelete.length > 0) {
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
}
updateProduct(
{ id: productId, formData },
{
onSuccess: (data) => {
Alert.alert('Success', 'Product updated successfully!');
// Clear newly added images after successful update
productFormRef.current?.clearImages();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to update product');
},
}
);
};
if (isFetching) {
@ -91,13 +113,7 @@ export default function EditProduct() {
);
}
const productData = product.product;
const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({
imgUrl: url,
mimeType: null,
}));
const existingImageKeys = productData.imageKeys || [];
const productData = product.product; // The API returns { product: Product }
const initialValues = {
name: productData.name,
@ -110,7 +126,7 @@ export default function EditProduct() {
deals: productData.deals?.map(deal => ({
quantity: deal.quantity,
price: deal.price,
validTill: deal.validTill ? new Date(deal.validTill) : null,
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
})) || [{ quantity: '', price: '', validTill: null }],
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
isSuspended: productData.isSuspended || false,
@ -126,9 +142,8 @@ export default function EditProduct() {
mode="edit"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={updateProduct.isPending || isUploading}
existingImages={existingImages}
existingImageKeys={existingImageKeys}
isLoading={isUpdating}
existingImages={productData.images || []}
/>
</AppContainer>
);

View file

@ -6,7 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import type { AdminProduct } from '@packages/shared';
import { Product } from '@/src/api-hooks/product.api';
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
@ -54,7 +54,7 @@ export default function Products() {
// const handleToggleStock = (product: any) => {
const handleToggleStock = (product: Pick<AdminProduct, 'id' | 'name' | 'isOutOfStock'>) => {
const handleToggleStock = (product: Pick<Product, 'id' | 'name' | 'isOutOfStock'>) => {
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
Alert.alert(
'Update Stock Status',

View file

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

View file

@ -1,248 +0,0 @@
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';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface User {
id: number;
name: string | null;
mobile: string | null;
isEligibleForNotif: boolean;
}
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,
});
const { uploadSingle } = useUploadToObjectStorage();
// 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 { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
imageUrl = key;
}
// Send notification
await sendNotification.mutateAsync({
userIds: selectedUserIds,
title: title.trim(),
text: message.trim(),
imageUrl,
});
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to send notification');
}
};
const getDisplayText = () => {
if (selectedUserIds.length === 0) return 'All Users';
if (selectedUserIds.length === 1) {
const user = eligibleUsers.find((u: User) => u.id === selectedUserIds[0]);
return user ? `${user.mobile}${user.name ? ` - ${user.name}` : ''}` : '1 user selected';
}
return `${selectedUserIds.length} users selected`;
};
if (isLoadingUsers) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<ActivityIndicator size="large" color="#3b82f6" />
<MyText style={tw`text-gray-500 mt-4`}>Loading users...</MyText>
</View>
</AppContainer>
);
}
return (
<AppContainer>
<View style={tw`flex-1 bg-gray-50`}>
{/* Header */}
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
<TouchableOpacity
onPress={() => router.back()}
style={tw`p-2 -ml-4`}
>
<MaterialIcons name="chevron-left" size={24} color="#374151" />
</TouchableOpacity>
<MyText style={tw`text-xl font-bold text-gray-900 ml-2`}>Send Notifications</MyText>
</View>
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`p-4`}>
{/* Title Input */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Title</MyText>
<MyTextInput
value={title}
onChangeText={setTitle}
placeholder="Enter notification title..."
style={tw`text-gray-900`}
/>
</View>
{/* Message Input */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Message</MyText>
<MyTextInput
value={message}
onChangeText={setMessage}
placeholder="Enter notification message..."
multiline
numberOfLines={4}
style={tw`text-gray-900`}
/>
</View>
{/* Image Upload - Hidden for now */}
{/* <View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Image (Optional)</MyText>
<ImageUploader
images={displayImage ? [displayImage] : []}
existingImageUrls={[]}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
/>
</View> */}
{/* User Selection */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Select Users (Optional)</MyText>
<BottomDropdown
label="Select Users"
value={selectedUserIds}
options={dropdownOptions}
onValueChange={(value) => setSelectedUserIds(value as number[])}
multiple={true}
placeholder="Select users"
onSearch={(query) => setSearchQuery(query)}
/>
<MyText style={tw`text-gray-500 text-sm mt-2`}>
{getDisplayText()}
</MyText>
<MyText style={tw`text-blue-600 text-xs mt-1`}>
Leave empty to send to all users
</MyText>
</View>
{/* Submit Button */}
<TouchableOpacity
onPress={handleSend}
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0}
style={tw`${
sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
? 'bg-gray-300'
: 'bg-blue-600'
} rounded-xl py-4 items-center shadow-sm`}
>
<MyText style={tw`text-white font-bold text-base`}>
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
</MyText>
</TouchableOpacity>
</ScrollView>
</View>
</AppContainer>
);
}

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { MaterialCommunityIcons, Entypo } from '@expo/vector-icons';
import { View, TouchableOpacity, FlatList, Alert, ActivityIndicator } from 'react-native';
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity, Checkbox } from 'common-ui';
import { View, TouchableOpacity, FlatList, Alert } from 'react-native';
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import { useRouter } from 'expo-router';
import dayjs from 'dayjs';
@ -12,7 +12,6 @@ interface SlotItemProps {
router: any;
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>;
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetch: () => void;
}
const SlotItemComponent: React.FC<SlotItemProps> = ({
@ -20,7 +19,6 @@ 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) || [];
@ -30,29 +28,6 @@ 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}`)}
@ -80,15 +55,10 @@ 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>
{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
<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
onPress={() => setMenuOpen(true)}
style={tw`ml-2 p-1`}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
@ -98,75 +68,33 @@ 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>
{/* Capacity Toggle */}
{/* 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>
<TouchableOpacity
onPress={handleCapacityToggle}
disabled={updateSlotCapacity.isPending}
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 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);
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>
<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>
</View>
</BottomDialog>
<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`} />
@ -265,7 +193,6 @@ export default function Slots() {
router={router}
setDialogProducts={setDialogProducts}
setDialogOpen={setDialogOpen}
refetch={refetch}
/>
)}
contentContainerStyle={tw`p-4`}
@ -275,8 +202,6 @@ export default function Slots() {
{/* FAB for Add New Slot */}
<MyTouchableOpacity
testID="add-slot-fab"
accessibilityLabel="add-slot-fab"
onPress={() => router.push('/slots/add' as any)}
activeOpacity={0.95}
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}

View file

@ -9,14 +9,10 @@ export default function AddStore() {
const router = useRouter();
const createStoreMutation = trpc.admin.store.createStore.useMutation();
const { refetch: refetchStores } = trpc.admin.store.getStores.useQuery(undefined, {
enabled: false,
});
const handleSubmit = (values: StoreFormData) => {
createStoreMutation.mutate(values, {
onSuccess: async (data) => {
await refetchStores();
onSuccess: (data) => {
Alert.alert('Success', data.message);
router.push('/stores' as any); // Navigate back to stores list
},

View file

@ -0,0 +1,158 @@
import React from 'react';
import { View, TouchableOpacity, Alert } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import dayjs from 'dayjs';
export default function UserDetails() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { data: userData, isLoading, error, refetch } = trpc.admin.staffUser.getUserDetails.useQuery(
{ userId: id ? parseInt(id) : 0 },
{ enabled: !!id }
);
const updateSuspensionMutation = trpc.admin.staffUser.updateUserSuspension.useMutation({
onSuccess: () => {
refetch();
Alert.alert('Success', 'User suspension status updated');
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to update suspension');
},
});
const handleToggleSuspension = () => {
if (!userData) return;
const newStatus = !userData.isSuspended;
updateSuspensionMutation.mutate({
userId: userData.id,
isSuspended: newStatus,
});
};
if (isLoading) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-gray-500`}>Loading user details...</MyText>
</View>
</AppContainer>
);
}
if (error || !userData) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<MaterialIcons name="error-outline" size={48} color="#EF4444" />
<MyText style={tw`text-gray-900 text-xl font-bold mt-4 mb-2`}>
Error
</MyText>
<MyText style={tw`text-gray-500 text-center`}>
{error?.message || "Failed to load user details"}
</MyText>
<TouchableOpacity
onPress={() => router.back()}
style={tw`mt-6 bg-gray-900 px-6 py-3 rounded-xl`}
>
<MyText style={tw`text-white font-bold`}>Go Back</MyText>
</TouchableOpacity>
</View>
</AppContainer>
);
}
const user = userData;
return (
<AppContainer>
<View style={tw`flex-1 bg-gray-50`}>
{/* User Info */}
<View style={tw`p-4`}>
<View style={tw`bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-4`}>
<View style={tw`flex-row items-center mb-6`}>
<View style={tw`w-16 h-16 bg-blue-100 rounded-full items-center justify-center mr-4`}>
<MaterialIcons name="person" size={32} color="#3B82F6" />
</View>
<View>
<MyText style={tw`text-2xl font-bold text-gray-900`}>{user.name || 'n/a'}</MyText>
<MyText style={tw`text-sm text-gray-500`}>User ID: {user.id}</MyText>
</View>
</View>
<View style={tw`space-y-4`}>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="phone" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
<MyText style={tw`text-gray-700`}>{user.mobile}</MyText>
</View>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="email" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
<MyText style={tw`text-gray-700`}>{user.email}</MyText>
</View>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="calendar-today" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
<MyText style={tw`text-gray-700`}>
Added on {dayjs(user.addedOn).format('MMM DD, YYYY')}
</MyText>
</View>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="shopping-cart" size={20} color="#6B7280" style={tw`mr-3 w-5`} />
<MyText style={tw`text-gray-700`}>
{user.lastOrdered
? `Last ordered ${dayjs(user.lastOrdered).format('MMM DD, YYYY')}`
: 'No orders yet'
}
</MyText>
</View>
</View>
</View>
{/* Suspension Status */}
<View style={tw`bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-4`}>
<View style={tw`flex-row items-center justify-between`}>
<View style={tw`flex-row items-center`}>
<MaterialIcons
name={user.isSuspended ? "block" : "check-circle"}
size={24}
color={user.isSuspended ? "#EF4444" : "#10B981"}
style={tw`mr-3`}
/>
<View>
<MyText style={tw`font-semibold text-gray-900`}>
{user.isSuspended ? 'Suspended' : 'Active'}
</MyText>
<MyText style={tw`text-sm text-gray-500`}>
{user.isSuspended ? 'User is suspended' : 'User is active'}
</MyText>
</View>
</View>
<TouchableOpacity
onPress={handleToggleSuspension}
disabled={updateSuspensionMutation.isPending}
style={tw`px-4 py-2 rounded-lg ${
user.isSuspended ? 'bg-green-500' : 'bg-red-500'
} ${updateSuspensionMutation.isPending ? 'opacity-50' : ''}`}
>
<MyText style={tw`text-white font-semibold text-sm`}>
{updateSuspensionMutation.isPending
? 'Updating...'
: user.isSuspended
? 'Revoke Suspension'
: 'Suspend User'
}
</MyText>
</TouchableOpacity>
</View>
</View>
</View>
</View>
</AppContainer>
);
}

View file

@ -1,258 +0,0 @@
import React, { useCallback } from 'react';
import {
View,
TouchableOpacity,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import {
AppContainer,
MyText,
tw,
Checkbox,
} from 'common-ui';
import { trpc } from '@/src/trpc-client';
import { formatDistanceToNow } from 'date-fns';
import dayjs from 'dayjs';
import { UserIncidentsView } from '@/components/UserIncidentsView';
interface Order {
id: number;
readableId: number;
totalAmount: string;
createdAt: string;
status: string;
isFlashDelivery: boolean;
itemCount: number;
}
interface OrderItemProps {
order: Order;
onPress: () => void;
}
const getStatusColor = (status: string) => {
switch (status) {
case 'delivered':
return 'text-green-600 bg-green-50 border-green-100';
case 'cancelled':
return 'text-red-600 bg-red-50 border-red-100';
default:
return 'text-yellow-600 bg-yellow-50 border-yellow-100';
}
};
const OrderItem: React.FC<OrderItemProps> = ({ order, onPress }) => {
const statusStyle = getStatusColor(order.status);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-3 shadow-sm`}
>
<View style={tw`flex-row items-center justify-between mb-3`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`text-lg font-bold text-gray-900`}>
#{order.readableId}
</MyText>
{order.isFlashDelivery && (
<View style={tw`ml-2 px-2 py-0.5 bg-amber-100 rounded-full border border-amber-200`}>
<MyText style={tw`text-[10px] font-black text-amber-700 uppercase`}></MyText>
</View>
)}
</View>
<View style={tw`px-3 py-1 rounded-full border ${statusStyle}`}>
<MyText style={tw`text-xs font-bold uppercase tracking-wider ${statusStyle.split(' ')[0]}`}>
{order.status}
</MyText>
</View>
</View>
<View style={tw`flex-row justify-between items-center`}>
<View>
<MyText style={tw`text-gray-500 text-sm mb-1`}>
{dayjs(order.createdAt).format('MMM DD, YYYY • h:mm A')}
</MyText>
<MyText style={tw`text-gray-400 text-xs`}>
{order.itemCount} {order.itemCount === 1 ? 'item' : 'items'}
</MyText>
</View>
<MyText style={tw`text-xl font-bold text-blue-600`}>
{order.totalAmount}
</MyText>
</View>
<View style={tw`mt-3 pt-3 border-t border-gray-100 flex-row items-center justify-center`}>
<MyText style={tw`text-blue-600 font-medium text-sm`}>
View Order Details
</MyText>
<MaterialIcons name="chevron-right" size={18} color="#3b82f6" />
</View>
</TouchableOpacity>
);
};
export default function UserDetails() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const userId = id ? parseInt(id) : 0;
const { data, isLoading, error, refetch } = trpc.admin.user.getUserDetails.useQuery(
{ userId },
{ enabled: !!userId }
);
const updateSuspension = trpc.admin.user.updateUserSuspension.useMutation({
onSuccess: () => {
refetch();
},
});
const handleOrderPress = useCallback((orderId: number) => {
router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
}, [router]);
const handleSuspensionToggle = useCallback((isSuspended: boolean) => {
updateSuspension.mutate({ userId, isSuspended });
}, [userId, updateSuspension]);
if (isLoading) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<ActivityIndicator size="large" color="#3b82f6" />
<MyText style={tw`text-gray-500 mt-4`}>Loading user details...</MyText>
</View>
</AppContainer>
);
}
if (error || !data) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center p-8`}>
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
<MyText style={tw`text-gray-500 mt-2 text-center`}>
{error?.message || 'Failed to load user details'}
</MyText>
<TouchableOpacity
onPress={() => refetch()}
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
>
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
</TouchableOpacity>
</View>
</AppContainer>
);
}
const { user, orders } = data;
const displayName = user.name || 'Unnamed User';
return (
<AppContainer>
<View style={tw`flex-1 bg-gray-50`}>
{/* Header */}
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
<TouchableOpacity
onPress={() => router.back()}
style={tw`p-2 -ml-4`}
>
<MaterialIcons name="chevron-left" size={24} color="#374151" />
</TouchableOpacity>
<MyText style={tw`text-xl font-bold text-gray-900 ml-2`}>User Details</MyText>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* User Info Card */}
<View style={tw`bg-white p-5 m-4 rounded-2xl shadow-sm border border-gray-100`}>
<View style={tw`flex-row items-center mb-4`}>
<View style={tw`w-12 h-12 bg-blue-50 rounded-full items-center justify-center mr-4`}>
<MaterialIcons name="person" size={24} color="#3B82F6" />
</View>
<View style={tw`flex-1`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`text-gray-900 font-bold text-lg mb-0.5`}>
{user.mobile || 'No Mobile'}
</MyText>
{user.isSuspended && (
<View style={tw`ml-2 px-2 py-0.5 bg-red-100 rounded-full border border-red-200`}>
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Suspended</MyText>
</View>
)}
</View>
<MyText style={tw`text-gray-500`}>
{displayName}
</MyText>
</View>
</View>
<View style={tw`bg-gray-50 p-3 rounded-xl mb-4`}>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="access-time" size={18} color="#6B7280" />
<MyText style={tw`ml-2 text-gray-600`}>
Registered {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</MyText>
</View>
</View>
{/* Suspension Toggle */}
<View style={tw`flex-row items-center justify-between pt-4 border-t border-gray-100`}>
<View style={tw`flex-1`}>
<MyText style={tw`text-gray-900 font-bold text-base mb-1`}>
Suspend User
</MyText>
<MyText style={tw`text-gray-500 text-sm`}>
Prevent user from placing orders
</MyText>
</View>
{updateSuspension.isPending ? (
<ActivityIndicator size="small" color="#3b82f6" />
) : (
<Checkbox
checked={user.isSuspended}
onPress={() => handleSuspensionToggle(!user.isSuspended)}
size={28}
/>
)}
</View>
</View>
{/* User Incidents Section */}
<UserIncidentsView userId={userId} orderId={null} />
{/* Orders Section */}
<View style={tw`px-4 pb-8`}>
<View style={tw`flex-row items-center justify-between mb-4`}>
<MyText style={tw`text-lg font-bold text-gray-900`}>Order History</MyText>
<MyText style={tw`text-gray-500 text-sm`}>
{orders.length} {orders.length === 1 ? 'order' : 'orders'}
</MyText>
</View>
{orders.length === 0 ? (
<View style={tw`bg-white rounded-xl border border-gray-100 p-8 items-center`}>
<MaterialIcons name="shopping-bag" size={48} color="#e5e7eb" />
<MyText style={tw`text-gray-500 mt-4 text-center`}>
No orders yet
</MyText>
</View>
) : (
orders.map((order) => (
<OrderItem
key={order.id}
order={order}
onPress={() => handleOrderPress(order.id)}
/>
))
)}
</View>
</ScrollView>
</View>
</AppContainer>
);
}

View file

@ -1,268 +0,0 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
View,
TouchableOpacity,
Alert,
RefreshControl,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import {
AppContainer,
MyText,
tw,
SearchBar,
MyFlatList,
} from 'common-ui';
import { trpc } from '@/src/trpc-client';
import { formatDistanceToNow } from 'date-fns';
interface User {
id: number;
name: string | null;
mobile: string | null;
createdAt: string;
totalOrders: number;
lastOrderDate: string | null;
isSuspended: boolean;
}
interface UserItemProps {
user: User;
index: number;
onPress: () => void;
}
const UserItem: React.FC<UserItemProps> = ({ user, index, onPress }) => {
const displayName = user.name || 'Unnamed User';
const hasOrders = user.totalOrders > 0;
const lastOrderText = user.lastOrderDate
? formatDistanceToNow(new Date(user.lastOrderDate), { addSuffix: true })
: 'Never';
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-3 shadow-sm`}
>
<View style={tw`flex-row items-center justify-between`}>
{/* Left: Index number */}
<View style={tw`w-8 h-8 rounded-full bg-gray-100 items-center justify-center mr-3`}>
<MyText style={tw`text-gray-600 text-xs font-bold`}>{index + 1}</MyText>
</View>
{/* Middle: User Info */}
<View style={tw`flex-1`}>
{/* Mobile number - primary identifier */}
<View style={tw`flex-row items-center mb-0.5`}>
<MyText style={tw`text-gray-900 font-bold text-base`}>
{user.mobile || 'No Mobile'}
</MyText>
{user.isSuspended && (
<View style={tw`ml-2 px-2 py-0.5 bg-red-100 rounded-full border border-red-200`}>
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Suspended</MyText>
</View>
)}
</View>
{/* Name */}
<MyText style={tw`text-gray-500 text-sm mb-1`}>
{displayName}
</MyText>
{/* Registration date */}
<MyText style={tw`text-gray-400 text-xs`}>
Registered: {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</MyText>
</View>
{/* Right: Order Stats */}
<View style={tw`items-end`}>
{/* Total Orders */}
<View style={tw`flex-row items-center mb-1`}>
<MaterialIcons name="shopping-bag" size={14} color={hasOrders ? '#10B981' : '#9CA3AF'} />
<MyText style={tw`${hasOrders ? 'text-green-600' : 'text-gray-400'} font-bold text-sm ml-1`}>
{user.totalOrders} orders
</MyText>
</View>
{/* Last Order */}
<MyText style={tw`text-gray-400 text-xs`}>
Last: {lastOrderText}
</MyText>
</View>
</View>
</TouchableOpacity>
);
};
interface ListHeaderProps {
searchTerm: string;
onSearchChange: (text: string) => void;
userCount: number;
}
const ListHeader: React.FC<ListHeaderProps> = ({ searchTerm, onSearchChange, userCount }) => (
<View>
{/* Search Bar */}
<View style={tw`px-4 py-3 bg-white border-b border-gray-100`}>
<SearchBar
value={searchTerm}
onChangeText={onSearchChange}
placeholder="Search by mobile number..."
/>
</View>
{/* Stats Summary */}
<View style={tw`px-4 py-2 bg-gray-50`}>
<MyText style={tw`text-gray-500 text-sm`}>
Showing {userCount} users
</MyText>
</View>
</View>
);
const ListFooter: React.FC<{ isFetching: boolean }> = ({ isFetching }) => {
if (!isFetching) return null;
return (
<View style={tw`py-4 items-center`}>
<ActivityIndicator size="small" color="#3b82f6" />
</View>
);
};
export default function UserManagement() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
// Infinite scroll query
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = trpc.admin.user.getAllUsers.useInfiniteQuery(
{
limit: 30,
search: searchTerm || undefined,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
useEffect(() => {
if (data?.pages?.length) {
setHasLoadedOnce(true);
}
}, [data]);
// Flatten all pages and remove duplicates
const users = useMemo(() => {
const allUsers = data?.pages.flatMap((page) => page.users) || [];
// Remove duplicates based on user id
const uniqueUsers = allUsers.filter((user, index, self) =>
index === self.findIndex((u) => u.id === user.id)
);
return uniqueUsers;
}, [data]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
}, [refetch]);
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleUserPress = useCallback((userId: number) => {
router.push(`/(drawer)/user-management/${userId}`);
}, [router]);
const renderUserItem = useCallback(({ item, index }: { item: User; index: number }) => {
return <UserItem user={item} index={index} onPress={() => handleUserPress(item.id)} />;
}, [handleUserPress]);
if (isLoading && !hasLoadedOnce) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<ActivityIndicator size="large" color="#3b82f6" />
<MyText style={tw`text-gray-500 mt-4`}>Loading users...</MyText>
</View>
</AppContainer>
);
}
if (isError) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center p-8`}>
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
<MyText style={tw`text-gray-500 mt-2 text-center`}>
{error?.message || 'Failed to load users'}
</MyText>
<TouchableOpacity
onPress={() => refetch()}
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
>
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
</TouchableOpacity>
</View>
</AppContainer>
);
}
return (
<View style={tw`flex-1 bg-gray-50`}>
{/* Users List */}
<MyFlatList
data={users}
renderItem={renderUserItem}
keyExtractor={(item) => item.id.toString()}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListHeaderComponent={
<ListHeader
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
userCount={users.length}
/>
}
ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
contentContainerStyle={tw`pb-8`}
stickyHeaderIndices={[0]}
ListEmptyComponent={
<View style={tw`flex-1 justify-center items-center py-12`}>
<MaterialIcons name="people-outline" size={64} color="#e5e7eb" />
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
No users found
</MyText>
{searchTerm && (
<MyText style={tw`text-gray-400 mt-1 text-center`}>
Try adjusting your search
</MyText>
)}
</View>
}
/>
</View>
);
}

View file

@ -54,7 +54,7 @@ export default function Users() {
const users = data?.pages.flatMap(page => page.users) || [];
const handleUserPress = (userId: string) => {
router.push(`/(drawer)/user-management/${userId}`);
router.push(`/user-details/${userId}`);
};
return (

View file

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

View file

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

View file

@ -7,7 +7,6 @@ import { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
export interface BannerFormData {
@ -53,10 +52,10 @@ export default function BannerForm({
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const { uploadSingle } = useUploadToObjectStorage();
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
// Fetch products for dropdown
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery();
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
const products = productsData?.products || [];
@ -98,11 +97,33 @@ export default function BannerForm({
let imageUrl: string | undefined;
if (selectedImages.length > 0) {
// Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store', // Using 'store' for now
mimeTypes,
});
// Upload image
const uploadUrl = uploadUrls[0];
const { blob, mimeType } = selectedImages[0];
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
imageUrl = presignedUrl;
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
imageUrl = uploadUrl;
}
// Call onSubmit with form values and imageUrl
await onSubmit(values, imageUrl);
} catch (error) {
console.error('Upload error:', error);

View file

@ -0,0 +1,197 @@
import React from 'react';
import { View, ScrollView, Dimensions } from 'react-native';
import { Image } from 'expo-image';
import { MyText, tw } from 'common-ui';
import { trpc } from '../src/trpc-client';
interface FullOrderViewProps {
orderId: number;
}
export const FullOrderView: React.FC<FullOrderViewProps> = ({ orderId }) => {
const { data: order, isLoading, error } = trpc.admin.order.getFullOrder.useQuery({ orderId });
if (isLoading) {
return (
<View style={tw`p-6`}>
<MyText style={tw`text-center text-gray-600`}>Loading order details...</MyText>
</View>
);
}
if (error || !order) {
return (
<View style={tw`p-6`}>
<MyText style={tw`text-center text-red-600`}>Failed to load order details</MyText>
</View>
);
}
const totalAmount = order.items.reduce((sum, item) => sum + item.amount, 0);
return (
<ScrollView
style={[tw`flex-1`, { maxHeight: Dimensions.get('window').height * 0.8 }]}
showsVerticalScrollIndicator={false}
>
<View style={tw`p-6`}>
<MyText style={tw`text-2xl font-bold text-gray-800 mb-6`}>Order #{order.readableId}</MyText>
{/* Customer Information */}
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Customer Details</MyText>
<View style={tw`space-y-2`}>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Name:</MyText>
<MyText style={tw`font-medium`}>{order.customerName}</MyText>
</View>
{order.customerEmail && (
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Email:</MyText>
<MyText style={tw`font-medium`}>{order.customerEmail}</MyText>
</View>
)}
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Mobile:</MyText>
<MyText style={tw`font-medium`}>{order.customerMobile}</MyText>
</View>
</View>
</View>
{/* Delivery Address */}
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Delivery Address</MyText>
<View style={tw`space-y-1`}>
<MyText style={tw`text-gray-800`}>{order.address.line1}</MyText>
{order.address.line2 && <MyText style={tw`text-gray-800`}>{order.address.line2}</MyText>}
<MyText style={tw`text-gray-800`}>
{order.address.city}, {order.address.state} - {order.address.pincode}
</MyText>
<MyText style={tw`text-gray-800`}>Phone: {order.address.phone}</MyText>
</View>
</View>
{/* Order Details */}
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Order Details</MyText>
<View style={tw`space-y-2`}>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Order Date:</MyText>
<MyText style={tw`font-medium`}>
{new Date(order.createdAt).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</MyText>
</View>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Payment Method:</MyText>
<MyText style={tw`font-medium`}>
{order.isCod ? 'Cash on Delivery' : 'Online Payment'}
</MyText>
</View>
{order.slotInfo && (
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Delivery Slot:</MyText>
<MyText style={tw`font-medium`}>
{new Date(order.slotInfo.time).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
</MyText>
</View>
)}
</View>
</View>
{/* Items */}
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Items ({order.items.length})</MyText>
{order.items.map((item, index) => (
<View key={item.id} style={tw`flex-row items-center py-3 ${index !== order.items.length - 1 ? 'border-b border-gray-100' : ''}`}>
<View style={tw`flex-1`}>
<MyText style={tw`font-medium text-gray-800`} numberOfLines={2}>
{item.productName}
</MyText>
<MyText style={tw`text-sm text-gray-600`}>
Qty: {item.quantity} {item.unit} × {parseFloat(item.price.toString()).toFixed(2)}
</MyText>
</View>
<MyText style={tw`font-semibold text-gray-800`}>{item.amount.toFixed(2)}</MyText>
</View>
))}
</View>
{/* Payment Information */}
{(order.payment || order.paymentInfo) && (
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Payment Information</MyText>
{order.payment && (
<View style={tw`space-y-2 mb-3`}>
<MyText style={tw`text-sm font-medium text-gray-700`}>Payment Details:</MyText>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Status:</MyText>
<MyText style={tw`font-medium capitalize`}>{order.payment.status}</MyText>
</View>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Gateway:</MyText>
<MyText style={tw`font-medium`}>{order.payment.gateway}</MyText>
</View>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Order ID:</MyText>
<MyText style={tw`font-medium`}>{order.payment.merchantOrderId}</MyText>
</View>
</View>
)}
{order.paymentInfo && (
<View style={tw`space-y-2`}>
<MyText style={tw`text-sm font-medium text-gray-700`}>Payment Info:</MyText>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Status:</MyText>
<MyText style={tw`font-medium capitalize`}>{order.paymentInfo.status}</MyText>
</View>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Gateway:</MyText>
<MyText style={tw`font-medium`}>{order.paymentInfo.gateway}</MyText>
</View>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-gray-600`}>Order ID:</MyText>
<MyText style={tw`font-medium`}>{order.paymentInfo.merchantOrderId}</MyText>
</View>
</View>
)}
</View>
)}
{/* User Notes */}
{order.userNotes && (
<View style={tw`bg-blue-50 rounded-xl p-4 mb-4`}>
<MyText style={tw`text-lg font-semibold text-gray-800 mb-2`}>Customer Notes</MyText>
<MyText style={tw`text-gray-700`}>{order.userNotes}</MyText>
</View>
)}
{/* Admin Notes */}
{order.adminNotes && (
<View style={tw`bg-yellow-50 rounded-xl p-4 mb-4`}>
<MyText style={tw`text-lg font-semibold text-gray-800 mb-2`}>Admin Notes</MyText>
<MyText style={tw`text-gray-700`}>{order.adminNotes}</MyText>
</View>
)}
{/* Total */}
<View style={tw`bg-blue-50 rounded-xl p-4`}>
<View style={tw`flex-row justify-between items-center`}>
<MyText style={tw`text-xl font-bold text-gray-800`}>Total Amount</MyText>
<MyText style={tw`text-2xl font-bold text-blue-600`}>{parseFloat(order.totalAmount.toString()).toFixed(2)}</MyText>
</View>
</View>
</View>
</ScrollView>
);
};

View file

@ -103,18 +103,6 @@ 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 }}>
@ -269,29 +257,6 @@ export function OrderOptionsMenu({
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
</TouchableOpacity>
{hasCoordinates && (
<TouchableOpacity
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
onPress={() => {
handleOpenInMaps();
onClose();
}}
>
<View style={tw`w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4`}>
<MaterialIcons name="map" size={20} color="#2563eb" />
</View>
<View>
<MyText style={tw`font-semibold text-gray-800 text-base`}>
Open in Maps
</MyText>
<MyText style={tw`text-gray-500 text-xs`}>
View delivery location on Google Maps
</MyText>
</View>
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
</TouchableOpacity>
)}
<TouchableOpacity
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
onPress={() => {

View file

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

View file

@ -73,11 +73,6 @@ 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(),
@ -148,22 +143,12 @@ export default function SlotForm({
<View style={tw`mb-4`}>
<Text style={tw`text-lg font-semibold mb-2`}>Delivery Date & Time</Text>
<DateTimePickerMod
dateTestID="delivery-date-picker"
timeTestID="delivery-time-picker"
value={values.deliveryTime}
setValue={(value) => setFieldValue('deliveryTime', value)}
/>
<DateTimePickerMod 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
dateTestID="freeze-date-picker"
timeTestID="freeze-time-picker"
value={values.freezeTime}
setValue={(value) => setFieldValue('freezeTime', value)}
/>
<DateTimePickerMod value={values.freezeTime} setValue={(value) => setFieldValue('freezeTime', value)} />
</View>
<View style={tw`mb-4`}>
@ -230,8 +215,6 @@ export default function SlotForm({
</FieldArray>
<TouchableOpacity
testID="create-slot-button"
accessibilityLabel="create-slot-button"
onPress={() => handleSubmit()}
disabled={isPending}
style={tw`${isPending ? 'bg-pink2' : 'bg-pink1'} p-3 rounded-lg items-center mt-6 pb-4`}

View file

@ -6,7 +6,6 @@ import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-u
import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
export interface StoreFormData {
name: string;
@ -60,19 +59,14 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
});
}, [initialValues, initialSelectedProducts]);
const existingImageUrls = useMemo(
() => (formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []),
[formInitialValues.imageUrl]
)
const staffOptions = staffData?.staff.map((staff: { id: number; name: string }) => ({
const staffOptions = staffData?.staff.map(staff => ({
label: staff.name,
value: staff.id,
})) || [];
const { uploadSingle, isUploading } = useUploadToObjectStorage();
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
@ -119,11 +113,39 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
let imageUrl: string | undefined;
if (selectedImages.length > 0) {
const { blob, mimeType } = selectedImages[0];
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
imageUrl = presignedUrl;
// Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store',
mimeTypes,
});
// Upload images
for (let i = 0; i < uploadUrls.length; i++) {
const uploadUrl = uploadUrls[i];
const { blob, mimeType } = selectedImages[i];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
}
// Extract key from first upload URL
// const u = new URL(uploadUrls[0]);
// const rawKey = u.pathname.replace(/^\/+/, "");
// imageUrl = decodeURIComponent(rawKey);
imageUrl = uploadUrls[0];
}
// Submit form with imageUrl
onSubmit({ ...values, imageUrl });
} catch (error) {
console.error('Upload error:', error);
@ -173,25 +195,20 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
<ImageUploader
images={displayImages}
existingImageUrls={existingImageUrls}
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() =>
setFormInitialValues((prev) => ({
...prev,
imageUrl: undefined,
}))
}
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
allowMultiple={false}
/>
</View>
<TouchableOpacity
onPress={submit}
disabled={isLoading || isUploading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
disabled={isLoading || generateUploadUrls.isPending}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
>
<MyText style={tw`text-white text-lg font-bold`}>
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
</MyText>
</TouchableOpacity>
</View>

View file

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

View file

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

View file

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

View file

@ -1,12 +0,0 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true,
};

View file

@ -1,23 +0,0 @@
describe('Example', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should have welcome screen', async () => {
await expect(element(by.id('welcome'))).toBeVisible();
});
it('should show hello screen after tap', async () => {
await element(by.id('hello_button')).tap();
await expect(element(by.text('Hello!!!'))).toBeVisible();
});
it('should show world screen after tap', async () => {
await element(by.id('world_button')).tap();
await expect(element(by.text('World!!!'))).toBeVisible();
});
});

View file

@ -5,8 +5,8 @@
},
"build": {
"development": {
"distribution": "internal",
"channel": "development"
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",

View file

@ -1,118 +0,0 @@
import { useState } from 'react';
import { trpc } from '../src/trpc-client';
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile' | 'tags';
interface UploadInput {
blob: Blob;
mimeType: string;
}
interface UploadBatchInput {
images: UploadInput[];
contextString: ContextString;
}
interface UploadResult {
keys: string[];
presignedUrls: string[];
}
export function useUploadToObjectStorage() {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
setIsUploading(true);
setError(null);
setProgress({ completed: 0, total: input.images.length });
try {
const { images, contextString } = input;
if (images.length === 0) {
return { keys: [], presignedUrls: [] };
}
// 1. Get presigned URLs from backend (one call for all images)
const mimeTypes = images.map(img => img.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString,
mimeTypes,
});
if (uploadUrls.length !== images.length) {
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
}
// 2. Upload all images in parallel
const uploadPromises = images.map(async (image, index) => {
const presignedUrl = uploadUrls[index];
const { blob, mimeType } = image;
const response = await fetch(presignedUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
});
if (!response.ok) {
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
}
// Update progress
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
return {
key: extractKeyFromPresignedUrl(presignedUrl),
presignedUrl,
};
});
// Use Promise.all - if any fails, entire batch fails
const results = await Promise.all(uploadPromises);
return {
keys: results.map(r => r.key),
presignedUrls: results.map(r => r.presignedUrl),
};
} catch (err) {
const uploadError = err instanceof Error ? err : new Error('Upload failed');
setError(uploadError);
throw uploadError;
} finally {
setIsUploading(false);
setProgress(null);
}
};
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
const result = await upload({
images: [{ blob, mimeType }],
contextString,
});
return {
key: result.keys[0],
presignedUrl: result.presignedUrls[0],
};
};
return {
upload,
uploadSingle,
isUploading,
error,
progress,
isPending: generateUploadUrls.isPending
};
}
function extractKeyFromPresignedUrl(url: string): string {
const u = new URL(url);
let rawKey = u.pathname.replace(/^\/+/, '');
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
return decodeURIComponent(rawKey);
}

Binary file not shown.

View file

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

View file

@ -0,0 +1,111 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from '../../services/axios-admin-ui';
// Types
export interface CreateProductPayload {
name: string;
shortDescription?: string;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
incrementStep?: number;
productQuantity?: number;
isOutOfStock?: boolean;
deals?: {
quantity: number;
price: number;
validTill: string;
}[];
}
export interface UpdateProductPayload {
name: string;
shortDescription?: string;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
incrementStep?: number;
productQuantity?: number;
isOutOfStock?: boolean;
deals?: {
quantity: number;
price: number;
validTill: string;
}[];
}
export interface Product {
id: number;
name: string;
shortDescription?: string | null;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
productQuantity?: number;
isOutOfStock?: boolean;
images?: string[];
createdAt: string;
unit?: {
id: number;
shortNotation: string;
fullName: string;
};
deals?: {
id: number;
quantity: string;
price: string;
validTill: string;
}[];
}
export interface CreateProductResponse {
product: Product;
deals?: any[];
message: string;
}
// API functions
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
const response = await axios.post('/av/products', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
const response = await axios.put(`/av/products/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
// Hooks
export const useCreateProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProductApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
};
export const useUpdateProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProductApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
};

View file

@ -0,0 +1,119 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from '../../services/axios-admin-ui';
// Types
export interface CreateTagPayload {
tagName: string;
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface UpdateTagPayload {
tagName: string;
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface Tag {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores: number[];
createdAt?: string;
}
export interface CreateTagResponse {
tag: Tag;
message: string;
}
export interface GetTagsResponse {
tags: Tag[];
message: string;
}
// API functions
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
const response = await axios.post('/av/product-tags', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
const response = await axios.put(`/av/product-tags/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
const response = await axios.delete(`/av/product-tags/${id}`);
return response.data;
};
const getTagsApi = async (): Promise<GetTagsResponse> => {
const response = await axios.get('/av/product-tags');
return response.data;
};
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
const response = await axios.get(`/av/product-tags/${id}`);
return response.data;
};
// Hooks
export const useCreateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useUpdateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useDeleteTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useGetTags = () => {
return useQuery({
queryKey: ['tags'],
queryFn: getTagsApi,
});
};
export const useGetTag = (id: number) => {
return useQuery({
queryKey: ['tags', id],
queryFn: () => getTagApi(id),
enabled: !!id,
});
};

View file

@ -1,10 +1,13 @@
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react';
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Formik, FieldArray } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDelete, useTheme, DatePicker, tw, useFocusCallback, Checkbox } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { trpc } from '../trpc-client';
import { useGetTags } from '../api-hooks/tag.api';
interface ProductFormData {
name: string;
@ -35,10 +38,9 @@ export interface ProductFormRef {
interface ProductFormProps {
mode: 'create' | 'edit';
initialValues: ProductFormData;
onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void;
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
isLoading: boolean;
existingImages?: ImageUploaderNeoItem[];
existingImageKeys?: string[];
existingImages?: string[];
}
const unitOptions = [
@ -48,22 +50,18 @@ const unitOptions = [
{ label: 'Unit Piece', value: 4 },
];
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
mode,
initialValues,
onSubmit,
isLoading,
existingImages:existingImagesRaw,
existingImageKeys = [],
existingImages = []
}, ref) => {
const { theme } = useTheme();
const [images, setImages] = useState<ImageUploaderNeoItem[]>([]);
const existingImages = existingImagesRaw || []
// Sync images state when existingImages prop changes (e.g., when async query data arrives)
useEffect(() => {
setImages(existingImages);
}, [existingImagesRaw]);
const [images, setImages] = useState<{ uri?: string }[]>([]);
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
const storeOptions = storesData?.stores.map(store => ({
@ -71,50 +69,44 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id,
})) || [];
const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
const { data: tagsData } = useGetTags();
const tagOptions = tagsData?.tags.map(tag => ({
label: tag.tagName,
value: tag.id.toString(),
})) || [];
// Build signed URL -> S3 key mapping for existing images
const signedUrlToKey = useMemo(() => {
const map: Record<string, string> = {};
existingImages.forEach((img, i) => {
if (existingImageKeys[i]) {
map[img.imgUrl] = existingImageKeys[i];
}
});
return map;
}, [existingImages, existingImageKeys]);
// Initialize existing images state when existingImages prop changes
useEffect(() => {
console.log('changing existing imaes statte')
setExistingImagesState(existingImages);
}, [existingImages]);
const pickImage = usePickImage({
setFile: (files) => setImages(prev => [...prev, ...files]),
multiple: true,
});
// Calculate which existing images were deleted
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
return (
<Formik
initialValues={initialValues}
onSubmit={(values) => {
// New images have mimeType set, existing images have mimeType === null
const newImages = images.filter(img => img.mimeType !== null);
const deletedImageKeys = existingImages
.filter(existing => !images.some(current => current.imgUrl === existing.imgUrl))
.map(deleted => signedUrlToKey[deleted.imgUrl])
.filter(Boolean);
onSubmit(
values,
newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })),
deletedImageKeys,
);
}}
onSubmit={(values) => onSubmit(values, images, deletedImages)}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
// Clear form when screen comes into focus
const clearForm = useCallback(() => {
setImages([]);
setExistingImagesState([]);
resetForm();
}, [resetForm]);
useFocusCallback(clearForm);
// Update ref with current clearForm function
useImperativeHandle(ref, () => ({
clearImages: clearForm,
}), [clearForm]);
@ -149,18 +141,44 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }}
/>
<ImageUploaderNeo
images={images}
onImageAdd={(payloads) => setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])}
onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))}
allowMultiple={true}
/>
{mode === 'create' && (
<ImageUploader
images={images}
onAddImage={pickImage}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
/>
)}
{mode === 'edit' && existingImagesState.length > 0 && (
<View style={{ marginBottom: 16 }}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Current Images</MyText>
<ImageGalleryWithDelete
imageUrls={existingImagesState}
setImageUrls={setExistingImagesState}
imageHeight={100}
imageWidth={100}
columns={3}
/>
</View>
)}
{mode === 'edit' && (
<View style={{ marginBottom: 16 }}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
<ImageUploader
images={images}
onAddImage={pickImage}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
/>
</View>
)}
<BottomDropdown
topLabel='Unit'
label="Unit"
value={values.unitId}
options={unitOptions}
// onValueChange={(value) => handleChange('unitId')(value+'')}
onValueChange={(value) => setFieldValue('unitId', value)}
placeholder="Select unit"
style={{ marginBottom: 16 }}
@ -170,7 +188,18 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
placeholder="Enter product quantity"
keyboardType="numeric"
value={values.productQuantity.toString()}
onChangeText={(text) => setFieldValue('productQuantity', text)}
onChangeText={(text) => {
// if(text)
// setFieldValue('productQuantity', text);
// else
setFieldValue('productQuantity', text);
// if (text === '' || text === null || text === undefined) {
// setFieldValue('productQuantity', 1);
// } else {
// const num = parseFloat(text);
// setFieldValue('productQuantity', isNaN(num) ? 1 : num);
// }
}}
style={{ marginBottom: 16 }}
/>
<BottomDropdown
@ -209,6 +238,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }}
/>
<View style={tw`flex-row items-center mb-4`}>
<Checkbox
checked={values.isSuspended}
@ -223,7 +254,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
checked={values.isFlashAvailable}
onPress={() => {
setFieldValue('isFlashAvailable', !values.isFlashAvailable);
if (values.isFlashAvailable) setFieldValue('flashPrice', '');
if (values.isFlashAvailable) setFieldValue('flashPrice', ''); // Clear price when disabled
}}
style={tw`mr-3`}
/>
@ -241,6 +272,87 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
/>
)}
{/* <FieldArray name="deals">
{({ push, remove, form }) => (
<View style={{ marginBottom: 16 }}>
<View style={tw`flex-row items-center mb-4`}>
<MaterialIcons name="local-offer" size={20} color="#3B82F6" />
<MyText style={tw`text-lg font-bold text-gray-800 ml-2`}>
Special Package Deals
</MyText>
<MyText style={tw`text-sm text-gray-500 ml-1`}>(Optional)</MyText>
</View>
{(form.values.deals || []).map((deal: any, index: number) => (
<View key={index} style={tw`bg-white p-4 rounded-2xl shadow-lg mb-4 border border-gray-100`}>
<View style={tw`mb-3`}>
<View style={tw`flex-row items-end gap-3 mb-3`}>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Quantity"
placeholder="Enter quantity"
keyboardType="numeric"
value={deal.quantity || ''}
onChangeText={form.handleChange(`deals.${index}.quantity`)}
fullWidth={false}
/>
</View>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Price"
placeholder="Enter price"
keyboardType="numeric"
value={deal.price || ''}
onChangeText={form.handleChange(`deals.${index}.price`)}
fullWidth={false}
/>
</View>
</View>
<View style={tw`flex-row items-end gap-3`}>
<View style={tw`flex-1`}>
<DatePicker
value={deal.validTill}
setValue={(date) => form.setFieldValue(`deals.${index}.validTill`, date)}
showLabel={true}
placeholder="Valid Till"
/>
</View>
<View style={tw`flex-1`}>
<TouchableOpacity
onPress={() => remove(index)}
style={tw`bg-red-500 p-3 rounded-lg shadow-md flex-row items-center justify-center`}
>
<MaterialIcons name="delete" size={16} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Remove</MyText>
</TouchableOpacity>
</View>
</View>
</View>
</View>
))}
{(form.values.deals || []).length === 0 && (
<View style={tw`bg-gray-50 p-6 rounded-2xl border-2 border-dashed border-gray-300 items-center mb-4`}>
<MaterialIcons name="local-offer" size={32} color="#9CA3AF" />
<MyText style={tw`text-gray-500 text-center mt-2`}>
No package deals added yet
</MyText>
<MyText style={tw`text-gray-400 text-sm text-center mt-1`}>
Add special pricing for bulk purchases
</MyText>
</View>
)}
<TouchableOpacity
onPress={() => push({ quantity: '', price: '', validTill: null })}
style={tw`bg-green-500 px-4 py-2 rounded-lg shadow-lg flex-row items-center justify-center mt-4`}
>
<MaterialIcons name="add" size={20} color="white" />
<MyText style={tw`text-white font-bold text-lg ml-2`}>Add Package Deal</MyText>
</TouchableOpacity>
</View>
)}
</FieldArray> */}
<TouchableOpacity
onPress={submit}
disabled={isLoading}

View file

@ -1,8 +1,10 @@
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
interface StoreOption {
@ -21,7 +23,7 @@ interface TagFormProps {
mode: 'create' | 'edit';
initialValues: TagFormData;
existingImageUrl?: string;
onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void;
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
isLoading: boolean;
stores?: StoreOption[];
}
@ -29,28 +31,27 @@ interface TagFormProps {
const TagForm = forwardRef<any, TagFormProps>(({
mode,
initialValues,
existingImageUrl: existingImageUrlRaw,
existingImageUrl = '',
onSubmit,
isLoading,
stores: storesRaw,
stores = [],
}, ref) => {
const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
const [removedExisting, setRemovedExisting] = useState(false)
const [image, setImage] = useState<{ uri?: string } | null>(null);
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
const existingImageUrl = existingImageUrlRaw || ''
const stores = storesRaw || []
// Update checkbox when initial values change
useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
if (existingImageUrl) {
setImages([{ imgUrl: existingImageUrl, mimeType: null }])
} else {
setImages([])
}
setRemovedExisting(false)
}, [existingImageUrlRaw, initialValues.isDashboardTag]);
existingImageUrl && setImage({uri:existingImageUrl})
}, [initialValues.isDashboardTag]);
const pickImage = usePickImage({
setFile: (files) => {
setImage(files || null)
},
multiple: false,
});
const validationSchema = Yup.object().shape({
@ -66,17 +67,17 @@ const TagForm = forwardRef<any, TagFormProps>(({
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values) => onSubmit(values, images, removedExisting)}
onSubmit={(values) => onSubmit(values, image || undefined)}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
// Clear form when screen comes into focus
const clearForm = useCallback(() => {
setImages([])
setRemovedExisting(false)
setIsDashboardTagChecked(false);
resetForm();
}, [resetForm]);
const clearForm = useCallback(() => {
setImage(null);
setIsDashboardTagChecked(false);
resetForm();
}, [resetForm]);
useFocusCallback(clearForm);
@ -107,21 +108,10 @@ const TagForm = forwardRef<any, TagFormProps>(({
</MyText>
<ImageUploaderNeo
images={images}
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
setImages((prev) => [...prev, ...payload.map((img) => ({
imgUrl: img.url,
mimeType: img.mimeType,
}))])
}}
onImageRemove={(payload) => {
if (payload.mimeType === null) {
setRemovedExisting(true)
}
setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url))
}}
allowMultiple={false}
<ImageUploader
images={image ? [image] : []}
onAddImage={pickImage}
onRemoveImage={() => setImage(null)}
/>
</View>

View file

@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
import { Entypo } from '@expo/vector-icons';
import { MyText, tw, BottomDialog } from 'common-ui';
import { useRouter } from 'expo-router';
import { trpc } from '@/src/trpc-client';
import { useDeleteTag } from '../api-hooks/tag.api';
export interface TagMenuProps {
tagId: number;
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const deleteTag = trpc.admin.product.deleteProductTag.useMutation();
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
const handleOpenMenu = () => {
setIsOpen(true);
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
};
const performDelete = () => {
deleteTag.mutate({ id: tagId }, {
deleteTag(tagId, {
onSuccess: () => {
Alert.alert('Success', 'Tag deleted successfully');
onDeleteSuccess?.();
@ -63,7 +63,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
const errorMessage = error.message || 'Failed to delete tag';
Alert.alert('Error', errorMessage);
},
})
});
};
const options = [

View file

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

View file

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

8
packages/db_helper_postgres/.env → apps/backend/.env Normal file → Executable file
View file

@ -1,5 +1,6 @@
ENV_MODE=PROD
DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
@ -20,11 +21,6 @@ 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/
API_CACHE_KEY=api-cache-dev
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh
CLOUDFLARE_ZONE_ID=edefbf750bfc3ff26ccd11e8e28dc8d7
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
APP_URL=http://localhost:4000

View file

@ -1,42 +0,0 @@
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
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
PHONE_PE_CLIENT_VERSION=1
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
PHONE_PE_MERCHANT_ID=M23F2IGP34ZAR
# S3_REGION=ap-hyderabad-1
# S3_REGION=sgp
# S3_ACCESS_KEY_ID=52932a33abce40b38b559dadccab640f
# S3_SECRET_ACCESS_KEY=d287998b696d4a1c912e727f6394e53b
# S3_URL=https://s3.sgp.io.cloud.ovh.net/
# S3_BUCKET_NAME=theobjectstore
S3_REGION=apac
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
S3_BUCKET_NAME=meatfarmer-dev
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
JWT_SECRET=my_meatfarmer_jwt_secret_key
ASSETS_DOMAIN=https://assets2.freshyo.in/
API_CACHE_KEY=api-cache-dev
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh
CLOUDFLARE_ZONE_ID=edefbf750bfc3ff26ccd11e8e28dc8d7
# 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
RAZORPAY_KEY=rzp_test_RdCBBUJ56NLaJK
RAZORPAY_SECRET=namEwKBE1ypWxH0QDVg6fWOe
OTP_SENDER_AUTH_TOKEN=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJDLTM5OENEMkJDRTM0MjQ4OCIsImlhdCI6MTc0Nzg0MTEwMywiZXhwIjoxOTA1NTIxMTAzfQ.IV64ofVKjcwveIanxu_P2XlACtPeA9sJQ74uM53osDeyUXsFv0rwkCl6NNBIX93s_wnh4MKITLbcF_ClwmFQ0A
MIN_ORDER_VALUE=300
DELIVERY_CHARGE=20
# Telegram Configuration
TELEGRAM_BOT_TOKEN=8410461852:AAGXQCwRPFbndqwTgLJh8kYxST4Z0vgh72U
TELEGRAM_CHAT_IDS=5147760058
# TELEGRAM_BOT_TOKEN=8410461852:AAGXQCwRPFbndqwTgLJh8kYxST4Z0vgh72U
# TELEGRAM_CHAT_IDS=-5075171894

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

View file

@ -1,13 +0,0 @@
{
"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"
}

View file

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