Compare commits
No commits in common. "main" and "before-notif" have entirely different histories.
main
...
before-not
1042 changed files with 20156 additions and 629897 deletions
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"devices": []
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"dependencies": "c63a16a85154f1ea03750b1df53dcdee0200585f",
|
||||
"devDependencies": "0a1ec1c6df1c9d5100926df058dd0824b1293819"
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
35
Dockerfile
35
Dockerfile
|
|
@ -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"]
|
||||
6
app.json
6
app.json
|
|
@ -1,7 +1,3 @@
|
|||
{
|
||||
"expo": {
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.mohammedshafiuddin54.meat-farmer-monorepo"
|
||||
}
|
||||
}
|
||||
"expo": {}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"dependencies": "4650ceb7c30aaa4d5fd17b9577e186af7a84b50d",
|
||||
"dependencies": "091948e86692e0cce7744b6b0543448538c3125a",
|
||||
"devDependencies": "b3b38265f32b99a8299270a292f38ca26288d53d"
|
||||
}
|
||||
|
|
|
|||
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,20 +120,6 @@ function CustomDrawerContent() {
|
|||
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"
|
||||
|
|
@ -227,10 +213,10 @@ export default function Layout() {
|
|||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
|
||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
108
apps/admin-ui/app/(drawer)/address-management/index.tsx
Normal file
108
apps/admin-ui/app/(drawer)/address-management/index.tsx
Normal 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
|
||||
|
|
@ -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);
|
||||
|
|
@ -85,72 +52,35 @@ 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 style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-gray-600`}>Loading complaints...</MyText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (error) {
|
||||
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 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`}>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyFlatList
|
||||
style={tw`flex-1`}
|
||||
contentContainerStyle={tw`px-4 py-4`}
|
||||
style={tw`flex-1 bg-white`}
|
||||
contentContainerStyle={tw`px-4 pb-6`}
|
||||
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>
|
||||
|
||||
<MyText style={tw`text-base text-gray-700 mb-3 leading-5`}>
|
||||
{item.text}
|
||||
</MyText>
|
||||
<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>
|
||||
|
||||
{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`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
|
||||
|
|
@ -163,64 +93,53 @@ export default function Complaints() {
|
|||
</View>
|
||||
)}
|
||||
|
||||
<View style={tw`flex-row items-center gap-2 mb-3`}>
|
||||
<MaterialIcons name="person" size={14} color="#6B7280" />
|
||||
<View style={tw`flex-row items-center mb-2`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => item.userId && handleUserPress(item.userId)}
|
||||
onPress={() =>
|
||||
Alert.alert("User Page", "User page coming soon")
|
||||
}
|
||||
>
|
||||
<MyText style={tw`text-sm text-blue-600 underline`}>
|
||||
{item.userName || item.userMobile || "Unknown User"}
|
||||
{item.userName}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
<MyText style={tw`text-sm text-gray-600 mx-2`}>|</MyText>
|
||||
{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)}
|
||||
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`bg-blue-500 py-3 rounded-xl items-center shadow-sm mt-2`}
|
||||
style={tw`mt-2 bg-blue-500 p-3 rounded-lg shadow-md`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>
|
||||
Resolve Complaint
|
||||
</MyText>
|
||||
<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 style={tw`flex-1 justify-center items-center py-10`}>
|
||||
<MyText style={tw`text-gray-500 text-center`}>No complaints found</MyText>
|
||||
</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
|
||||
}
|
||||
/>
|
||||
<PaginationComponent totalCount={totalCount} />
|
||||
<ConfirmationDialog
|
||||
open={dialogOpen}
|
||||
positiveAction={handleConfirmResolve}
|
||||
|
|
@ -229,11 +148,10 @@ export default function Complaints() {
|
|||
setDialogOpen(false);
|
||||
setSelectedComplaintId(null);
|
||||
}}
|
||||
title="Resolve Complaint"
|
||||
title="Mark as Resolved"
|
||||
message="Add admin notes for this resolution:"
|
||||
confirmText="Resolve"
|
||||
cancelText="Cancel"
|
||||
isLoading={resolveComplaint.isPending}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -17,13 +17,6 @@ export default function Layout() {
|
|||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="all-items-order"
|
||||
options={{
|
||||
title: "All Items Order",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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 (
|
||||
{values.constants.map((constant, index) => (
|
||||
<View key={constant.key} style={tw`mb-4`}>
|
||||
<ConstantInput
|
||||
constantKey={constant.key}
|
||||
value={values[constant.key]}
|
||||
setFieldValue={setFieldValue}
|
||||
router={router}
|
||||
/>
|
||||
<ConstantInput constant={constant} setFieldValue={setFieldValue} index={index} router={router} />
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
|
||||
<MyTouchableOpacity
|
||||
onPress={() => handleSubmit()}
|
||||
|
|
|
|||
|
|
@ -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,21 +182,15 @@ 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);
|
||||
}
|
||||
|
||||
setSelectedProductIds([]);
|
||||
setSelectedProductId(null);
|
||||
setShowAddDialog(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -175,6 +173,15 @@ export default function Dashboard() {
|
|||
category: 'marketing',
|
||||
iconColor: '#F97316',
|
||||
iconBg: '#FFEDD5',
|
||||
},
|
||||
{
|
||||
title: 'Address Management',
|
||||
icon: 'location-on',
|
||||
description: 'Manage service areas',
|
||||
route: '/(drawer)/address-management',
|
||||
category: 'settings',
|
||||
iconColor: '#EAB308',
|
||||
iconBg: '#FEF9C3',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
|
|
@ -185,24 +192,6 @@ export default function Dashboard() {
|
|||
iconColor: '#7C3AED',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
{
|
||||
title: 'User Management',
|
||||
icon: 'people',
|
||||
description: 'View and manage all users',
|
||||
route: '/(drawer)/user-management',
|
||||
category: 'users',
|
||||
iconColor: '#0EA5E9',
|
||||
iconBg: '#E0F2FE',
|
||||
},
|
||||
{
|
||||
title: 'Send Notifications',
|
||||
icon: 'campaign',
|
||||
description: 'Send push notifications to users',
|
||||
route: '/(drawer)/send-notifications',
|
||||
category: 'users',
|
||||
iconColor: '#8B5CF6',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = menuItems.filter(item => item.category === 'quick');
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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={() => {}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
|
||||
Customer Details
|
||||
</MyText>
|
||||
<MaterialIcons name="chevron-right" size={20} color="#6B7280" />
|
||||
</View>
|
||||
|
||||
<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
|
||||
|
|
@ -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
|
||||
// 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);
|
||||
}
|
||||
|
||||
await createTag.mutateAsync({
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription || undefined,
|
||||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
await refetchTags()
|
||||
createTag(formData, {
|
||||
onSuccess: (data) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
]);
|
||||
},
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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);
|
||||
}
|
||||
|
||||
await updateTag.mutateAsync({
|
||||
id: tagIdNum,
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription || undefined,
|
||||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
await refetchTags()
|
||||
updateTag({ id: tagIdNum, formData }, {
|
||||
onSuccess: (data) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
]);
|
||||
},
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -1,35 +1,14 @@
|
|||
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[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await createProduct.mutateAsync({
|
||||
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
||||
const payload: CreateProductPayload = {
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
|
|
@ -39,18 +18,45 @@ export default function AddProduct() {
|
|||
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 || [],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
await refetchProducts();
|
||||
Alert.alert('Success', 'Product created successfully!');
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to create product');
|
||||
// Append tag IDs
|
||||
if (values.tagIds && values.tagIds.length > 0) {
|
||||
values.tagIds.forEach((tagId: number) => {
|
||||
formData.append('tagIds', tagId.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,50 +1,28 @@
|
|||
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,
|
||||
|
|
@ -54,21 +32,65 @@ export default function EditProduct() {
|
|||
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 || [],
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
// 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`)}
|
||||
|
|
@ -83,11 +58,6 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
|||
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
|
||||
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
|
||||
</View>
|
||||
{slot.isCapacityFull && (
|
||||
<View style={tw`px-2 py-1 rounded-full bg-red-500 ml-2`}>
|
||||
<MyText style={tw`text-xs font-bold text-white`}>FULL</MyText>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={tw`ml-2 p-1`}
|
||||
|
|
@ -102,48 +72,6 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
|||
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold mb-4`}>Slot #{slot.id} Actions</MyText>
|
||||
|
||||
{/* Capacity Toggle */}
|
||||
<TouchableOpacity
|
||||
onPress={handleCapacityToggle}
|
||||
disabled={updateSlotCapacity.isPending}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
<View style={tw`flex-row items-center justify-between`}>
|
||||
<View style={tw`flex-row items-center flex-1`}>
|
||||
{updateSlotCapacity.isPending ? (
|
||||
<ActivityIndicator size="small" color="#EF4444" style={tw`mr-3`} />
|
||||
) : (
|
||||
<MaterialCommunityIcons
|
||||
name={slot.isCapacityFull ? "package-variant-closed" : "package-variant"}
|
||||
size={20}
|
||||
color={slot.isCapacityFull ? "#EF4444" : "#4B5563"}
|
||||
style={tw`mr-3`}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
<MyText style={tw`text-base text-gray-800`}>Mark as Full Capacity</MyText>
|
||||
<MyText style={tw`text-xs text-gray-500 mt-0.5`}>
|
||||
{slot.isCapacityFull
|
||||
? "Slot is hidden from users"
|
||||
: "Hidden from users when full"}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
{updateSlotCapacity.isPending ? (
|
||||
<ActivityIndicator size="small" color="#EF4444" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={slot.isCapacityFull}
|
||||
onPress={handleCapacityToggle}
|
||||
size={22}
|
||||
fillColor="#EF4444"
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
158
apps/admin-ui/app/(drawer)/user-details/[id]/index.tsx
Normal file
158
apps/admin-ui/app/(drawer)/user-details/[id]/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ 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"
|
||||
>
|
||||
<SafeAreaView edges={['left', 'right', 'bottom']} style={{ flex: 1, backgroundColor: '#fff' }}>
|
||||
<RefreshProvider queryClient={queryClient}>
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</RefreshProvider>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
197
apps/admin-ui/components/FullOrderView.tsx
Normal file
197
apps/admin-ui/components/FullOrderView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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())}
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
|||
},
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
console.log({values})
|
||||
|
||||
const submitData = {
|
||||
snippetCode: values.snippetCode,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,8 +5,8 @@
|
|||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"distribution": "internal",
|
||||
"channel": "development"
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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",
|
||||
|
|
|
|||
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal file
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
119
apps/admin-ui/src/api-hooks/tag.api.ts
Normal file
119
apps/admin-ui/src/api-hooks/tag.api.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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];
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
return map;
|
||||
}, [existingImages, existingImageKeys]);
|
||||
|
||||
// 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
|
||||
{mode === 'create' && (
|
||||
<ImageUploader
|
||||
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}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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,14 +67,14 @@ 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)
|
||||
setImage(null);
|
||||
|
||||
setIsDashboardTagChecked(false);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@
|
|||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*",
|
||||
"../backend/*"
|
||||
],
|
||||
"@backend/*": [
|
||||
"../backend/src/*"
|
||||
"./*"
|
||||
],
|
||||
"shared-types": ["../shared-types"],
|
||||
"common-ui": ["../../packages/ui"],
|
||||
|
|
|
|||
6
packages/db_helper_postgres/.env → apps/backend/.env
Normal file → Executable file
6
packages/db_helper_postgres/.env → apps/backend/.env
Normal file → Executable file
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Binary file not shown.
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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
0
packages/db_helper_postgres/drizzle.config.ts → apps/backend/drizzle.config.ts
Normal file → Executable file
0
packages/db_helper_postgres/drizzle.config.ts → apps/backend/drizzle.config.ts
Normal file → Executable file
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue