Compare commits
3 commits
e96ed2334e
...
a8e52583d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8e52583d4 | ||
|
|
28c7207016 | ||
|
|
d658022a51 |
21 changed files with 8008 additions and 435 deletions
|
|
@ -5,6 +5,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { MyText, tw } from 'common-ui';
|
import { MyText, tw } from 'common-ui';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { theme } from 'common-ui/src/theme';
|
import { theme } from 'common-ui/src/theme';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -14,6 +15,7 @@ interface MenuItem {
|
||||||
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
|
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
iconBg?: string;
|
iconBg?: string;
|
||||||
|
badgeCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItemComponentProps {
|
interface MenuItemComponentProps {
|
||||||
|
|
@ -21,7 +23,9 @@ interface MenuItemComponentProps {
|
||||||
router: any;
|
router: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuItemComponent: React.FC<MenuItemComponentProps> = ({ item, router }) => (
|
const MenuItemComponent: React.FC<MenuItemComponentProps> = ({ item, router }) => {
|
||||||
|
|
||||||
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={item.route}
|
key={item.route}
|
||||||
onPress={() => router.push(item.route as any)}
|
onPress={() => router.push(item.route as any)}
|
||||||
|
|
@ -39,13 +43,23 @@ const MenuItemComponent: React.FC<MenuItemComponentProps> = ({ item, router }) =
|
||||||
<MyText style={tw`text-gray-500 text-xs`}>{item.description}</MyText>
|
<MyText style={tw`text-gray-500 text-xs`}>{item.description}</MyText>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<MaterialIcons name="chevron-right" size={24} color="#D1D5DB" />
|
{item.badgeCount ? (
|
||||||
|
<View style={tw`bg-red-500 px-2 py-1 rounded-full`}>
|
||||||
|
<MyText style={tw`text-white text-xs font-bold`}>{item.badgeCount}</MyText>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#D1D5DB" style={tw`ml-2`} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data: essentialsData } = trpc.admin.user.getEssentials.useQuery();
|
||||||
|
|
||||||
|
console.log({essentialsData})
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Manage Orders',
|
title: 'Manage Orders',
|
||||||
|
|
@ -91,6 +105,7 @@ export default function Dashboard() {
|
||||||
category: 'quick',
|
category: 'quick',
|
||||||
iconColor: '#F59E0B',
|
iconColor: '#F59E0B',
|
||||||
iconBg: '#FEF3C7',
|
iconBg: '#FEF3C7',
|
||||||
|
badgeCount: essentialsData?.unresolvedComplaints,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Products',
|
title: 'Products',
|
||||||
|
|
@ -207,6 +222,11 @@ export default function Dashboard() {
|
||||||
>
|
>
|
||||||
<View style={[tw`w-10 h-10 rounded-lg items-center justify-center mb-2`, { backgroundColor: item.iconBg }]}>
|
<View style={[tw`w-10 h-10 rounded-lg items-center justify-center mb-2`, { backgroundColor: item.iconBg }]}>
|
||||||
<MaterialIcons name={item.icon as any} size={20} color={item.iconColor} />
|
<MaterialIcons name={item.icon as any} size={20} color={item.iconColor} />
|
||||||
|
{item.badgeCount ? (
|
||||||
|
<View style={tw`absolute -top-1 -right-1 bg-red-500 min-w-5 h-5 rounded-full items-center justify-center px-1`}>
|
||||||
|
<MyText style={tw`text-white text-[10px] font-bold`}>{item.badgeCount}</MyText>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<MyText style={tw`text-gray-900 font-bold text-xs text-center`} numberOfLines={2}>
|
<MyText style={tw`text-gray-900 font-bold text-xs text-center`} numberOfLines={2}>
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ import { trpc } from "@/src/trpc-client";
|
||||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import Entypo from "@expo/vector-icons/Entypo";
|
import Entypo from "@expo/vector-icons/Entypo";
|
||||||
import * as Location from "expo-location";
|
import * as Location from "expo-location";
|
||||||
|
import { OrderOptionsMenu } from "@/components/OrderOptionsMenu";
|
||||||
|
import CancelOrderDialog from "@/components/CancelOrderDialog";
|
||||||
|
import { OrderNotesForm } from "@/components/OrderNotesForm";
|
||||||
|
|
||||||
// Define types outside
|
// Define types outside
|
||||||
interface OrderWithSequence {
|
interface OrderWithSequence {
|
||||||
|
|
@ -270,6 +273,8 @@ export default function DeliverySequences() {
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number>(-1);
|
const [selectedUserId, setSelectedUserId] = useState<number>(-1);
|
||||||
const [selectedOrderIds, setSelectedOrderIds] = useState<number[]>([]);
|
const [selectedOrderIds, setSelectedOrderIds] = useState<number[]>([]);
|
||||||
const [hasSequenceChanged, setHasSequenceChanged] = useState(false);
|
const [hasSequenceChanged, setHasSequenceChanged] = useState(false);
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
|
const [notesDialogOpen, setNotesDialogOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { data: slotsData, refetch: refetchSlots } =
|
const { data: slotsData, refetch: refetchSlots } =
|
||||||
|
|
@ -705,273 +710,90 @@ export default function DeliverySequences() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Order Menu Dialog */}
|
{/* Order Menu Dialog */}
|
||||||
<BottomDialog
|
<OrderOptionsMenu
|
||||||
open={showOrderMenu}
|
open={showOrderMenu}
|
||||||
onClose={() => setShowOrderMenu(false)}
|
onClose={() => setShowOrderMenu(false)}
|
||||||
>
|
order={{
|
||||||
<View style={tw`pb-8 pt-2 px-4`}>
|
id: selectedOrder?.id || 0,
|
||||||
{/* Handle Bar */}
|
readableId: selectedOrder?.readableId || 0,
|
||||||
<View style={tw`items-center mb-6`}>
|
isPackaged: selectedOrder?.isPackaged || false,
|
||||||
<View style={tw`w-12 h-1.5 bg-gray-200 rounded-full mb-4`} />
|
isDelivered: selectedOrder?.isDelivered || false,
|
||||||
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
address: selectedOrder?.address || '',
|
||||||
Order #{selectedOrder?.readableId}
|
addressId: selectedOrder?.addressId || 0,
|
||||||
</MyText>
|
adminNotes: selectedOrder?.adminNotes || null,
|
||||||
<MyText style={tw`text-sm text-gray-500`}>
|
latitude: selectedOrder?.latitude || null,
|
||||||
Select an action to perform
|
longitude: selectedOrder?.longitude || null,
|
||||||
</MyText>
|
}}
|
||||||
</View>
|
onViewDetails={() => {
|
||||||
|
if (selectedOrder) {
|
||||||
{/* Actions */}
|
router.push(`/order-details/${selectedOrder.id}`);
|
||||||
<TouchableOpacity
|
}
|
||||||
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
setShowOrderMenu(false);
|
||||||
onPress={() => {
|
}}
|
||||||
router.push(`/order-details/${selectedOrder?.id}`);
|
onTogglePackaged={() => {
|
||||||
setShowOrderMenu(false);
|
if (!selectedOrder) return;
|
||||||
}}
|
updatePackagedMutation.mutate(
|
||||||
disabled={updateAddressCoordsMutation.isPending}
|
{ orderId: selectedOrder.id.toString(), isPackaged: !selectedOrder.isPackaged },
|
||||||
>
|
{
|
||||||
<View
|
onSuccess: () => {
|
||||||
style={tw`w-10 h-10 rounded-full bg-purple-50 items-center justify-center mr-4`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="visibility" size={20} color="#9333ea" />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
|
||||||
View Details
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-500 text-xs`}>
|
|
||||||
See full order information
|
|
||||||
</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 ${
|
|
||||||
updatePackagedMutation.isPending ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
onPress={async () => {
|
|
||||||
if (!selectedOrder) return;
|
|
||||||
try {
|
|
||||||
await updatePackagedMutation.mutateAsync({
|
|
||||||
orderId: selectedOrder.id.toString(),
|
|
||||||
isPackaged: !selectedOrder.isPackaged,
|
|
||||||
});
|
|
||||||
refetchOrders();
|
refetchOrders();
|
||||||
refetchSequence();
|
refetchSequence();
|
||||||
setShowOrderMenu(false);
|
},
|
||||||
} catch (error) {
|
onError: () => {
|
||||||
Alert.alert("Error", "Failed to update packaged status");
|
Alert.alert("Error", "Failed to update packaged status");
|
||||||
}
|
},
|
||||||
}}
|
}
|
||||||
disabled={updatePackagedMutation.isPending}
|
);
|
||||||
>
|
}}
|
||||||
<View
|
onToggleDelivered={() => {
|
||||||
style={tw`w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4`}
|
if (!selectedOrder) return;
|
||||||
>
|
updateDeliveredMutation.mutate(
|
||||||
<MaterialIcons name="inventory" size={20} color="#2563eb" />
|
{ orderId: selectedOrder.id.toString(), isDelivered: !selectedOrder.isDelivered },
|
||||||
</View>
|
{
|
||||||
<View>
|
onSuccess: () => {
|
||||||
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
|
||||||
{selectedOrder?.isPackaged
|
|
||||||
? "Unmark Packaged"
|
|
||||||
: "Mark Packaged"}
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-500 text-xs`}>
|
|
||||||
{selectedOrder?.isPackaged
|
|
||||||
? "Revert to not packaged"
|
|
||||||
: "Update status to packaged"}
|
|
||||||
</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 ${
|
|
||||||
updateDeliveredMutation.isPending ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
onPress={async () => {
|
|
||||||
if (!selectedOrder) return;
|
|
||||||
try {
|
|
||||||
await updateDeliveredMutation.mutateAsync({
|
|
||||||
orderId: selectedOrder.id.toString(),
|
|
||||||
isDelivered: !selectedOrder.isDelivered,
|
|
||||||
});
|
|
||||||
refetchOrders();
|
refetchOrders();
|
||||||
refetchSequence();
|
refetchSequence();
|
||||||
setShowOrderMenu(false);
|
},
|
||||||
} catch (error) {
|
onError: () => {
|
||||||
Alert.alert("Error", "Failed to update delivered status");
|
Alert.alert("Error", "Failed to update delivered status");
|
||||||
}
|
},
|
||||||
}}
|
}
|
||||||
disabled={updateDeliveredMutation.isPending}
|
);
|
||||||
>
|
}}
|
||||||
<View
|
onOpenAdminNotes={() => {
|
||||||
style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}
|
setShowOrderMenu(false);
|
||||||
>
|
setNotesDialogOpen(true);
|
||||||
<MaterialIcons name="local-shipping" size={20} color="#16a34a" />
|
}}
|
||||||
</View>
|
onCancelOrder={() => {
|
||||||
<View>
|
setShowOrderMenu(false);
|
||||||
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
setCancelDialogOpen(true);
|
||||||
{selectedOrder?.isDelivered
|
}}
|
||||||
? "Unmark Delivered"
|
onAttachLocation={() => {}}
|
||||||
: "Mark Delivered"}
|
onWhatsApp={() => {}}
|
||||||
</MyText>
|
onDial={() => {}}
|
||||||
<MyText style={tw`text-gray-500 text-xs`}>
|
/>
|
||||||
{selectedOrder?.isDelivered
|
|
||||||
? "Revert delivery status"
|
|
||||||
: "Complete the delivery"}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
<MaterialIcons
|
|
||||||
name="chevron-right"
|
|
||||||
size={24}
|
|
||||||
color="#9ca3af"
|
|
||||||
style={tw`ml-auto`}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<CancelOrderDialog
|
||||||
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm ${
|
orderId={selectedOrder?.id || 0}
|
||||||
updateAddressCoordsMutation.isPending ? "opacity-50" : ""
|
open={cancelDialogOpen}
|
||||||
}`}
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
onPress={async () => {
|
onSuccess={() => {
|
||||||
if (!selectedOrder) return;
|
refetchOrders();
|
||||||
try {
|
refetchSequence();
|
||||||
const { status } =
|
}}
|
||||||
await Location.requestForegroundPermissionsAsync();
|
/>
|
||||||
if (status !== "granted") {
|
|
||||||
Alert.alert(
|
|
||||||
"Permission Denied",
|
|
||||||
"Location permission is required to attach coordinates."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const location = await Location.getCurrentPositionAsync({
|
|
||||||
accuracy: Location.Accuracy.High,
|
|
||||||
});
|
|
||||||
const { latitude, longitude } = location.coords;
|
|
||||||
await updateAddressCoordsMutation.mutateAsync({
|
|
||||||
addressId: selectedOrder.addressId,
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
});
|
|
||||||
Alert.alert(
|
|
||||||
"Success",
|
|
||||||
"Location attached to address successfully."
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert(
|
|
||||||
"Error",
|
|
||||||
"Failed to attach location. Please try again."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setShowOrderMenu(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={tw`w-10 h-10 rounded-full bg-orange-50 items-center justify-center mr-4`}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name="add-location-alt"
|
|
||||||
size={20}
|
|
||||||
color="#ea580c"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
|
||||||
Attach Location
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-500 text-xs`}>
|
|
||||||
Save coordinates to address
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
<MaterialIcons
|
|
||||||
name="chevron-right"
|
|
||||||
size={24}
|
|
||||||
color="#9ca3af"
|
|
||||||
style={tw`ml-auto`}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<BottomDialog open={notesDialogOpen} onClose={() => setNotesDialogOpen(false)}>
|
||||||
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
<OrderNotesForm
|
||||||
onPress={() => {
|
orderId={selectedOrder?.id || 0}
|
||||||
const phoneMatch = selectedOrder?.address.match(/Phone: (\d+)/);
|
initialNotes={selectedOrder?.adminNotes || ''}
|
||||||
const phone = phoneMatch ? phoneMatch[1] : null;
|
onSuccess={() => {
|
||||||
if (phone) {
|
refetchOrders();
|
||||||
Linking.openURL(`whatsapp://send?phone=+91${phone}`);
|
refetchSequence();
|
||||||
} else {
|
setNotesDialogOpen(false);
|
||||||
Alert.alert("No phone number found");
|
}}
|
||||||
}
|
onCancel={() => setNotesDialogOpen(false)}
|
||||||
setShowOrderMenu(false);
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="message" size={20} color="#16a34a" />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
|
||||||
Message On WhatsApp
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-500 text-xs`}>
|
|
||||||
Send message via WhatsApp
|
|
||||||
</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={() => {
|
|
||||||
const phoneMatch = selectedOrder?.address.match(/Phone: (\d+)/);
|
|
||||||
const phone = phoneMatch ? phoneMatch[1] : null;
|
|
||||||
if (phone) {
|
|
||||||
Linking.openURL(`tel:${phone}`);
|
|
||||||
} else {
|
|
||||||
Alert.alert("No phone number found");
|
|
||||||
}
|
|
||||||
setShowOrderMenu(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="phone" size={20} color="#16a34a" />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
|
||||||
Dial Mobile Number
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-500 text-xs`}>
|
|
||||||
Call customer directly
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
<MaterialIcons
|
|
||||||
name="chevron-right"
|
|
||||||
size={24}
|
|
||||||
color="#9ca3af"
|
|
||||||
style={tw`ml-auto`}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</BottomDialog>
|
</BottomDialog>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState , useEffect } from 'react';
|
import React, { useState , useEffect } from 'react';
|
||||||
import { View, TouchableOpacity, Alert, TextInput, ActivityIndicator } from 'react-native';
|
import { View, TouchableOpacity, Alert, TextInput, ActivityIndicator, Linking } from 'react-native';
|
||||||
import { AppContainer, MyText, tw, MyFlatList, BottomDialog, BottomDropdown, Checkbox, theme, MyTextInput } from 'common-ui';
|
import { AppContainer, MyText, tw, MyFlatList, BottomDialog, BottomDropdown, Checkbox, theme, MyTextInput } from 'common-ui';
|
||||||
import { trpc } from '../../../src/trpc-client';
|
import { trpc } from '../../../src/trpc-client';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
|
@ -7,6 +7,8 @@ import dayjs from 'dayjs';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { Entypo } from '@expo/vector-icons';
|
import { Entypo } from '@expo/vector-icons';
|
||||||
import CancelOrderDialog from '@/components/CancelOrderDialog';
|
import CancelOrderDialog from '@/components/CancelOrderDialog';
|
||||||
|
import { OrderOptionsMenu } from '@/components/OrderOptionsMenu';
|
||||||
|
import * as Location from 'expo-location';
|
||||||
|
|
||||||
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => {
|
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => {
|
||||||
const [notesText, setNotesText] = useState(existingNotes || '');
|
const [notesText, setNotesText] = useState(existingNotes || '');
|
||||||
|
|
@ -111,7 +113,6 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
{ orderId: order.orderId.toString(), isPackaged },
|
{ orderId: order.orderId.toString(), isPackaged },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMenuOpen(false);
|
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +124,6 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
{ orderId: order.orderId.toString(), isDelivered },
|
{ orderId: order.orderId.toString(), isDelivered },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMenuOpen(false);
|
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -344,81 +344,40 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
<OrderOptionsMenu
|
||||||
<View style={tw`p-6`}>
|
open={menuOpen}
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800 mb-4`}>
|
onClose={() => setMenuOpen(false)}
|
||||||
Order Options
|
order={{
|
||||||
</MyText>
|
id: order.id,
|
||||||
{order.isFlashDelivery && (
|
readableId: order.readableId,
|
||||||
<View style={tw`bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 flex-row items-center`}>
|
isPackaged: order.isPackaged,
|
||||||
<MaterialIcons name="bolt" size={20} color="#D97706" />
|
isDelivered: order.isDelivered,
|
||||||
<View style={tw`ml-3 flex-1`}>
|
isFlashDelivery: order.isFlashDelivery,
|
||||||
<MyText style={tw`text-sm font-bold text-amber-900`}>Flash Delivery Order</MyText>
|
address: order.address,
|
||||||
<MyText style={tw`text-xs text-amber-700`}>
|
addressId: 0,
|
||||||
Deliver within 30 minutes • High Priority
|
adminNotes: order.adminNotes,
|
||||||
</MyText>
|
userNotes: order.userNotes,
|
||||||
</View>
|
latitude: null,
|
||||||
</View>
|
longitude: null,
|
||||||
)}
|
status: order.status,
|
||||||
<TouchableOpacity
|
}}
|
||||||
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
onViewDetails={handleMenuOption}
|
||||||
onPress={() => handleMarkPackaged(!order.isPackaged)}
|
onTogglePackaged={() => handleMarkPackaged(!order.isPackaged)}
|
||||||
>
|
onToggleDelivered={() => handleMarkDelivered(!order.isDelivered)}
|
||||||
<Entypo name="box" size={20} color="#6B7280" />
|
onOpenAdminNotes={() => {
|
||||||
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
setMenuOpen(false);
|
||||||
{order.isPackaged ? 'Mark Not Packaged' : 'Mark Packaged'}
|
setNotesDialogOpen(true);
|
||||||
</MyText>
|
}}
|
||||||
</TouchableOpacity>
|
onCancelOrder={() => {
|
||||||
{order.isPackaged && (
|
setMenuOpen(false);
|
||||||
<TouchableOpacity
|
setCancelDialogOpen(true);
|
||||||
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
}}
|
||||||
onPress={() => handleMarkDelivered(!order.isDelivered)}
|
onAttachLocation={() => {}}
|
||||||
>
|
onWhatsApp={() => {}}
|
||||||
<Entypo name="location" size={20} color="#6B7280" />
|
onDial={() => {}}
|
||||||
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
/>
|
||||||
{order.isDelivered ? 'Mark Not Delivered' : 'Mark Delivered'}
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
|
||||||
onPress={handleMenuOption}
|
|
||||||
>
|
|
||||||
<Entypo name="info-with-circle" size={20} color="#6B7280" />
|
|
||||||
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
|
||||||
Order Details
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
|
||||||
onPress={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
setNotesDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Entypo name="edit" size={20} color="#6B7280" />
|
|
||||||
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
|
||||||
Admin Notes
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{order.status !== 'cancelled' && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-row items-center p-4 bg-red-50 rounded-lg`}
|
|
||||||
onPress={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
setCancelDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="cancel" size={20} color="#DC2626" />
|
|
||||||
<MyText style={tw`text-red-700 font-medium ml-3`}>
|
|
||||||
Cancel Order
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</BottomDialog>
|
|
||||||
|
|
||||||
<BottomDialog open={itemsDialogOpen} onClose={() => setItemsDialogOpen(false)}>
|
<BottomDialog open={itemsDialogOpen} onClose={() => setItemsDialogOpen(false)}>
|
||||||
<View style={tw`py-6`}>
|
<View style={tw`py-6`}>
|
||||||
<View style={tw`flex-row items-center justify-between mb-4`}>
|
<View style={tw`flex-row items-center justify-between mb-4`}>
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800`}>
|
<MyText style={tw`text-lg font-bold text-gray-800`}>
|
||||||
|
|
@ -823,4 +782,4 @@ export default function Orders() {
|
||||||
</BottomDialog>
|
</BottomDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
306
apps/admin-ui/components/OrderOptionsMenu.tsx
Normal file
306
apps/admin-ui/components/OrderOptionsMenu.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, TouchableOpacity, Linking, Alert, TextInput, ScrollView, Dimensions } from 'react-native';
|
||||||
|
import * as Location from 'expo-location';
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
import {
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
BottomDialog,
|
||||||
|
} from 'common-ui';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||||
|
|
||||||
|
interface OrderOptionsMenuProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
order: {
|
||||||
|
id: number;
|
||||||
|
readableId: number;
|
||||||
|
isPackaged: boolean;
|
||||||
|
isDelivered: boolean;
|
||||||
|
isFlashDelivery?: boolean;
|
||||||
|
address: string;
|
||||||
|
addressId: number;
|
||||||
|
adminNotes?: string | null;
|
||||||
|
userNotes?: string | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
onViewDetails: () => void;
|
||||||
|
onTogglePackaged: () => void;
|
||||||
|
onToggleDelivered: () => void;
|
||||||
|
onOpenAdminNotes: () => void;
|
||||||
|
onCancelOrder: () => void;
|
||||||
|
onAttachLocation: () => void;
|
||||||
|
onWhatsApp: () => void;
|
||||||
|
onDial: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderOptionsMenu({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
order,
|
||||||
|
onViewDetails,
|
||||||
|
onTogglePackaged,
|
||||||
|
onToggleDelivered,
|
||||||
|
onOpenAdminNotes,
|
||||||
|
onCancelOrder,
|
||||||
|
onAttachLocation,
|
||||||
|
onWhatsApp,
|
||||||
|
onDial,
|
||||||
|
}: OrderOptionsMenuProps) {
|
||||||
|
const updateAddressCoordsMutation = trpc.admin.order.updateAddressCoords.useMutation();
|
||||||
|
|
||||||
|
const handleAttachLocation = async () => {
|
||||||
|
try {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert(
|
||||||
|
'Permission Denied',
|
||||||
|
'Location permission is required to attach coordinates.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const location = await Location.getCurrentPositionAsync({
|
||||||
|
accuracy: Location.Accuracy.High,
|
||||||
|
});
|
||||||
|
const { latitude, longitude } = location.coords;
|
||||||
|
await updateAddressCoordsMutation.mutateAsync({
|
||||||
|
addressId: order.addressId,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
});
|
||||||
|
Alert.alert('Success', 'Location attached to address successfully.');
|
||||||
|
onAttachLocation();
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to attach location. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractPhone = (address: string) => {
|
||||||
|
const phoneMatch = address.match(/Phone: (\d+)/);
|
||||||
|
return phoneMatch ? phoneMatch[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWhatsApp = () => {
|
||||||
|
const phone = extractPhone(order.address);
|
||||||
|
if (phone) {
|
||||||
|
Linking.openURL(`whatsapp://send?phone=+91${phone}`);
|
||||||
|
} else {
|
||||||
|
Alert.alert('No phone number found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDial = () => {
|
||||||
|
const phone = extractPhone(order.address);
|
||||||
|
if (phone) {
|
||||||
|
Linking.openURL(`tel:${phone}`);
|
||||||
|
} else {
|
||||||
|
Alert.alert('No phone number found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomDialog open={open} onClose={onClose}>
|
||||||
|
<View style={{ maxHeight: SCREEN_HEIGHT * 0.7 }}>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ flexGrow: 1 }}>
|
||||||
|
<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`}>
|
||||||
|
Order #{order.readableId}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>
|
||||||
|
Select an action to perform
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
|
onPress={() => {
|
||||||
|
onViewDetails();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full bg-purple-50 items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="visibility" size={20} color="#9333ea" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
View Details
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
See full order information
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}>
|
||||||
|
<View
|
||||||
|
style={tw`p-1`}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
onStartShouldSetResponder={() => true}
|
||||||
|
onResponderEnd={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTogglePackaged();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={order.isPackaged ? 'checkbox' : 'square-outline'}
|
||||||
|
size={24}
|
||||||
|
color={order.isPackaged ? '#10B981' : '#1570EF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`ml-3 flex-1`}>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Packaged
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
{order.isPackaged ? 'Mark as not packaged' : 'Mark as packaged'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}>
|
||||||
|
<View
|
||||||
|
style={tw`p-1`}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
onStartShouldSetResponder={() => true}
|
||||||
|
onResponderEnd={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleDelivered();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={order.isDelivered ? 'checkbox' : 'square-outline'}
|
||||||
|
size={24}
|
||||||
|
color={order.isDelivered ? '#10B981' : '#1570EF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`ml-3 flex-1`}>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Delivered
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
{order.isDelivered ? 'Mark as not delivered' : 'Mark as delivered'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
|
onPress={() => {
|
||||||
|
onOpenAdminNotes();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full bg-yellow-50 items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="edit" size={20} color="#ca8a04" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Admin Notes
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
{order.adminNotes ? 'Edit existing notes' : 'Add admin notes'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{order.status !== 'cancelled' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
|
onPress={() => {
|
||||||
|
onCancelOrder();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full bg-red-50 items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="cancel" size={20} color="#dc2626" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-red-700 text-base`}>
|
||||||
|
Cancel Order
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-red-500 text-xs`}>
|
||||||
|
Cancel and provide reason
|
||||||
|
</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={() => {
|
||||||
|
handleAttachLocation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
disabled={updateAddressCoordsMutation.isPending}
|
||||||
|
>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full bg-orange-50 items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="add-location-alt" size={20} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Attach Location
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
Save GPS coordinates to address
|
||||||
|
</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={() => {
|
||||||
|
handleWhatsApp();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="message" size={20} color="#16a34a" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Message On WhatsApp
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
Send message via WhatsApp
|
||||||
|
</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={() => {
|
||||||
|
handleDial();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}>
|
||||||
|
<MaterialIcons name="phone" size={20} color="#16a34a" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Dial Mobile Number
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
Call customer directly
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#9ca3af" style={tw`ml-auto`} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
apps/backend/drizzle/0068_colossal_magma.sql
Normal file
2
apps/backend/drizzle/0068_colossal_magma.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "mf"."addresses" ADD COLUMN "admin_latitude" real;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mf"."addresses" ADD COLUMN "admin_longitude" real;
|
||||||
1
apps/backend/drizzle/0069_violet_smiling_tiger.sql
Normal file
1
apps/backend/drizzle/0069_violet_smiling_tiger.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "mf"."addresses" ADD COLUMN "google_maps_url" varchar(500);
|
||||||
3612
apps/backend/drizzle/meta/0068_snapshot.json
Normal file
3612
apps/backend/drizzle/meta/0068_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3618
apps/backend/drizzle/meta/0069_snapshot.json
Normal file
3618
apps/backend/drizzle/meta/0069_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -477,6 +477,20 @@
|
||||||
"when": 1769280779210,
|
"when": 1769280779210,
|
||||||
"tag": "0067_messy_earthquake",
|
"tag": "0067_messy_earthquake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 68,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769709890336,
|
||||||
|
"tag": "0068_colossal_magma",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 69,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769718702463,
|
||||||
|
"tag": "0069_violet_smiling_tiger",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +48,9 @@ export const addresses = mf.table('addresses', {
|
||||||
isDefault: boolean('is_default').notNull().default(false),
|
isDefault: boolean('is_default').notNull().default(false),
|
||||||
latitude: real('latitude'),
|
latitude: real('latitude'),
|
||||||
longitude: real('longitude'),
|
longitude: real('longitude'),
|
||||||
|
googleMapsUrl: varchar('google_maps_url', { length: 500 }),
|
||||||
|
adminLatitude: real('admin_latitude'),
|
||||||
|
adminLongitude: real('admin_longitude'),
|
||||||
zoneId: integer('zone_id').references(() => addressZones.id),
|
zoneId: integer('zone_id').references(() => addressZones.id),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
@ -642,4 +645,4 @@ export const staffPermissionsRelations = relations(staffPermissions, ({ many })
|
||||||
export const staffRolePermissionsRelations = relations(staffRolePermissions, ({ one }) => ({
|
export const staffRolePermissionsRelations = relations(staffRolePermissions, ({ one }) => ({
|
||||||
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
|
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
|
||||||
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
|
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
20
apps/backend/src/lib/license-util.ts
Normal file
20
apps/backend/src/lib/license-util.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export async function extractCoordsFromRedirectUrl(url: string): Promise<{ latitude: string; longitude: string } | null> {
|
||||||
|
try {
|
||||||
|
await axios.get(url, { maxRedirects: 0 });
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 302 || error.response?.status === 301) {
|
||||||
|
const redirectUrl = error.response.headers.location;
|
||||||
|
const coordsMatch = redirectUrl.match(/!3d([-\d.]+)!4d([-\d.]+)/);
|
||||||
|
if (coordsMatch) {
|
||||||
|
return {
|
||||||
|
latitude: coordsMatch[1],
|
||||||
|
longitude: coordsMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -519,9 +519,9 @@ export const orderRouter = router({
|
||||||
}, ${order.address.city}, ${order.address.state} - ${
|
}, ${order.address.city}, ${order.address.state} - ${
|
||||||
order.address.pincode
|
order.address.pincode
|
||||||
}, Phone: ${order.address.phone}`,
|
}, Phone: ${order.address.phone}`,
|
||||||
addressId: order.addressId,
|
addressId: order.addressId,
|
||||||
latitude: order.address.latitude,
|
latitude: order.address.adminLatitude ?? order.address.latitude,
|
||||||
longitude: order.address.longitude,
|
longitude: order.address.adminLongitude ?? order.address.longitude,
|
||||||
totalAmount: parseFloat(order.totalAmount),
|
totalAmount: parseFloat(order.totalAmount),
|
||||||
items,
|
items,
|
||||||
deliveryTime: order.slot?.deliveryTime.toISOString() || null,
|
deliveryTime: order.slot?.deliveryTime.toISOString() || null,
|
||||||
|
|
@ -646,8 +646,8 @@ export const orderRouter = router({
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(addresses)
|
.update(addresses)
|
||||||
.set({
|
.set({
|
||||||
latitude,
|
adminLatitude: latitude,
|
||||||
longitude,
|
adminLongitude: longitude,
|
||||||
})
|
})
|
||||||
.where(eq(addresses.id, addressId))
|
.where(eq(addresses.id, addressId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
@ -793,9 +793,9 @@ export const orderRouter = router({
|
||||||
}, ${order.address.city}, ${order.address.state} - ${
|
}, ${order.address.city}, ${order.address.state} - ${
|
||||||
order.address.pincode
|
order.address.pincode
|
||||||
}, Phone: ${order.address.phone}`,
|
}, Phone: ${order.address.phone}`,
|
||||||
addressId: order.addressId,
|
addressId: order.addressId,
|
||||||
latitude: order.address.latitude,
|
latitude: order.address.adminLatitude ?? order.address.latitude,
|
||||||
longitude: order.address.longitude,
|
longitude: order.address.adminLongitude ?? order.address.longitude,
|
||||||
totalAmount: parseFloat(order.totalAmount),
|
totalAmount: parseFloat(order.totalAmount),
|
||||||
deliveryCharge: parseFloat(order.deliveryCharge || "0"),
|
deliveryCharge: parseFloat(order.deliveryCharge || "0"),
|
||||||
items,
|
items,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { protectedProcedure } from '../trpc-index';
|
import { protectedProcedure } from '../trpc-index';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db } from '../../db/db_index';
|
import { db } from '../../db/db_index';
|
||||||
import { users } from '../../db/schema';
|
import { users, complaints } from '../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { ApiError } from '../../lib/api-error';
|
import { ApiError } from '../../lib/api-error';
|
||||||
|
|
||||||
|
|
@ -51,4 +51,16 @@ export const userRouter = {
|
||||||
data: newUser,
|
data: newUser,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getEssentials: protectedProcedure
|
||||||
|
.query(async () => {
|
||||||
|
const [result] = await db
|
||||||
|
.select({ count: db.$count(complaints) })
|
||||||
|
.from(complaints)
|
||||||
|
.where(eq(complaints.isResolved, false));
|
||||||
|
|
||||||
|
return {
|
||||||
|
unresolvedComplaints: result.count || 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
@ -4,6 +4,7 @@ import { db } from '../../db/db_index';
|
||||||
import { addresses, orders, orderStatus, deliverySlotInfo } from '../../db/schema';
|
import { addresses, orders, orderStatus, deliverySlotInfo } from '../../db/schema';
|
||||||
import { eq, and, gte } from 'drizzle-orm';
|
import { eq, and, gte } from 'drizzle-orm';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { extractCoordsFromRedirectUrl } from '../../lib/license-util';
|
||||||
|
|
||||||
export const addressRouter = router({
|
export const addressRouter = router({
|
||||||
getDefaultAddress: protectedProcedure
|
getDefaultAddress: protectedProcedure
|
||||||
|
|
@ -36,10 +37,23 @@ export const addressRouter = router({
|
||||||
state: z.string().min(1, 'State is required'),
|
state: z.string().min(1, 'State is required'),
|
||||||
pincode: z.string().min(1, 'Pincode is required'),
|
pincode: z.string().min(1, 'Pincode is required'),
|
||||||
isDefault: z.boolean().optional(),
|
isDefault: z.boolean().optional(),
|
||||||
|
latitude: z.number().optional(),
|
||||||
|
longitude: z.number().optional(),
|
||||||
|
googleMapsUrl: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = ctx.user.userId;
|
const userId = ctx.user.userId;
|
||||||
const { name, phone, addressLine1, addressLine2, city, state, pincode, isDefault } = input;
|
const { name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input;
|
||||||
|
|
||||||
|
let { latitude, longitude } = input;
|
||||||
|
|
||||||
|
if (googleMapsUrl && latitude === undefined && longitude === undefined) {
|
||||||
|
const coords = await extractCoordsFromRedirectUrl(googleMapsUrl);
|
||||||
|
if (coords) {
|
||||||
|
latitude = Number(coords.latitude);
|
||||||
|
longitude = Number(coords.longitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !phone || !addressLine1 || !city || !state || !pincode) {
|
if (!name || !phone || !addressLine1 || !city || !state || !pincode) {
|
||||||
|
|
@ -61,6 +75,9 @@ export const addressRouter = router({
|
||||||
state,
|
state,
|
||||||
pincode,
|
pincode,
|
||||||
isDefault: isDefault || false,
|
isDefault: isDefault || false,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
googleMapsUrl,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
return { success: true, data: newAddress };
|
return { success: true, data: newAddress };
|
||||||
|
|
@ -77,10 +94,23 @@ export const addressRouter = router({
|
||||||
state: z.string().min(1, 'State is required'),
|
state: z.string().min(1, 'State is required'),
|
||||||
pincode: z.string().min(1, 'Pincode is required'),
|
pincode: z.string().min(1, 'Pincode is required'),
|
||||||
isDefault: z.boolean().optional(),
|
isDefault: z.boolean().optional(),
|
||||||
|
latitude: z.number().optional(),
|
||||||
|
longitude: z.number().optional(),
|
||||||
|
googleMapsUrl: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = ctx.user.userId;
|
const userId = ctx.user.userId;
|
||||||
const { id, name, phone, addressLine1, addressLine2, city, state, pincode, isDefault } = input;
|
const { id, name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input;
|
||||||
|
|
||||||
|
let { latitude, longitude } = input;
|
||||||
|
|
||||||
|
if (googleMapsUrl && latitude === undefined && longitude === undefined) {
|
||||||
|
const coords = await extractCoordsFromRedirectUrl(googleMapsUrl);
|
||||||
|
if (coords) {
|
||||||
|
latitude = Number(coords.latitude);
|
||||||
|
longitude = Number(coords.longitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if address exists and belongs to user
|
// Check if address exists and belongs to user
|
||||||
const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1);
|
const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1);
|
||||||
|
|
@ -93,7 +123,7 @@ export const addressRouter = router({
|
||||||
await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId));
|
await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updatedAddress] = await db.update(addresses).set({
|
const updateData: any = {
|
||||||
name,
|
name,
|
||||||
phone,
|
phone,
|
||||||
addressLine1,
|
addressLine1,
|
||||||
|
|
@ -102,7 +132,17 @@ export const addressRouter = router({
|
||||||
state,
|
state,
|
||||||
pincode,
|
pincode,
|
||||||
isDefault: isDefault || false,
|
isDefault: isDefault || false,
|
||||||
}).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).returning();
|
googleMapsUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (latitude !== undefined) {
|
||||||
|
updateData.latitude = latitude;
|
||||||
|
}
|
||||||
|
if (longitude !== undefined) {
|
||||||
|
updateData.longitude = longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedAddress] = await db.update(addresses).set(updateData).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).returning();
|
||||||
|
|
||||||
return { success: true, data: updatedAddress };
|
return { success: true, data: updatedAddress };
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { View, Dimensions, Image, Alert, ScrollView, StatusBar as RNStatusBar } from "react-native";
|
import { View, Dimensions, Image, Alert, ScrollView, StatusBar as RNStatusBar, RefreshControl } from "react-native";
|
||||||
import { StatusBar as ExpoStatusBar } from 'expo-status-bar';
|
import { StatusBar as ExpoStatusBar } from 'expo-status-bar';
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
|
@ -119,6 +119,7 @@ export default function Dashboard() {
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const { backgroundColor } = useStatusBarStore();
|
const { backgroundColor } = useStatusBarStore();
|
||||||
const { getQuickestSlot } = useProductSlotIdentifier();
|
const { getQuickestSlot } = useProductSlotIdentifier();
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: productsData,
|
data: productsData,
|
||||||
|
|
@ -130,11 +131,11 @@ export default function Dashboard() {
|
||||||
tagId: selectedTagId || undefined,
|
tagId: selectedTagId || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: essentialConsts, isLoading: isLoadingConsts, error: constsError } = useGetEssentialConsts();
|
const { data: essentialConsts, isLoading: isLoadingConsts, error: constsError, refetch: refetchConsts } = useGetEssentialConsts();
|
||||||
|
|
||||||
|
|
||||||
const { data: storesData } = trpc.user.stores.getStores.useQuery();
|
const { data: storesData, refetch: refetchStores } = trpc.user.stores.getStores.useQuery();
|
||||||
const { data: slotsData } = trpc.user.slots.getSlotsWithProducts.useQuery();
|
const { data: slotsData, refetch: refetchSlots } = trpc.user.slots.getSlotsWithProducts.useQuery();
|
||||||
|
|
||||||
const products = productsData?.products || [];
|
const products = productsData?.products || [];
|
||||||
|
|
||||||
|
|
@ -202,12 +203,26 @@ export default function Dashboard() {
|
||||||
.filter((product): product is NonNullable<typeof product> => product != null);
|
.filter((product): product is NonNullable<typeof product> => product != null);
|
||||||
|
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
refetch(),
|
||||||
|
refetchStores(),
|
||||||
|
refetchSlots(),
|
||||||
|
refetchConsts(),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useManualRefresh(() => {
|
useManualRefresh(() => {
|
||||||
refetch();
|
handleRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
useMarkDataFetchers(() => {
|
useMarkDataFetchers(() => {
|
||||||
refetch();
|
handleRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleScroll = (event: any) => {
|
const handleScroll = (event: any) => {
|
||||||
|
|
@ -243,6 +258,14 @@ export default function Dashboard() {
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={[tw`flex-1 bg-white`, { position: 'relative' }]}
|
style={[tw`flex-1 bg-white`, { position: 'relative' }]}
|
||||||
stickyHeaderIndices={[2]}
|
stickyHeaderIndices={[2]}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor="#3b82f6"
|
||||||
|
colors={["#3b82f6"]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
handleScroll(e);
|
handleScroll(e);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Alert, Modal } from 'react-native';
|
import { View, Alert } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { AppContainer, MyText, tw, useMarkDataFetchers, MyFlatList, MyTouchableOpacity } from 'common-ui';
|
import { AppContainer, MyText, tw, useMarkDataFetchers, MyFlatList, MyTouchableOpacity, BottomDialog } from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import AddressForm from '@/src/components/AddressForm';
|
import AddressForm from '@/src/components/AddressForm';
|
||||||
|
|
@ -16,6 +16,9 @@ interface Address {
|
||||||
state: string;
|
state: string;
|
||||||
pincode: string;
|
pincode: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
googleMapsUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddressCard({ address, onEdit, onDelete, onSetDefault, isDeleting }: {
|
function AddressCard({ address, onEdit, onDelete, onSetDefault, isDeleting }: {
|
||||||
|
|
@ -239,14 +242,9 @@ export default function Addresses() {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<BottomDialog open={modalVisible} onClose={() => setModalVisible(false)}>
|
||||||
visible={modalVisible}
|
<View style={tw`pt-4`}>
|
||||||
animationType="slide"
|
<View style={tw`flex-row justify-between items-center pb-4 border-b border-gray-100`}>
|
||||||
presentationStyle="pageSheet"
|
|
||||||
onRequestClose={() => setModalVisible(false)}
|
|
||||||
>
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`flex-row justify-between items-center pb-2`}>
|
|
||||||
<MyText weight="semibold" style={tw`text-lg text-gray-800`}>
|
<MyText weight="semibold" style={tw`text-lg text-gray-800`}>
|
||||||
{editingAddress ? 'Edit Address' : 'Add Address'}
|
{editingAddress ? 'Edit Address' : 'Add Address'}
|
||||||
</MyText>
|
</MyText>
|
||||||
|
|
@ -257,22 +255,28 @@ export default function Addresses() {
|
||||||
<MaterialIcons name="close" size={24} color="#6B7280" />
|
<MaterialIcons name="close" size={24} color="#6B7280" />
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<AddressForm
|
<View style={tw`pt-4`}>
|
||||||
onSuccess={handleAddressSubmit}
|
<AddressForm
|
||||||
initialValues={editingAddress ? {
|
onSuccess={handleAddressSubmit}
|
||||||
name: editingAddress.name,
|
initialValues={editingAddress ? {
|
||||||
phone: editingAddress.phone,
|
id: editingAddress.id,
|
||||||
addressLine1: editingAddress.addressLine1,
|
name: editingAddress.name,
|
||||||
addressLine2: editingAddress.addressLine2 || '',
|
phone: editingAddress.phone,
|
||||||
city: editingAddress.city,
|
addressLine1: editingAddress.addressLine1,
|
||||||
state: editingAddress.state,
|
addressLine2: editingAddress.addressLine2 || '',
|
||||||
pincode: editingAddress.pincode,
|
city: editingAddress.city,
|
||||||
isDefault: editingAddress.isDefault,
|
state: editingAddress.state,
|
||||||
} : undefined}
|
pincode: editingAddress.pincode,
|
||||||
isEdit={!!editingAddress}
|
isDefault: editingAddress.isDefault,
|
||||||
/>
|
latitude: editingAddress.googleMapsUrl ? undefined : (editingAddress.latitude ?? undefined),
|
||||||
</AppContainer>
|
longitude: editingAddress.googleMapsUrl ? undefined : (editingAddress.longitude ?? undefined),
|
||||||
</Modal>
|
googleMapsUrl: editingAddress.googleMapsUrl ?? undefined,
|
||||||
|
} : undefined}
|
||||||
|
isEdit={!!editingAddress}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import FirstUserWrapper from "@/components/FirstUserWrapper";
|
||||||
import UpdateChecker from "@/components/UpdateChecker";
|
import UpdateChecker from "@/components/UpdateChecker";
|
||||||
import { RefreshProvider } from "../../../packages/ui/src/lib/refresh-context";
|
import { RefreshProvider } from "../../../packages/ui/src/lib/refresh-context";
|
||||||
import WebViewWrapper from "@/components/WebViewWrapper";
|
import WebViewWrapper from "@/components/WebViewWrapper";
|
||||||
|
import BackHandlerWrapper from "@/components/BackHandler";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
|
@ -47,29 +48,30 @@ export default function RootLayout() {
|
||||||
<SafeAreaView edges={['bottom', 'left', 'right']} style={{ flex: 1, paddingTop: 0 }}>
|
<SafeAreaView edges={['bottom', 'left', 'right']} style={{ flex: 1, paddingTop: 0 }}>
|
||||||
|
|
||||||
<View style={{ flex: 1, backgroundColor: theme.colors.gray1, }}>
|
<View style={{ flex: 1, backgroundColor: theme.colors.gray1, }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<UpdateChecker>
|
<UpdateChecker>
|
||||||
<HealthTestWrapper>
|
<HealthTestWrapper>
|
||||||
<WebViewWrapper>
|
<WebViewWrapper>
|
||||||
<FirstUserWrapper>
|
<FirstUserWrapper>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<PaperProvider>
|
<PaperProvider>
|
||||||
<LocationTestWrapper>
|
<LocationTestWrapper>
|
||||||
<RefreshProvider queryClient={queryClient}>
|
<RefreshProvider queryClient={queryClient}>
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<BackHandlerWrapper />
|
||||||
</RefreshProvider>
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
</LocationTestWrapper>
|
</RefreshProvider>
|
||||||
</PaperProvider>
|
</LocationTestWrapper>
|
||||||
</NotificationProvider>
|
</PaperProvider>
|
||||||
</AuthProvider>
|
</NotificationProvider>
|
||||||
</FirstUserWrapper>
|
</AuthProvider>
|
||||||
</WebViewWrapper>
|
</FirstUserWrapper>
|
||||||
</HealthTestWrapper>
|
</WebViewWrapper>
|
||||||
</UpdateChecker>
|
</HealthTestWrapper>
|
||||||
</trpc.Provider>
|
</UpdateChecker>
|
||||||
</QueryClientProvider>
|
</trpc.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|
|
||||||
32
apps/user-ui/components/BackHandler.tsx
Normal file
32
apps/user-ui/components/BackHandler.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { BackHandler, Alert } from 'react-native';
|
||||||
|
import { useRouter, usePathname } from 'expo-router';
|
||||||
|
|
||||||
|
export default function BackHandlerWrapper() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isHomeScreen =
|
||||||
|
!router.canGoBack() &&
|
||||||
|
(pathname.includes('home') || pathname === '/');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onBackPress = () => {
|
||||||
|
if (isHomeScreen) {
|
||||||
|
Alert.alert('Exit App', 'Are you sure you want to exit?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Exit', onPress: () => BackHandler.exitApp() },
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [isHomeScreen]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,15 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
||||||
const { getQuickestSlot } = useProductSlotIdentifier();
|
const { getQuickestSlot } = useProductSlotIdentifier();
|
||||||
const { setShouldNavigateToCart } = useFlashNavigationStore();
|
const { setShouldNavigateToCart } = useFlashNavigationStore();
|
||||||
|
|
||||||
|
const sortedDeliverySlots = useMemo(() => {
|
||||||
|
if (!productDetail?.deliverySlots) return []
|
||||||
|
return [...productDetail.deliverySlots].sort((a, b) => {
|
||||||
|
const deliveryDiff = new Date(a.deliveryTime).getTime() - new Date(b.deliveryTime).getTime()
|
||||||
|
if (deliveryDiff !== 0) return deliveryDiff
|
||||||
|
return new Date(a.freezeTime).getTime() - new Date(b.freezeTime).getTime()
|
||||||
|
})
|
||||||
|
}, [productDetail?.deliverySlots])
|
||||||
|
|
||||||
// Find current quantity from cart data
|
// Find current quantity from cart data
|
||||||
const cartItem = productDetail ? cartData?.data?.items?.find((item: any) => item.productId === productDetail.id) : null;
|
const cartItem = productDetail ? cartData?.data?.items?.find((item: any) => item.productId === productDetail.id) : null;
|
||||||
const quantity = cartItem?.quantity || 0;
|
const quantity = cartItem?.quantity || 0;
|
||||||
|
|
@ -341,11 +350,11 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
||||||
<MyText style={tw`text-lg font-bold text-gray-900`}>Available Slots</MyText>
|
<MyText style={tw`text-lg font-bold text-gray-900`}>Available Slots</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{productDetail.deliverySlots.length === 0 ? (
|
{sortedDeliverySlots.length === 0 ? (
|
||||||
<MyText style={tw`text-gray-400 italic`}>No delivery slots available currently</MyText>
|
<MyText style={tw`text-gray-400 italic`}>No delivery slots available currently</MyText>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{productDetail.deliverySlots.slice(0, 2).map((slot, index) => (
|
{sortedDeliverySlots.slice(0, 2).map((slot, index) => (
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
key={index}
|
key={index}
|
||||||
style={tw`flex-row items-start mb-4 bg-gray-50 p-3 rounded-xl border border-gray-100`}
|
style={tw`flex-row items-start mb-4 bg-gray-50 p-3 rounded-xl border border-gray-100`}
|
||||||
|
|
@ -365,12 +374,12 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
||||||
<MaterialIcons name="add" size={20} color="#3B82F6" style={tw`mt-0.5`} />
|
<MaterialIcons name="add" size={20} color="#3B82F6" style={tw`mt-0.5`} />
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
))}
|
))}
|
||||||
{productDetail.deliverySlots.length > 2 && (
|
{sortedDeliverySlots.length > 2 && (
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => setShowAllSlots(true)}
|
onPress={() => setShowAllSlots(true)}
|
||||||
style={tw`items-center py-2`}
|
style={tw`items-center py-2`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-brand500 font-bold text-sm`}>View All {productDetail.deliverySlots.length} Slots</MyText>
|
<MyText style={tw`text-brand500 font-bold text-sm`}>View All {sortedDeliverySlots.length} Slots</MyText>
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -557,7 +566,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{productDetail.deliverySlots.map((slot, index) => (
|
{sortedDeliverySlots.map((slot, index) => (
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
key={index}
|
key={index}
|
||||||
style={tw`flex-row items-start mb-4 bg-gray-50 p-4 rounded-xl border border-gray-100`}
|
style={tw`flex-row items-start mb-4 bg-gray-50 p-4 rounded-xl border border-gray-100`}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
export const useGetEssentialConsts = () => {
|
export const useGetEssentialConsts = () => {
|
||||||
return trpc.common.essentialConsts.useQuery(undefined, {
|
const query = trpc.common.essentialConsts.useQuery(undefined, {
|
||||||
refetchInterval: 60000,
|
refetchInterval: 60000,
|
||||||
});
|
});
|
||||||
|
return { ...query, refetch: query.refetch };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { trpc } from '../trpc-client';
|
||||||
interface AddressFormProps {
|
interface AddressFormProps {
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
initialValues?: {
|
initialValues?: {
|
||||||
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
addressLine1: string;
|
addressLine1: string;
|
||||||
|
|
@ -18,6 +19,9 @@ interface AddressFormProps {
|
||||||
state: string;
|
state: string;
|
||||||
pincode: string;
|
pincode: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
googleMapsUrl?: string;
|
||||||
};
|
};
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -26,11 +30,17 @@ const AddressForm: React.FC<AddressFormProps> = ({ onSuccess, initialValues, isE
|
||||||
const [locationLoading, setLocationLoading] = useState(false);
|
const [locationLoading, setLocationLoading] = useState(false);
|
||||||
const [locationError, setLocationError] = useState<string | null>(null);
|
const [locationError, setLocationError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showGoogleMapsField, setShowGoogleMapsField] = useState(!!initialValues?.googleMapsUrl);
|
||||||
|
const [currentLocation, setCurrentLocation] = useState<{ latitude: number; longitude: number } | null>(
|
||||||
|
initialValues?.latitude && initialValues?.longitude
|
||||||
|
? { latitude: initialValues.latitude, longitude: initialValues.longitude }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
const createAddressMutation = trpc.user.address.createAddress.useMutation({
|
const createAddressMutation = trpc.user.address.createAddress.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
onSuccess();
|
setTimeout(() => onSuccess(), 100);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -38,12 +48,22 @@ const AddressForm: React.FC<AddressFormProps> = ({ onSuccess, initialValues, isE
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const attachCurrentLocation = async (setFieldValue: (field: string, value: any) => void) => {
|
const updateAddressMutation = trpc.user.address.updateAddress.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setTimeout(() => onSuccess(), 100);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update address');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachCurrentLocation = async () => {
|
||||||
setLocationLoading(true);
|
setLocationLoading(true);
|
||||||
setLocationError(null);
|
setLocationError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request location permission
|
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
|
||||||
if (status !== 'granted') {
|
if (status !== 'granted') {
|
||||||
|
|
@ -51,28 +71,13 @@ const AddressForm: React.FC<AddressFormProps> = ({ onSuccess, initialValues, isE
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current position
|
|
||||||
const location = await Location.getCurrentPositionAsync({
|
const location = await Location.getCurrentPositionAsync({
|
||||||
accuracy: Location.Accuracy.High,
|
accuracy: Location.Accuracy.High,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reverse geocode to get address
|
const { latitude, longitude } = location.coords;
|
||||||
const address = await Location.reverseGeocodeAsync({
|
setCurrentLocation({ latitude, longitude });
|
||||||
latitude: location.coords.latitude,
|
Alert.alert('Success', 'Location attached successfully');
|
||||||
longitude: location.coords.longitude,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate form fields with geocoded data
|
|
||||||
if (address[0]) {
|
|
||||||
const addr = address[0];
|
|
||||||
const addressLine1 = `${addr.streetNumber || ''} ${addr.street || ''}`.trim();
|
|
||||||
setFieldValue('addressLine1', addressLine1 || addr.name || '');
|
|
||||||
setFieldValue('city', addr.city || addr.subregion || '');
|
|
||||||
setFieldValue('state', addr.region || '');
|
|
||||||
setFieldValue('pincode', addr.postalCode || '');
|
|
||||||
} else {
|
|
||||||
setLocationError('Unable to determine address from your location');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Location error:', error);
|
console.error('Location error:', error);
|
||||||
setLocationError('Unable to fetch location. Please check your GPS settings.');
|
setLocationError('Unable to fetch location. Please check your GPS settings.');
|
||||||
|
|
@ -105,11 +110,24 @@ const AddressForm: React.FC<AddressFormProps> = ({ onSuccess, initialValues, isE
|
||||||
state: 'Telangana',
|
state: 'Telangana',
|
||||||
pincode: '509001',
|
pincode: '509001',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
|
googleMapsUrl: '',
|
||||||
}}
|
}}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
createAddressMutation.mutate(values);
|
const payload = {
|
||||||
|
...values,
|
||||||
|
latitude: currentLocation?.latitude,
|
||||||
|
longitude: currentLocation?.longitude,
|
||||||
|
googleMapsUrl: values.googleMapsUrl || undefined,
|
||||||
|
};
|
||||||
|
if (isEdit && initialValues?.id) {
|
||||||
|
updateAddressMutation.mutate({ id: initialValues.id, ...payload });
|
||||||
|
} else {
|
||||||
|
createAddressMutation.mutate(payload);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ handleChange, handleBlur, handleSubmit, values, errors, touched, setFieldValue }) => (
|
{({ handleChange, handleBlur, handleSubmit, values, errors, touched, setFieldValue }) => (
|
||||||
|
|
@ -181,6 +199,61 @@ const AddressForm: React.FC<AddressFormProps> = ({ onSuccess, initialValues, isE
|
||||||
/>
|
/>
|
||||||
{touched.pincode && errors.pincode && <MyText style={tw`text-red-500 mb-2`}>{errors.pincode}</MyText>}
|
{touched.pincode && errors.pincode && <MyText style={tw`text-red-500 mb-2`}>{errors.pincode}</MyText>}
|
||||||
|
|
||||||
|
{locationLoading ? (
|
||||||
|
<MyText style={tw`text-blue-500 text-sm mb-2`}>Fetching location...</MyText>
|
||||||
|
) : locationError ? (
|
||||||
|
<MyText style={tw`text-red-500 text-sm mb-2`}>{locationError}</MyText>
|
||||||
|
) : currentLocation ? (
|
||||||
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
|
<MyText style={tw`text-green-600 text-sm font-medium`}>Location Attached</MyText>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => attachCurrentLocation()}
|
||||||
|
disabled={locationLoading}
|
||||||
|
style={tw`ml-4`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-blue-500 text-sm font-medium`}>Attach Current</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => attachCurrentLocation()}
|
||||||
|
disabled={locationLoading}
|
||||||
|
style={tw`mb-4`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-blue-500 text-sm font-medium`}>
|
||||||
|
Attach Current Location
|
||||||
|
</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => setShowGoogleMapsField(true)}
|
||||||
|
disabled={false}
|
||||||
|
style={tw`mb-1`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-blue-500 text-sm font-medium`}>
|
||||||
|
Attach with Google Maps
|
||||||
|
</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
|
||||||
|
{showGoogleMapsField && (
|
||||||
|
<View style={tw`mb-2`}>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs mb-2`}>
|
||||||
|
1. Open Google Maps and Find location{'\n'}
|
||||||
|
2. Long press the desired location{'\n'}
|
||||||
|
3. Click on Share and Click on Copy{'\n'}
|
||||||
|
4. Paste the copied url here in the field.
|
||||||
|
</MyText>
|
||||||
|
<MyTextInput
|
||||||
|
placeholder="Google Maps Shared URL"
|
||||||
|
shrunkPadding={true}
|
||||||
|
onChangeText={handleChange('googleMapsUrl')}
|
||||||
|
onBlur={handleBlur('googleMapsUrl')}
|
||||||
|
value={values.googleMapsUrl}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={values.isDefault}
|
checked={values.isDefault}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue