From 08b352f686b5c66e077a7360cb64ccc35ce83f5d Mon Sep 17 00:00:00 2001 From: kenduNMT Date: Tue, 18 Nov 2025 16:18:43 +0700 Subject: [PATCH] order --- app/(tabs)/cart.tsx | 31 +- app/(tabs)/checkout.tsx | 434 ++++++++++++++++++++++++++++ app/(tabs)/orders.tsx | 375 +++++++++++++++++++++++++ app/order-detail.tsx | 509 +++++++++++++++++++++++++++++++++ app/order-success.tsx | 530 +++++++++++++++++++++++++++++++++++ components/CartItemCard.tsx | 6 +- components/ui/CartButton.tsx | 82 ++++++ hooks/useCart.ts | 41 ++- services/cart.ts | 23 +- services/orderApi.ts | 136 +++++++++ 10 files changed, 2140 insertions(+), 27 deletions(-) create mode 100644 app/(tabs)/checkout.tsx create mode 100644 app/(tabs)/orders.tsx create mode 100644 app/order-detail.tsx create mode 100644 app/order-success.tsx create mode 100644 components/ui/CartButton.tsx create mode 100644 services/orderApi.ts diff --git a/app/(tabs)/cart.tsx b/app/(tabs)/cart.tsx index a28e492..846f849 100644 --- a/app/(tabs)/cart.tsx +++ b/app/(tabs)/cart.tsx @@ -11,11 +11,12 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; -import { useFocusEffect } from '@react-navigation/native'; +import { useFocusEffect, useRouter } from 'expo-router'; import { useCart } from '../../hooks/useCart'; import CartItemCard from '../../components/CartItemCard'; export default function CartScreen() { + const router = useRouter(); const { cart, loading, @@ -42,7 +43,7 @@ export default function CartScreen() { { text: 'Hủy', style: 'cancel' }, { text: 'Xóa tất cả', - onPress: clearCart, + onPress: () => clearCart(), style: 'destructive' }, ] @@ -50,7 +51,13 @@ export default function CartScreen() { }; const handleCheckout = () => { - Alert.alert('Thông báo', 'Chức năng thanh toán đang được phát triển'); + if (!cart || cart.items.length === 0) { + Alert.alert('Thông báo', 'Giỏ hàng của bạn đang trống'); + return; + } + + // Navigate to checkout screen + router.push('/checkout'); }; const formatPrice = (price: number) => { @@ -84,6 +91,12 @@ export default function CartScreen() { Hãy thêm sản phẩm vào giỏ hàng để mua sắm + router.push('/')} + > + Tiếp tục mua sắm + ); @@ -202,6 +215,18 @@ const styles = StyleSheet.create({ marginTop: 8, textAlign: 'center', }, + shopButton: { + backgroundColor: '#ff6b6b', + paddingHorizontal: 32, + paddingVertical: 14, + borderRadius: 12, + marginTop: 24, + }, + shopButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, listContent: { padding: 16, }, diff --git a/app/(tabs)/checkout.tsx b/app/(tabs)/checkout.tsx new file mode 100644 index 0000000..1e727e3 --- /dev/null +++ b/app/(tabs)/checkout.tsx @@ -0,0 +1,434 @@ +import React, { useState } from 'react'; +import { getUser } from '@/services/auth'; +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useCart } from '../../hooks/useCart'; +import orderApi from '../../services/orderApi'; + +interface PaymentMethod { + id: string; + name: string; + icon: string; +} + +const PAYMENT_METHODS: PaymentMethod[] = [ + { id: 'COD', name: 'Thanh toán khi nhận hàng', icon: 'cash-outline' }, + { id: 'BANK', name: 'Chuyển khoản ngân hàng', icon: 'card-outline' }, + { id: 'MOMO', name: 'Ví MoMo', icon: 'wallet-outline' }, + { id: 'VNPAY', name: 'VNPay', icon: 'card-outline' }, +]; + +export default function CheckoutScreen() { + const router = useRouter(); + const { cart, refreshCart, clearCart } = useCart(); + + const [loading, setLoading] = useState(false); + const [shippingAddress, setShippingAddress] = useState(''); + const [phoneNumber, setPhoneNumber] = useState(''); + const [note, setNote] = useState(''); + const [selectedPayment, setSelectedPayment] = useState('COD'); + + const formatPrice = (price: number) => { + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + }).format(price); + }; + + const validateForm = () => { + if (!shippingAddress.trim()) { + Alert.alert('Lỗi', 'Vui lòng nhập địa chỉ giao hàng'); + return false; + } + if (!phoneNumber.trim()) { + Alert.alert('Lỗi', 'Vui lòng nhập số điện thoại'); + return false; + } + if (!/^[0-9]{10}$/.test(phoneNumber)) { + Alert.alert('Lỗi', 'Số điện thoại không hợp lệ (10 chữ số)'); + return false; + } + return true; + }; + + const handleCreateOrder = async () => { + if (!validateForm() || !cart) return; + + setLoading(true); + + try { + // LẤY userId từ AsyncStorage + const user = await getUser(); + if (!user) { + Alert.alert("Lỗi", "Bạn chưa đăng nhập!"); + return; + } + const userId = user.userId; + + const fullAddress = `${shippingAddress}${note ? ` - Ghi chú: ${note}` : ''} - SĐT: ${phoneNumber}`; + + const orderData = { + shippingAddress: fullAddress, + items: cart.items.map(item => ({ + productId: item.product.productId, + quantity: item.quantity, + })), + }; + + // Gọi API đúng với 2 tham số + const response = await orderApi.createOrder(userId, orderData); + + if (response.success && response.data) { + await clearCart(false); + await refreshCart(); + router.replace({ + pathname: '/order-success', + params: { orderId: response.data.orderId.toString() } + }); + } else { + Alert.alert("Lỗi", response.message || "Đặt hàng thất bại"); + } + } catch (error: any) { + console.error("Create order error:", error); + Alert.alert("Lỗi", error.message || "Không thể kết nối đến server"); + } finally { + setLoading(false); + } + }; + + + + if (!cart || cart.items.length === 0) { + return ( + + + + + + ); + } + + return ( + + + {/* Header */} + + router.back()}> + + + Thanh toán + + + + + {/* Shipping Address */} + + + + Địa chỉ giao hàng + + + + + + + + + + {/* Order Items */} + + + + + Sản phẩm ({cart.items.length}) + + + + {cart.items.map((item) => ( + + + + {item.product.productName} + + + {formatPrice(item.product.price)} x {item.quantity} + + + + {formatPrice(item.product.price * item.quantity)} + + + ))} + + + {/* Payment Method */} + + + + Phương thức thanh toán + + + {PAYMENT_METHODS.map((method) => ( + setSelectedPayment(method.id)} + > + + + {method.name} + + {selectedPayment === method.id && ( + + )} + + ))} + + + {/* Order Summary */} + + Chi tiết thanh toán + + + Tạm tính + + {formatPrice(cart.totalAmount)} + + + + + Phí vận chuyển + Miễn phí + + + + Tổng cộng + + {formatPrice(cart.totalAmount)} + + + + + + + + {/* Order Button */} + + + {loading ? ( + + ) : ( + <> + Đặt hàng + + {formatPrice(cart.totalAmount)} + + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: '#333', + }, + scrollView: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + section: { + backgroundColor: '#fff', + marginTop: 12, + padding: 16, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + marginLeft: 8, + color: '#333', + }, + input: { + borderWidth: 1, + borderColor: '#e0e0e0', + borderRadius: 8, + padding: 12, + marginBottom: 12, + fontSize: 15, + backgroundColor: '#fafafa', + }, + cartItem: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + itemInfo: { + flex: 1, + marginRight: 12, + }, + itemName: { + fontSize: 15, + marginBottom: 4, + color: '#333', + }, + itemPrice: { + fontSize: 13, + color: '#666', + }, + itemTotal: { + fontSize: 15, + fontWeight: '700', + color: '#ff6b6b', + }, + paymentMethod: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 14, + borderWidth: 1, + borderColor: '#e0e0e0', + borderRadius: 8, + marginBottom: 10, + backgroundColor: '#fafafa', + }, + paymentMethodActive: { + borderColor: '#ff6b6b', + backgroundColor: '#fff5f5', + }, + paymentMethodInfo: { + flexDirection: 'row', + alignItems: 'center', + }, + paymentMethodName: { + marginLeft: 12, + fontSize: 15, + color: '#333', + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 10, + }, + summaryLabel: { + fontSize: 15, + color: '#666', + }, + summaryValue: { + fontSize: 15, + color: '#333', + }, + totalRow: { + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + marginTop: 8, + paddingTop: 16, + }, + totalLabel: { + fontSize: 17, + fontWeight: '700', + color: '#333', + }, + totalValue: { + fontSize: 20, + fontWeight: '700', + color: '#ff6b6b', + }, + footer: { + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + }, + orderButton: { + backgroundColor: '#ff6b6b', + padding: 16, + borderRadius: 12, + alignItems: 'center', + }, + orderButtonDisabled: { + opacity: 0.6, + }, + orderButtonText: { + color: '#fff', + fontSize: 17, + fontWeight: '700', + }, + orderButtonSubtext: { + color: '#fff', + fontSize: 14, + marginTop: 4, + }, +}); \ No newline at end of file diff --git a/app/(tabs)/orders.tsx b/app/(tabs)/orders.tsx new file mode 100644 index 0000000..ec95d47 --- /dev/null +++ b/app/(tabs)/orders.tsx @@ -0,0 +1,375 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + ActivityIndicator, + RefreshControl, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import orderApi, { Order } from '../../services/orderApi'; + +const STATUS_COLORS: Record = { + PENDING: '#FFA726', + CONFIRMED: '#42A5F5', + SHIPPING: '#AB47BC', + DELIVERED: '#66BB6A', + CANCELLED: '#EF5350', +}; + +const STATUS_LABELS: Record = { + PENDING: 'Chờ xác nhận', + CONFIRMED: 'Đã xác nhận', + SHIPPING: 'Đang giao', + DELIVERED: 'Đã giao', + CANCELLED: 'Đã hủy', +}; + +const FILTER_TABS = [ + { key: 'ALL', label: 'Tất cả' }, + { key: 'PENDING', label: 'Chờ xác nhận' }, + { key: 'SHIPPING', label: 'Đang giao' }, + { key: 'DELIVERED', label: 'Đã giao' }, + { key: 'CANCELLED', label: 'Đã hủy' }, +]; + +export default function OrdersListScreen() { + const router = useRouter(); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [activeTab, setActiveTab] = useState('ALL'); + + useEffect(() => { + loadOrders(); + }, [activeTab]); + + const loadOrders = async () => { + try { + const response = activeTab === 'ALL' + ? await orderApi.getAllOrders() + : await orderApi.getOrdersByStatus(activeTab); + + if (response.success && response.data) { + setOrders(response.data); + } + } catch (error: any) { + console.error('Load orders error:', error); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + const onRefresh = () => { + setRefreshing(true); + loadOrders(); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatPrice = (price: number) => { + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + }).format(price); + }; + + const renderOrderItem = ({ item }: { item: Order }) => ( + router.push(`/order-detail?id=${item.orderId}`)} + > + + Đơn hàng #{item.orderId} + + + {STATUS_LABELS[item.orderStatus]} + + + + + {formatDate(item.createdAt)} + + + {item.orderItems.slice(0, 2).map((orderItem) => ( + + • {orderItem.productName} x{orderItem.quantity} + + ))} + {item.orderItems.length > 2 && ( + + và {item.orderItems.length - 2} sản phẩm khác + + )} + + + + Tổng tiền: + + {formatPrice(item.totalPrice)} + + + + + Xem chi tiết + + + + ); + + const renderEmptyList = () => ( + + + Chưa có đơn hàng nào + router.push('/')} + > + Mua sắm ngay + + + ); + + if (loading) { + return ( + + + + + + ); + } + + return ( + + {/* Header */} + + router.back()}> + + + Đơn hàng của tôi + + + + {/* Filter Tabs */} + + item.key} + renderItem={({ item }) => ( + setActiveTab(item.key)} + > + + {item.label} + + + )} + /> + + + {/* Orders List */} + item.orderId.toString()} + contentContainerStyle={orders.length > 0 ? styles.listContainer : styles.emptyListContainer} + ListEmptyComponent={renderEmptyList} + refreshControl={ + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: '#333', + }, + tabContainer: { + backgroundColor: '#fff', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + tab: { + paddingHorizontal: 16, + paddingVertical: 8, + marginHorizontal: 4, + borderRadius: 20, + }, + activeTab: { + backgroundColor: '#ff6b6b', + }, + tabText: { + fontSize: 14, + color: '#666', + }, + activeTabText: { + color: '#fff', + fontWeight: '700', + }, + listContainer: { + padding: 12, + }, + emptyListContainer: { + flex: 1, + }, + orderCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + orderHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + orderId: { + fontSize: 16, + fontWeight: '700', + color: '#333', + }, + statusBadge: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 12, + }, + statusText: { + color: '#fff', + fontSize: 12, + fontWeight: '700', + }, + orderDate: { + fontSize: 13, + color: '#999', + marginBottom: 12, + }, + orderItems: { + marginBottom: 12, + }, + productName: { + fontSize: 14, + color: '#333', + marginBottom: 4, + }, + moreItems: { + fontSize: 13, + color: '#666', + fontStyle: 'italic', + }, + orderFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#f0f0f0', + }, + totalLabel: { + fontSize: 15, + color: '#666', + }, + totalPrice: { + fontSize: 18, + fontWeight: '700', + color: '#ff6b6b', + }, + viewDetailButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: 12, + }, + viewDetailText: { + color: '#ff6b6b', + fontSize: 14, + fontWeight: '700', + marginRight: 4, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + }, + emptyText: { + fontSize: 16, + color: '#999', + marginTop: 16, + marginBottom: 24, + }, + shopButton: { + backgroundColor: '#ff6b6b', + paddingHorizontal: 32, + paddingVertical: 14, + borderRadius: 12, + }, + shopButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, +}); \ No newline at end of file diff --git a/app/order-detail.tsx b/app/order-detail.tsx new file mode 100644 index 0000000..3ed9265 --- /dev/null +++ b/app/order-detail.tsx @@ -0,0 +1,509 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + Alert, +} from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import api from '../services/api'; + +interface OrderDetail { + orderId: number; + userId: number; + userName: string; + totalPrice: number; + orderStatus: string; + shippingAddress: string; + createdAt: string; + updatedAt: string; + orderItems: { + orderItemId: number; + productId: number; + productName: string; + quantity: number; + price: number; + subtotal: number; + }[]; +} + +interface Timeline { + status: string; + timestamp: string | null; + completed: boolean; +} + +const STATUS_COLORS: Record = { + PENDING: '#FFA726', + CONFIRMED: '#42A5F5', + SHIPPING: '#AB47BC', + DELIVERED: '#66BB6A', + CANCELLED: '#EF5350', +}; + +const STATUS_LABELS: Record = { + PENDING: 'Chờ xác nhận', + CONFIRMED: 'Đã xác nhận', + SHIPPING: 'Đang giao hàng', + DELIVERED: 'Đã giao hàng', + CANCELLED: 'Đã hủy', +}; + +export default function OrderDetailScreen() { + const router = useRouter(); + const params = useLocalSearchParams(); + const orderId = params.id; + + const [order, setOrder] = useState(null); + const [timeline, setTimeline] = useState([]); + const [loading, setLoading] = useState(true); + const [cancelling, setCancelling] = useState(false); + + useEffect(() => { + if (orderId) { + loadOrderDetail(); + loadOrderProgress(); + } + }, [orderId]); + + /** 🔹 Load chi tiết đơn hàng */ + const loadOrderDetail = async () => { + try { + const res = await api.get(`/orders/${orderId}`, { + requireAuth: true, + }); + // Fix: Safely check 'res' type before accessing properties + if (res && typeof res === 'object' && 'success' in res && (res as any).success) { + setOrder((res as any).data); + } + } catch (error) { + console.error('Load order detail error:', error); + Alert.alert('Lỗi', 'Không thể tải chi tiết đơn hàng.'); + } finally { + setLoading(false); + } + }; + + /** 🔹 Load tiến trình đơn hàng */ + const loadOrderProgress = async () => { + try { + const res = await api.get(`/orders/${orderId}/progress`, { + requireAuth: true, + }); + // Fix: safely check 'res' type before accessing properties + if (res && typeof res === 'object' && 'success' in res && (res as any).success) { + setTimeline((res as any).data.timeline); + } + } catch (error) { + console.error('Load order progress error:', error); + } + }; + + /** 🔹 Hủy đơn hàng */ + const handleCancelOrder = () => { + Alert.alert( + 'Hủy đơn hàng', + 'Bạn có chắc chắn muốn hủy đơn hàng này?', + [ + { text: 'Không', style: 'cancel' }, + { text: 'Có', onPress: cancelOrder, style: 'destructive' }, + ] + ); + }; + + const cancelOrder = async () => { + setCancelling(true); + + try { + const res = await api.put(`/orders/${orderId}/cancel`, undefined, { + requireAuth: true, + }); + + // Fix: 'res' is of type 'unknown', so first safely cast/type guard + if (res && typeof res === 'object' && 'success' in res && (res as any).success) { + Alert.alert('Thành công', 'Đơn hàng đã được hủy'); + loadOrderDetail(); + loadOrderProgress(); + } else { + let message = 'Không thể hủy đơn hàng'; + if (res && typeof res === 'object' && 'message' in res) { + // @ts-ignore + message = res.message || message; + } + Alert.alert('Lỗi', message); + } + } catch (error) { + Alert.alert('Lỗi', 'Không thể hủy đơn hàng'); + } finally { + setCancelling(false); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (loading) { + return ( + + + + ); + } + + if (!order) { + return ( + + Không tìm thấy đơn hàng + + ); + } + + return ( + + {/* Header */} + + router.back()}> + + + Chi tiết đơn hàng + + + + {/* Order status */} + + + + {STATUS_LABELS[order.orderStatus]} + + + Đơn hàng #{order.orderId} + + Đặt hàng: {formatDate(order.createdAt)} + + + + {/* Timeline */} + {order.orderStatus !== 'CANCELLED' && ( + + Tiến độ đơn hàng + {timeline.map((step, idx) => ( + + + + {step.completed && ( + + )} + + {idx < timeline.length - 1 && ( + + )} + + + + {STATUS_LABELS[step.status]} + + {step.timestamp && ( + + {formatDate(step.timestamp)} + + )} + + + ))} + + )} + + {/* Shipping address */} + + + Địa chỉ giao hàng + + {order.shippingAddress} + + + {/* Items */} + + + Sản phẩm + + {order.orderItems.map((item) => ( + + + {item.productName} + x{item.quantity} + + + + {item.price.toLocaleString('vi-VN')}₫ + + + {item.subtotal.toLocaleString('vi-VN')}₫ + + + + ))} + + + {/* Summary */} + + Thanh toán + + Tạm tính + + {order.totalPrice.toLocaleString('vi-VN')}₫ + + + + Phí vận chuyển + 0₫ + + + Tổng cộng + + {order.totalPrice.toLocaleString('vi-VN')}₫ + + + + + {/* Cancel Order */} + {(order.orderStatus === 'PENDING' || + order.orderStatus === 'CONFIRMED') && ( + + {cancelling ? ( + + ) : ( + Hủy đơn hàng + )} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F5F5F5', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#E0E0E0', + }, + headerTitle: { + fontSize: 18, + fontWeight: 'bold', + }, + statusSection: { + backgroundColor: '#fff', + padding: 20, + alignItems: 'center', + marginTop: 10, + }, + statusBadge: { + paddingHorizontal: 20, + paddingVertical: 8, + borderRadius: 20, + marginBottom: 12, + }, + statusText: { + color: '#fff', + fontSize: 14, + fontWeight: 'bold', + }, + orderId: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 4, + }, + orderDate: { + fontSize: 14, + color: '#666', + }, + section: { + backgroundColor: '#fff', + marginTop: 10, + padding: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 12, + }, + timelineItem: { + flexDirection: 'row', + marginBottom: 8, + }, + timelineLeft: { + alignItems: 'center', + marginRight: 12, + }, + timelineDot: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#E0E0E0', + alignItems: 'center', + justifyContent: 'center', + }, + timelineDotActive: { + backgroundColor: '#4CAF50', + }, + timelineLine: { + width: 2, + flex: 1, + backgroundColor: '#E0E0E0', + marginTop: 4, + }, + timelineLineActive: { + backgroundColor: '#4CAF50', + }, + timelineRight: { + flex: 1, + paddingBottom: 16, + }, + timelineStatus: { + fontSize: 14, + color: '#999', + marginBottom: 4, + }, + timelineStatusActive: { + color: '#333', + fontWeight: 'bold', + }, + timelineDate: { + fontSize: 12, + color: '#999', + }, + addressText: { + fontSize: 14, + color: '#333', + lineHeight: 20, + }, + productItem: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#F0F0F0', + }, + productInfo: { + flex: 1, + marginRight: 12, + }, + productName: { + fontSize: 14, + marginBottom: 4, + }, + productQuantity: { + fontSize: 12, + color: '#666', + }, + productPrices: { + alignItems: 'flex-end', + }, + productPrice: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, + productSubtotal: { + fontSize: 14, + fontWeight: 'bold', + color: '#FF6B6B', + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 8, + }, + summaryLabel: { + fontSize: 14, + color: '#666', + }, + summaryValue: { + fontSize: 14, + }, + totalRow: { + borderTopWidth: 1, + borderTopColor: '#E0E0E0', + marginTop: 8, + paddingTop: 12, + }, + totalLabel: { + fontSize: 16, + fontWeight: 'bold', + }, + totalValue: { + fontSize: 18, + fontWeight: 'bold', + color: '#FF6B6B', + }, + cancelButton: { + backgroundColor: '#EF5350', + margin: 16, + padding: 16, + borderRadius: 8, + alignItems: 'center', + }, + buttonDisabled: { + opacity: 0.6, + }, + cancelButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); \ No newline at end of file diff --git a/app/order-success.tsx b/app/order-success.tsx new file mode 100644 index 0000000..d4991b8 --- /dev/null +++ b/app/order-success.tsx @@ -0,0 +1,530 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + Alert, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import orderApi, { Order, OrderProgress } from '../services/orderApi'; + +const STATUS_COLORS: Record = { + PENDING: '#FFA726', + CONFIRMED: '#42A5F5', + SHIPPING: '#AB47BC', + DELIVERED: '#66BB6A', + CANCELLED: '#EF5350', +}; + +const STATUS_LABELS: Record = { + PENDING: 'Chờ xác nhận', + CONFIRMED: 'Đã xác nhận', + SHIPPING: 'Đang giao hàng', + DELIVERED: 'Đã giao hàng', + CANCELLED: 'Đã hủy', +}; + +export default function OrderDetailScreen() { + const router = useRouter(); + const params = useLocalSearchParams(); + const orderId = params.orderId as string; + + const [order, setOrder] = useState(null); + const [timeline, setTimeline] = useState([]); + const [loading, setLoading] = useState(true); + const [cancelling, setCancelling] = useState(false); + + useEffect(() => { + if (orderId) { + console.log("Order Success Params:", params); +console.log("Order ID:", orderId); + + loadOrderDetail(); + loadOrderProgress(); + } + }, [orderId]); + + const loadOrderDetail = async () => { + try { + const response = await orderApi.getOrderDetail(Number(orderId)); + if (response.success && response.data) { + setOrder(response.data); + } + } catch (error: any) { + console.error('Load order detail error:', error); + Alert.alert('Lỗi', 'Không thể tải thông tin đơn hàng'); + } finally { + setLoading(false); + } + }; + + const loadOrderProgress = async () => { + try { + const response = await orderApi.getOrderProgress(Number(orderId)); + if (response.success && response.data) { + setTimeline(response.data.timeline); + } + } catch (error: any) { + console.error('Load order progress error:', error); + } + }; + + const handleCancelOrder = () => { + Alert.alert( + 'Hủy đơn hàng', + 'Bạn có chắc chắn muốn hủy đơn hàng này?', + [ + { text: 'Không', style: 'cancel' }, + { text: 'Có', onPress: cancelOrder, style: 'destructive' }, + ] + ); + }; + + const cancelOrder = async () => { + setCancelling(true); + try { + const response = await orderApi.cancelOrder(Number(orderId)); + if (response.success) { + Alert.alert('Thành công', 'Đơn hàng đã được hủy'); + loadOrderDetail(); + loadOrderProgress(); + } else { + Alert.alert('Lỗi', response.message || 'Không thể hủy đơn hàng'); + } + } catch (error: any) { + Alert.alert('Lỗi', error.message || 'Không thể hủy đơn hàng'); + } finally { + setCancelling(false); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatPrice = (price: number) => { + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + }).format(price); + }; + + if (loading) { + return ( + + + + + + ); + } + + if (!order) { + return ( + + + + Không tìm thấy đơn hàng + router.back()} + > + Quay lại + + + + ); + } + + return ( + + {/* Header */} + + router.push("/orders")}> + + + Chi tiết đơn hàng + + + + + {/* Order Status */} + + + + {STATUS_LABELS[order.orderStatus]} + + + Đơn hàng #{order.orderId} + + Đặt hàng: {formatDate(order.createdAt)} + + + + {/* Timeline */} + {order.orderStatus !== 'CANCELLED' && timeline.length > 0 && ( + + Tiến độ đơn hàng + {timeline.map((step, index) => ( + + + + {step.completed && ( + + )} + + {index < timeline.length - 1 && ( + + )} + + + + {STATUS_LABELS[step.status]} + + {step.timestamp && ( + + {formatDate(step.timestamp)} + + )} + + + ))} + + )} + + {/* Shipping Address */} + + + + Địa chỉ giao hàng + + {order.shippingAddress} + + + {/* Order Items */} + + + + Sản phẩm + + {order.orderItems.map((item) => ( + + + {item.productName} + x{item.quantity} + + + + {formatPrice(item.price)} + + + {formatPrice(item.subtotal)} + + + + ))} + + + {/* Payment Summary */} + + Thanh toán + + Tạm tính + + {formatPrice(order.totalPrice)} + + + + Phí vận chuyển + Miễn phí + + + Tổng cộng + + {formatPrice(order.totalPrice)} + + + + + + + + {/* Cancel Button */} + {(order.orderStatus === 'PENDING' || + order.orderStatus === 'CONFIRMED') && ( + + + {cancelling ? ( + + ) : ( + Hủy đơn hàng + )} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: '#333', + }, + scrollView: { + flex: 1, + }, + statusSection: { + backgroundColor: '#fff', + padding: 20, + alignItems: 'center', + marginTop: 12, + }, + statusBadge: { + paddingHorizontal: 20, + paddingVertical: 8, + borderRadius: 20, + marginBottom: 12, + }, + statusText: { + color: '#fff', + fontSize: 14, + fontWeight: '700', + }, + orderId: { + fontSize: 18, + fontWeight: '700', + marginBottom: 4, + color: '#333', + }, + orderDate: { + fontSize: 14, + color: '#666', + }, + section: { + backgroundColor: '#fff', + marginTop: 12, + padding: 16, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + marginLeft: 8, + color: '#333', + }, + timelineItem: { + flexDirection: 'row', + marginBottom: 8, + }, + timelineLeft: { + alignItems: 'center', + marginRight: 12, + }, + timelineDot: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#e0e0e0', + alignItems: 'center', + justifyContent: 'center', + }, + timelineDotActive: { + backgroundColor: '#4CAF50', + }, + timelineLine: { + width: 2, + flex: 1, + backgroundColor: '#e0e0e0', + marginTop: 4, + }, + timelineLineActive: { + backgroundColor: '#4CAF50', + }, + timelineRight: { + flex: 1, + paddingBottom: 16, + }, + timelineStatus: { + fontSize: 14, + color: '#999', + marginBottom: 4, + }, + timelineStatusActive: { + color: '#333', + fontWeight: '700', + }, + timelineDate: { + fontSize: 12, + color: '#999', + }, + addressText: { + fontSize: 14, + color: '#333', + lineHeight: 20, + }, + productItem: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + productInfo: { + flex: 1, + marginRight: 12, + }, + productName: { + fontSize: 15, + marginBottom: 4, + color: '#333', + }, + productQuantity: { + fontSize: 13, + color: '#666', + }, + productPrices: { + alignItems: 'flex-end', + }, + productPrice: { + fontSize: 13, + color: '#666', + marginBottom: 4, + }, + productSubtotal: { + fontSize: 15, + fontWeight: '700', + color: '#ff6b6b', + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 10, + }, + summaryLabel: { + fontSize: 15, + color: '#666', + }, + summaryValue: { + fontSize: 15, + color: '#333', + }, + totalRow: { + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + marginTop: 8, + paddingTop: 16, + }, + totalLabel: { + fontSize: 17, + fontWeight: '700', + color: '#333', + }, + totalValue: { + fontSize: 20, + fontWeight: '700', + color: '#ff6b6b', + }, + footer: { + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + }, + cancelButton: { + backgroundColor: '#EF5350', + padding: 16, + borderRadius: 12, + alignItems: 'center', + }, + buttonDisabled: { + opacity: 0.6, + }, + cancelButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + errorText: { + fontSize: 16, + color: '#999', + marginTop: 16, + marginBottom: 24, + }, + backButton: { + backgroundColor: '#ff6b6b', + paddingHorizontal: 32, + paddingVertical: 14, + borderRadius: 12, + }, + backButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, +}); \ No newline at end of file diff --git a/components/CartItemCard.tsx b/components/CartItemCard.tsx index 71f91b9..ed19d0f 100644 --- a/components/CartItemCard.tsx +++ b/components/CartItemCard.tsx @@ -85,17 +85,17 @@ export default function CartItemCard({ return ( - {item.productName} + {item.product.productName} - {formatPrice(item.price)} + {formatPrice(item.product.price)} diff --git a/components/ui/CartButton.tsx b/components/ui/CartButton.tsx new file mode 100644 index 0000000..6b9dbaa --- /dev/null +++ b/components/ui/CartButton.tsx @@ -0,0 +1,82 @@ +import React, { useState, useEffect } from 'react'; +import { TouchableOpacity, View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export default function CartButton() { + const router = useRouter(); + const [cartCount, setCartCount] = useState(0); + + useEffect(() => { + loadCartCount(); + + // Refresh cart count khi focus + const interval = setInterval(loadCartCount, 3000); + return () => clearInterval(interval); + }, []); + + const loadCartCount = async () => { + try { + const token = await AsyncStorage.getItem('token'); + if (!token) return; + + const response = await fetch('http://YOUR_API_URL:8080/api/cart', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + const data = await response.json(); + if (data.success && data.data.cartItems) { + const totalItems = data.data.cartItems.reduce( + (sum: number, item: any) => sum + item.quantity, + 0 + ); + setCartCount(totalItems); + } + } catch (error) { + console.error('Load cart count error:', error); + } + }; + + return ( + router.push('/cart')} + > + + {cartCount > 0 && ( + + + {cartCount > 99 ? '99+' : cartCount} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'relative', + padding: 8, + }, + badge: { + position: 'absolute', + top: 4, + right: 4, + backgroundColor: '#FF6B6B', + borderRadius: 10, + minWidth: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 4, + }, + badgeText: { + color: '#fff', + fontSize: 10, + fontWeight: 'bold', + }, +}); \ No newline at end of file diff --git a/hooks/useCart.ts b/hooks/useCart.ts index defec64..084fdd6 100644 --- a/hooks/useCart.ts +++ b/hooks/useCart.ts @@ -127,21 +127,32 @@ export const useCart = () => { }, []); // Xóa tất cả sản phẩm - const clearCart = useCallback(async () => { - try { - const userId = await getUserId(); - if (!userId) return false; - - await cartService.clearCart(userId); - setCart(null); - Alert.alert('Thành công', 'Đã xóa tất cả sản phẩm'); - return true; - } catch (error) { - console.error('Clear cart error:', error); - Alert.alert('Lỗi', 'Không thể xóa giỏ hàng'); - return false; - } - }, []); + const clearCart = useCallback( + async (showAlert: boolean = true) => { + try { + const userId = await getUserId(); + if (!userId) return false; + + await cartService.clearCart(userId); + setCart(null); + + if (showAlert) { + Alert.alert('Thành công', 'Đã xóa tất cả sản phẩm'); + } + + return true; + } catch (error) { + console.error('Clear cart error:', error); + + if (showAlert) { + Alert.alert('Lỗi', 'Không thể xóa giỏ hàng'); + } + + return false; + } + }, + [] + ); // Load cart khi component mount useEffect(() => { diff --git a/services/cart.ts b/services/cart.ts index 0343794..55b54f5 100644 --- a/services/cart.ts +++ b/services/cart.ts @@ -2,12 +2,15 @@ import api from './api'; export interface CartItem { cartItemId: number; - productId: number; - productName: string; - productImage: string; quantity: number; - price: number; subtotal: number; + + product: { + productId: number; + productName: string; + price: number; + imageUrl: string; + }; } export interface CartResponse { @@ -43,12 +46,20 @@ const cartService = { // Lấy danh sách sản phẩm trong giỏ hàng getCart: async (userId: number): Promise => { - console.log('=== GET CART REQUEST ===', { userId }); const response = await api.get( `/cart/${userId}`, { requireAuth: true } ); - console.log('=== GET CART RESPONSE ===', response); + + // MAP lại items để UI dùng dễ hơn + response.items = response.items.map(item => ({ + ...item, + productName: item.product.productName, + productImage: item.product.imageUrl, + price: Number(item.product.price), + subtotal: Number(item.subtotal), + })); + return response; }, diff --git a/services/orderApi.ts b/services/orderApi.ts new file mode 100644 index 0000000..52070e8 --- /dev/null +++ b/services/orderApi.ts @@ -0,0 +1,136 @@ +import api from './api'; + +export interface CreateOrderRequest { + shippingAddress: string; + items: { + productId: number; + quantity: number; + }[]; +} + +export interface OrderItem { + orderItemId: number; + productId: number; + productName: string; + quantity: number; + price: number; + subtotal: number; +} + +export interface Order { + orderId: number; + userId: number; + userName: string; + totalPrice: number; + orderStatus: string; + shippingAddress: string; + createdAt: string; + updatedAt: string; + orderItems: OrderItem[]; +} + +export interface OrderProgress { + orderId: number; + orderStatus: string; + timeline: { + status: string; + timestamp: string | null; + completed: boolean; + }[]; +} + +export interface ApiResponse { + success: boolean; + message?: string; + data?: T; + total?: number; +} + +class OrderApiService { + /** + * Tạo đơn hàng + */ + async createOrder(userId: number,orderData: CreateOrderRequest): Promise> { + try { + return await api.post(`/orders/${userId}`, orderData, { + requireAuth: true + }); + } catch (error) { + console.error('Create order error:', error); + throw error; + } + } + + /** + * Lấy tất cả đơn hàng + */ + async getAllOrders(): Promise> { + try { + return await api.get>('/orders', { + requireAuth: true + }); + } catch (error) { + console.error('Get all orders error:', error); + throw error; + } + } + + /** + * Lọc đơn hàng theo trạng thái + */ + async getOrdersByStatus(status: string): Promise> { + try { + return await api.get>( + `/orders/filter?status=${status}`, + { requireAuth: true } + ); + } catch (error) { + console.error('Get orders by status error:', error); + throw error; + } + } + + /** + * Lấy chi tiết đơn hàng + */ + async getOrderDetail(orderId: number): Promise> { + try { + return await api.get>(`/orders/${orderId}`, { + requireAuth: true + }); + } catch (error) { + console.error('Get order detail error:', error); + throw error; + } + } + + /** + * Lấy tiến độ đơn hàng + */ + async getOrderProgress(orderId: number): Promise> { + try { + return api.get(`/orders/${orderId}/progress`, { requireAuth: true }); + } catch (error) { + console.error('Get order progress error:', error); + throw error; + } + } + + /** + * Hủy đơn hàng + */ + async cancelOrder(orderId: number): Promise> { + try { + return await api.put>( + `/orders/${orderId}/cancel`, + undefined, + { requireAuth: true } + ); + } catch (error) { + console.error('Cancel order error:', error); + throw error; + } + } +} + +export default new OrderApiService(); \ No newline at end of file