enh
This commit is contained in:
parent
b0e84b7089
commit
c02f2c84f5
5 changed files with 203 additions and 19 deletions
|
|
@ -21,6 +21,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
|
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import ProductCard from "@/components/ProductCard";
|
import ProductCard from "@/components/ProductCard";
|
||||||
|
import AddToCartDialog from "@/src/components/AddToCartDialog";
|
||||||
import MyFlatList from "common-ui/src/components/flat-list";
|
import MyFlatList from "common-ui/src/components/flat-list";
|
||||||
|
|
||||||
import { trpc } from "@/src/trpc-client";
|
import { trpc } from "@/src/trpc-client";
|
||||||
|
|
@ -623,19 +624,20 @@ export default function Dashboard() {
|
||||||
columnWrapperStyle={{gap: 16}}
|
columnWrapperStyle={{gap: 16}}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
|
|
||||||
<ProductCard
|
<ProductCard
|
||||||
item={item}
|
item={item}
|
||||||
itemWidth={gridItemWidth}
|
itemWidth={gridItemWidth}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push(
|
router.push(
|
||||||
`/(drawer)/(tabs)/home/product-detail/${item.id}`
|
`/(drawer)/(tabs)/home/product-detail/${item.id}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeliveryInfo={true}
|
showDeliveryInfo={true}
|
||||||
miniView={false}
|
miniView={false}
|
||||||
|
useAddToCartDialog={true}
|
||||||
|
|
||||||
key={item.id}
|
key={item.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
initialNumToRender={4}
|
initialNumToRender={4}
|
||||||
|
|
@ -665,6 +667,7 @@ export default function Dashboard() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<LoadingDialog open={isLoadingDialogOpen} message="Adding to cart..." />
|
<LoadingDialog open={isLoadingDialogOpen} message="Adding to cart..." />
|
||||||
|
<AddToCartDialog />
|
||||||
<View style={tw`h-16`}></View>
|
<View style={tw`h-16`}></View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={tw`absolute bottom-2 left-4 right-4`}>
|
<View style={tw`absolute bottom-2 left-4 right-4`}>
|
||||||
|
|
|
||||||
|
|
@ -135,12 +135,9 @@ export default function SearchResults() {
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
item={item}
|
item={item}
|
||||||
handleAddToCart={handleAddToCart}
|
|
||||||
handleBuyNow={handleBuyNow}
|
|
||||||
itemWidth={itemWidth}
|
itemWidth={itemWidth}
|
||||||
onPress={() => router.push(`/(drawer)/(tabs)/home/product-detail/${item.id}`)}
|
onPress={() => router.push(`/(drawer)/(tabs)/home/product-detail/${item.id}`)}
|
||||||
showDeliveryInfo={false}
|
showDeliveryInfo={false}
|
||||||
iconType="flash"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
keyExtractor={(item, index) => index.toString()}
|
keyExtractor={(item, index) => index.toString()}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { View, Alert, TouchableOpacity, Text } from 'react-native';
|
import { View, Alert, TouchableOpacity, Text } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { tw, theme, MyText, MyTouchableOpacity, Quantifier, MiniQuantifier } from 'common-ui';
|
import { tw, theme, MyText, MyTouchableOpacity, Quantifier, MiniQuantifier } from 'common-ui';
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
useAddToCart,
|
useAddToCart,
|
||||||
} from '@/hooks/cart-query-hooks';
|
} from '@/hooks/cart-query-hooks';
|
||||||
import { useProductSlotIdentifier } from '@/hooks/useProductSlotIdentifier';
|
import { useProductSlotIdentifier } from '@/hooks/useProductSlotIdentifier';
|
||||||
|
import { useCartStore } from '@/src/store/cartStore';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
|
|
@ -23,6 +25,7 @@ interface ProductCardProps {
|
||||||
miniView?: boolean;
|
miniView?: boolean;
|
||||||
nullIfNotAvailable?: boolean;
|
nullIfNotAvailable?: boolean;
|
||||||
containerComp?: React.ComponentType<any> | React.JSXElementConstructor<any>;
|
containerComp?: React.ComponentType<any> | React.JSXElementConstructor<any>;
|
||||||
|
useAddToCartDialog?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatQuantity = (quantity: number, unit: string): { value: string; display: string } => {
|
const formatQuantity = (quantity: number, unit: string): { value: string; display: string } => {
|
||||||
|
|
@ -40,9 +43,11 @@ const ProductCard: React.FC<ProductCardProps> = ({
|
||||||
miniView = false,
|
miniView = false,
|
||||||
nullIfNotAvailable = false,
|
nullIfNotAvailable = false,
|
||||||
containerComp: ContainerComp = React.Fragment,
|
containerComp: ContainerComp = React.Fragment,
|
||||||
|
useAddToCartDialog = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { data: cartData } = useGetCart();
|
const { data: cartData } = useGetCart();
|
||||||
const { getQuickestSlot } = useProductSlotIdentifier();
|
const { getQuickestSlot } = useProductSlotIdentifier();
|
||||||
|
const { setAddedToCartProduct } = useCartStore();
|
||||||
const updateCartItem = useUpdateCartItem({
|
const updateCartItem = useUpdateCartItem({
|
||||||
showSuccessAlert: false,
|
showSuccessAlert: false,
|
||||||
showErrorAlert: false,
|
showErrorAlert: false,
|
||||||
|
|
@ -63,6 +68,22 @@ const ProductCard: React.FC<ProductCardProps> = ({
|
||||||
const cartItem = cartData?.items?.find((cartItem: any) => cartItem.productId === item.id);
|
const cartItem = cartData?.items?.find((cartItem: any) => cartItem.productId === item.id);
|
||||||
const quantity = cartItem?.quantity || 0;
|
const quantity = cartItem?.quantity || 0;
|
||||||
|
|
||||||
|
// Query all slots with products
|
||||||
|
const { data: slotsData } = trpc.user.slots.getSlotsWithProducts.useQuery();
|
||||||
|
|
||||||
|
// Create slot lookup map
|
||||||
|
const slotMap = useMemo(() => {
|
||||||
|
const map: Record<number, any> = {};
|
||||||
|
slotsData?.slots?.forEach((slot: any) => {
|
||||||
|
map[slot.id] = slot;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [slotsData]);
|
||||||
|
|
||||||
|
// Get cart item's slot delivery time if item is in cart
|
||||||
|
const cartSlot = cartItem?.slotId ? slotMap[cartItem.slotId] : null;
|
||||||
|
const displayDeliveryDate = cartSlot?.deliveryTime || item.nextDeliveryDate;
|
||||||
|
|
||||||
// Precompute the next slot and determine display out of stock status
|
// Precompute the next slot and determine display out of stock status
|
||||||
const slotId = getQuickestSlot(item.id);
|
const slotId = getQuickestSlot(item.id);
|
||||||
const displayIsOutOfStock = item.isOutOfStock || !slotId;
|
const displayIsOutOfStock = item.isOutOfStock || !slotId;
|
||||||
|
|
@ -73,7 +94,9 @@ const ProductCard: React.FC<ProductCardProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQuantityChange = (newQuantity: number) => {
|
const handleQuantityChange = (newQuantity: number) => {
|
||||||
if (newQuantity === 0 && cartItem) {
|
if (useAddToCartDialog) {
|
||||||
|
setAddedToCartProduct({ productId: item.id, product: item });
|
||||||
|
} else if (newQuantity === 0 && cartItem) {
|
||||||
removeFromCart.mutate({ itemId: cartItem.id });
|
removeFromCart.mutate({ itemId: cartItem.id });
|
||||||
} else if (newQuantity === 1 && !cartItem) {
|
} else if (newQuantity === 1 && !cartItem) {
|
||||||
const slotId = getQuickestSlot(item.id);
|
const slotId = getQuickestSlot(item.id);
|
||||||
|
|
@ -141,11 +164,11 @@ const ProductCard: React.FC<ProductCardProps> = ({
|
||||||
<MyText style={tw`text-gray-500 text-xs font-medium`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{formatQuantity(item.productQuantity || 1, item.unitNotation).display}</MyText></MyText>
|
<MyText style={tw`text-gray-500 text-xs font-medium`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{formatQuantity(item.productQuantity || 1, item.unitNotation).display}</MyText></MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showDeliveryInfo && item.nextDeliveryDate && (
|
{showDeliveryInfo && displayDeliveryDate && (
|
||||||
<View style={tw`flex-row items-center bg-brand50 px-2 py-1.5 rounded-lg self-start mb-2 border border-brand100`}>
|
<View style={tw`flex-row items-center bg-brand50 px-2 py-1.5 rounded-lg self-start mb-2 border border-brand100`}>
|
||||||
<MaterialIcons name="local-shipping" size={12} color="#2E90FA" />
|
<MaterialIcons name="local-shipping" size={12} color="#2E90FA" />
|
||||||
<MyText style={tw`text-[10px] text-brand700 ml-1.5 font-bold`}>
|
<MyText style={tw`text-[10px] text-brand700 ml-1.5 font-bold`}>
|
||||||
{dayjs(item.nextDeliveryDate).format("ddd, DD MMM • h:mm A")}
|
{dayjs(displayDeliveryDate).format("ddd, DD MMM • h:mm A")}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
143
apps/user-ui/src/components/AddToCartDialog.tsx
Normal file
143
apps/user-ui/src/components/AddToCartDialog.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { View, ScrollView } from 'react-native';
|
||||||
|
import { tw, BottomDialog, MyText, MyTouchableOpacity, Quantifier } from 'common-ui';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useCartStore } from '@/src/store/cartStore';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { useAddToCart, useGetCart } from '@/hooks/cart-query-hooks';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export default function AddToCartDialog() {
|
||||||
|
const { addedToCartProduct, clearAddedToCartProduct } = useCartStore();
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [selectedSlotId, setSelectedSlotId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: slotsData } = trpc.user.slots.getSlotsWithProducts.useQuery();
|
||||||
|
const { data: cartData } = useGetCart();
|
||||||
|
|
||||||
|
const addToCart = useAddToCart({
|
||||||
|
showSuccessAlert: false,
|
||||||
|
showErrorAlert: false,
|
||||||
|
refetchCart: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOpen = !!addedToCartProduct;
|
||||||
|
|
||||||
|
|
||||||
|
const product = addedToCartProduct?.product;
|
||||||
|
|
||||||
|
// Pre-select cart's slotId if item is already in cart
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && product) {
|
||||||
|
const cartItem = cartData?.items?.find((item: any) => item.productId === product.id);
|
||||||
|
if (cartItem?.slotId) {
|
||||||
|
setSelectedSlotId(cartItem.slotId);
|
||||||
|
} else {
|
||||||
|
setSelectedSlotId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, cartData, product]);
|
||||||
|
|
||||||
|
const { slotMap, productSlotIdsMap } = useMemo(() => {
|
||||||
|
const slotMap: Record<number, any> = {};
|
||||||
|
const productSlotIdsMap: Record<number, number[]> = {};
|
||||||
|
|
||||||
|
if (slotsData?.slots) {
|
||||||
|
slotsData.slots.forEach((slot: any) => {
|
||||||
|
slotMap[slot.id] = slot;
|
||||||
|
|
||||||
|
slot.products?.forEach((p: any) => {
|
||||||
|
if (!productSlotIdsMap[p.id]) {
|
||||||
|
productSlotIdsMap[p.id] = [];
|
||||||
|
}
|
||||||
|
productSlotIdsMap[p.id].push(slot.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { slotMap, productSlotIdsMap };
|
||||||
|
}, [slotsData]);
|
||||||
|
|
||||||
|
const availableSlotIds = productSlotIdsMap[product?.id] || [];
|
||||||
|
|
||||||
|
const availableSlots = availableSlotIds
|
||||||
|
.map((slotId) => slotMap[slotId])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const handleAddToCart = () => {
|
||||||
|
const slotId = selectedSlotId ?? availableSlotIds[0] ?? 0;
|
||||||
|
|
||||||
|
addToCart.mutate(
|
||||||
|
{ productId: product.id, quantity, slotId },
|
||||||
|
{ onSuccess: () => clearAddedToCartProduct() }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !addedToCartProduct) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomDialog open={isOpen} onClose={clearAddedToCartProduct}>
|
||||||
|
<View style={tw`p-6 max-h-[500px]`}>
|
||||||
|
<View style={tw`flex-row items-center mb-6`}>
|
||||||
|
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
|
||||||
|
<MaterialIcons name="schedule" size={20} color="#3B82F6" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Select Delivery Slot</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{availableSlots.map((slot: any) => (
|
||||||
|
<MyTouchableOpacity
|
||||||
|
key={slot.id}
|
||||||
|
style={tw`flex-row items-start mb-4 bg-gray-50 p-4 rounded-xl border border-gray-100 ${
|
||||||
|
selectedSlotId === slot.id ? 'border-brand500' : 'border-gray-100'
|
||||||
|
}`}
|
||||||
|
onPress={() => setSelectedSlotId(slot.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="local-shipping" size={20} color="#3B82F6" style={tw`mt-0.5`} />
|
||||||
|
<View style={tw`ml-3 flex-1`}>
|
||||||
|
<MyText style={tw`text-gray-900 font-bold text-base`}>
|
||||||
|
{dayjs(slot.deliveryTime).format('ddd, DD MMM • h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{selectedSlotId === slot.id ? (
|
||||||
|
<MaterialIcons name="check-circle" size={24} color="#3B82F6" style={tw`mt-0.5`} />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons name="check-box-outline-blank" size={24} color="#9CA3AF" style={tw`mt-0.5`} />
|
||||||
|
)}
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={tw`mt-4`}>
|
||||||
|
<MyText style={tw`text-sm font-bold text-gray-900 mb-2`}>Quantity</MyText>
|
||||||
|
<Quantifier
|
||||||
|
value={quantity}
|
||||||
|
setValue={setQuantity}
|
||||||
|
step={product.incrementStep}
|
||||||
|
unit={product.unitNotation}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row gap-3 mt-4`}>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
style={tw`flex-1 bg-brand500 py-3.5 rounded-xl items-center ${!selectedSlotId ? 'opacity-50' : ''}`}
|
||||||
|
onPress={handleAddToCart}
|
||||||
|
disabled={addToCart.isLoading || !selectedSlotId}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold`}>
|
||||||
|
{addToCart.isLoading ? 'Adding...' : 'Add to Cart'}
|
||||||
|
</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
|
||||||
|
onPress={clearAddedToCartProduct}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/user-ui/src/store/cartStore.ts
Normal file
18
apps/user-ui/src/store/cartStore.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface AddedToCartProduct {
|
||||||
|
productId: number;
|
||||||
|
product: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartStore {
|
||||||
|
addedToCartProduct: AddedToCartProduct | null;
|
||||||
|
setAddedToCartProduct: (product: AddedToCartProduct | null) => void;
|
||||||
|
clearAddedToCartProduct: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCartStore = create<CartStore>((set) => ({
|
||||||
|
addedToCartProduct: null,
|
||||||
|
setAddedToCartProduct: (product) => set({ addedToCartProduct: product }),
|
||||||
|
clearAddedToCartProduct: () => set({ addedToCartProduct: null }),
|
||||||
|
}));
|
||||||
Loading…
Add table
Reference in a new issue