order
This commit is contained in:
@@ -11,11 +11,12 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
import { useCart } from '../../hooks/useCart';
|
import { useCart } from '../../hooks/useCart';
|
||||||
import CartItemCard from '../../components/CartItemCard';
|
import CartItemCard from '../../components/CartItemCard';
|
||||||
|
|
||||||
export default function CartScreen() {
|
export default function CartScreen() {
|
||||||
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
cart,
|
cart,
|
||||||
loading,
|
loading,
|
||||||
@@ -42,7 +43,7 @@ export default function CartScreen() {
|
|||||||
{ text: 'Hủy', style: 'cancel' },
|
{ text: 'Hủy', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Xóa tất cả',
|
text: 'Xóa tất cả',
|
||||||
onPress: clearCart,
|
onPress: () => clearCart(),
|
||||||
style: 'destructive'
|
style: 'destructive'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -50,7 +51,13 @@ export default function CartScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckout = () => {
|
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) => {
|
const formatPrice = (price: number) => {
|
||||||
@@ -84,6 +91,12 @@ export default function CartScreen() {
|
|||||||
<Text style={styles.emptySubtext}>
|
<Text style={styles.emptySubtext}>
|
||||||
Hãy thêm sản phẩm vào giỏ hàng để mua sắm
|
Hãy thêm sản phẩm vào giỏ hàng để mua sắm
|
||||||
</Text>
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.shopButton}
|
||||||
|
onPress={() => router.push('/')}
|
||||||
|
>
|
||||||
|
<Text style={styles.shopButtonText}>Tiếp tục mua sắm</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -202,6 +215,18 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
shopButton: {
|
||||||
|
backgroundColor: '#ff6b6b',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
shopButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
},
|
||||||
|
|||||||
434
app/(tabs)/checkout.tsx
Normal file
434
app/(tabs)/checkout.tsx
Normal file
@@ -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 (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#ff6b6b" />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Thanh toán</Text>
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollView}>
|
||||||
|
{/* Shipping Address */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Ionicons name="location" size={20} color="#ff6b6b" />
|
||||||
|
<Text style={styles.sectionTitle}>Địa chỉ giao hàng</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Số nhà, tên đường, phường/xã"
|
||||||
|
value={shippingAddress}
|
||||||
|
onChangeText={setShippingAddress}
|
||||||
|
multiline
|
||||||
|
numberOfLines={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Số điện thoại (10 chữ số)"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChangeText={setPhoneNumber}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
maxLength={10}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Ghi chú cho người giao hàng (không bắt buộc)"
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
multiline
|
||||||
|
numberOfLines={2}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order Items */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Ionicons name="cart" size={20} color="#ff6b6b" />
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Sản phẩm ({cart.items.length})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{cart.items.map((item) => (
|
||||||
|
<View key={item.cartItemId} style={styles.cartItem}>
|
||||||
|
<View style={styles.itemInfo}>
|
||||||
|
<Text style={styles.itemName} numberOfLines={2}>
|
||||||
|
{item.product.productName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.itemPrice}>
|
||||||
|
{formatPrice(item.product.price)} x {item.quantity}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.itemTotal}>
|
||||||
|
{formatPrice(item.product.price * item.quantity)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Payment Method */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Ionicons name="card" size={20} color="#ff6b6b" />
|
||||||
|
<Text style={styles.sectionTitle}>Phương thức thanh toán</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{PAYMENT_METHODS.map((method) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={method.id}
|
||||||
|
style={[
|
||||||
|
styles.paymentMethod,
|
||||||
|
selectedPayment === method.id && styles.paymentMethodActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedPayment(method.id)}
|
||||||
|
>
|
||||||
|
<View style={styles.paymentMethodInfo}>
|
||||||
|
<Ionicons name={method.icon as any} size={24} color="#333" />
|
||||||
|
<Text style={styles.paymentMethodName}>{method.name}</Text>
|
||||||
|
</View>
|
||||||
|
{selectedPayment === method.id && (
|
||||||
|
<Ionicons name="checkmark-circle" size={24} color="#ff6b6b" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order Summary */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Chi tiết thanh toán</Text>
|
||||||
|
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Tạm tính</Text>
|
||||||
|
<Text style={styles.summaryValue}>
|
||||||
|
{formatPrice(cart.totalAmount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Phí vận chuyển</Text>
|
||||||
|
<Text style={styles.summaryValue}>Miễn phí</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.summaryRow, styles.totalRow]}>
|
||||||
|
<Text style={styles.totalLabel}>Tổng cộng</Text>
|
||||||
|
<Text style={styles.totalValue}>
|
||||||
|
{formatPrice(cart.totalAmount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: 100 }} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Order Button */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.orderButton, loading && styles.orderButtonDisabled]}
|
||||||
|
onPress={handleCreateOrder}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.orderButtonText}>Đặt hàng</Text>
|
||||||
|
<Text style={styles.orderButtonSubtext}>
|
||||||
|
{formatPrice(cart.totalAmount)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
375
app/(tabs)/orders.tsx
Normal file
375
app/(tabs)/orders.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
PENDING: '#FFA726',
|
||||||
|
CONFIRMED: '#42A5F5',
|
||||||
|
SHIPPING: '#AB47BC',
|
||||||
|
DELIVERED: '#66BB6A',
|
||||||
|
CANCELLED: '#EF5350',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
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<Order[]>([]);
|
||||||
|
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 }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.orderCard}
|
||||||
|
onPress={() => router.push(`/order-detail?id=${item.orderId}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.orderHeader}>
|
||||||
|
<Text style={styles.orderId}>Đơn hàng #{item.orderId}</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: STATUS_COLORS[item.orderStatus] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{STATUS_LABELS[item.orderStatus]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.orderDate}>{formatDate(item.createdAt)}</Text>
|
||||||
|
|
||||||
|
<View style={styles.orderItems}>
|
||||||
|
{item.orderItems.slice(0, 2).map((orderItem) => (
|
||||||
|
<Text key={orderItem.orderItemId} style={styles.productName}>
|
||||||
|
• {orderItem.productName} x{orderItem.quantity}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{item.orderItems.length > 2 && (
|
||||||
|
<Text style={styles.moreItems}>
|
||||||
|
và {item.orderItems.length - 2} sản phẩm khác
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.orderFooter}>
|
||||||
|
<Text style={styles.totalLabel}>Tổng tiền:</Text>
|
||||||
|
<Text style={styles.totalPrice}>
|
||||||
|
{formatPrice(item.totalPrice)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.viewDetailButton}>
|
||||||
|
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#ff6b6b" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmptyList = () => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons name="receipt-outline" size={80} color="#ccc" />
|
||||||
|
<Text style={styles.emptyText}>Chưa có đơn hàng nào</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.shopButton}
|
||||||
|
onPress={() => router.push('/')}
|
||||||
|
>
|
||||||
|
<Text style={styles.shopButtonText}>Mua sắm ngay</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#ff6b6b" />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Đơn hàng của tôi</Text>
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Filter Tabs */}
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={FILTER_TABS}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.tab,
|
||||||
|
activeTab === item.key && styles.activeTab,
|
||||||
|
]}
|
||||||
|
onPress={() => setActiveTab(item.key)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabText,
|
||||||
|
activeTab === item.key && styles.activeTabText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Orders List */}
|
||||||
|
<FlatList
|
||||||
|
data={orders}
|
||||||
|
renderItem={renderOrderItem}
|
||||||
|
keyExtractor={(item) => item.orderId.toString()}
|
||||||
|
contentContainerStyle={orders.length > 0 ? styles.listContainer : styles.emptyListContainer}
|
||||||
|
ListEmptyComponent={renderEmptyList}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
colors={['#ff6b6b']}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
509
app/order-detail.tsx
Normal file
509
app/order-detail.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
PENDING: '#FFA726',
|
||||||
|
CONFIRMED: '#42A5F5',
|
||||||
|
SHIPPING: '#AB47BC',
|
||||||
|
DELIVERED: '#66BB6A',
|
||||||
|
CANCELLED: '#EF5350',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
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<OrderDetail | null>(null);
|
||||||
|
const [timeline, setTimeline] = useState<Timeline[]>([]);
|
||||||
|
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 (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#FF6B6B" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text>Không tìm thấy đơn hàng</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color="#000" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Chi tiết đơn hàng</Text>
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order status */}
|
||||||
|
<View style={styles.statusSection}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: STATUS_COLORS[order.orderStatus] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{STATUS_LABELS[order.orderStatus]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.orderId}>Đơn hàng #{order.orderId}</Text>
|
||||||
|
<Text style={styles.orderDate}>
|
||||||
|
Đặt hàng: {formatDate(order.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{order.orderStatus !== 'CANCELLED' && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Tiến độ đơn hàng</Text>
|
||||||
|
{timeline.map((step, idx) => (
|
||||||
|
<View key={idx} style={styles.timelineItem}>
|
||||||
|
<View style={styles.timelineLeft}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineDot,
|
||||||
|
step.completed && styles.timelineDotActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{step.completed && (
|
||||||
|
<Ionicons name="checkmark" size={16} color="#fff" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{idx < timeline.length - 1 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineLine,
|
||||||
|
step.completed && styles.timelineLineActive,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.timelineRight}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.timelineStatus,
|
||||||
|
step.completed && styles.timelineStatusActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[step.status]}
|
||||||
|
</Text>
|
||||||
|
{step.timestamp && (
|
||||||
|
<Text style={styles.timelineDate}>
|
||||||
|
{formatDate(step.timestamp)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shipping address */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
<Ionicons name="location" size={18} color="#333" /> Địa chỉ giao hàng
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.addressText}>{order.shippingAddress}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
<Ionicons name="cart" size={18} color="#333" /> Sản phẩm
|
||||||
|
</Text>
|
||||||
|
{order.orderItems.map((item) => (
|
||||||
|
<View key={item.orderItemId} style={styles.productItem}>
|
||||||
|
<View style={styles.productInfo}>
|
||||||
|
<Text style={styles.productName}>{item.productName}</Text>
|
||||||
|
<Text style={styles.productQuantity}>x{item.quantity}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.productPrices}>
|
||||||
|
<Text style={styles.productPrice}>
|
||||||
|
{item.price.toLocaleString('vi-VN')}₫
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.productSubtotal}>
|
||||||
|
{item.subtotal.toLocaleString('vi-VN')}₫
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Thanh toán</Text>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Tạm tính</Text>
|
||||||
|
<Text style={styles.summaryValue}>
|
||||||
|
{order.totalPrice.toLocaleString('vi-VN')}₫
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Phí vận chuyển</Text>
|
||||||
|
<Text style={styles.summaryValue}>0₫</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.summaryRow, styles.totalRow]}>
|
||||||
|
<Text style={styles.totalLabel}>Tổng cộng</Text>
|
||||||
|
<Text style={styles.totalValue}>
|
||||||
|
{order.totalPrice.toLocaleString('vi-VN')}₫
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Cancel Order */}
|
||||||
|
{(order.orderStatus === 'PENDING' ||
|
||||||
|
order.orderStatus === 'CONFIRMED') && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cancelButton, cancelling && styles.buttonDisabled]}
|
||||||
|
onPress={handleCancelOrder}
|
||||||
|
disabled={cancelling}
|
||||||
|
>
|
||||||
|
{cancelling ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.cancelButtonText}>Hủy đơn hàng</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ height: 20 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
530
app/order-success.tsx
Normal file
530
app/order-success.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
PENDING: '#FFA726',
|
||||||
|
CONFIRMED: '#42A5F5',
|
||||||
|
SHIPPING: '#AB47BC',
|
||||||
|
DELIVERED: '#66BB6A',
|
||||||
|
CANCELLED: '#EF5350',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
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<Order | null>(null);
|
||||||
|
const [timeline, setTimeline] = useState<OrderProgress['timeline']>([]);
|
||||||
|
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 (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#ff6b6b" />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Ionicons name="alert-circle-outline" size={80} color="#ccc" />
|
||||||
|
<Text style={styles.errorText}>Không tìm thấy đơn hàng</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<Text style={styles.backButtonText}>Quay lại</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.push("/orders")}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Chi tiết đơn hàng</Text>
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollView}>
|
||||||
|
{/* Order Status */}
|
||||||
|
<View style={styles.statusSection}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: STATUS_COLORS[order.orderStatus] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{STATUS_LABELS[order.orderStatus]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.orderId}>Đơn hàng #{order.orderId}</Text>
|
||||||
|
<Text style={styles.orderDate}>
|
||||||
|
Đặt hàng: {formatDate(order.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{order.orderStatus !== 'CANCELLED' && timeline.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Tiến độ đơn hàng</Text>
|
||||||
|
{timeline.map((step, index) => (
|
||||||
|
<View key={index} style={styles.timelineItem}>
|
||||||
|
<View style={styles.timelineLeft}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineDot,
|
||||||
|
step.completed && styles.timelineDotActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{step.completed && (
|
||||||
|
<Ionicons name="checkmark" size={16} color="#fff" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{index < timeline.length - 1 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineLine,
|
||||||
|
step.completed && styles.timelineLineActive,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.timelineRight}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.timelineStatus,
|
||||||
|
step.completed && styles.timelineStatusActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[step.status]}
|
||||||
|
</Text>
|
||||||
|
{step.timestamp && (
|
||||||
|
<Text style={styles.timelineDate}>
|
||||||
|
{formatDate(step.timestamp)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shipping Address */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Ionicons name="location" size={18} color="#ff6b6b" />
|
||||||
|
<Text style={styles.sectionTitle}>Địa chỉ giao hàng</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.addressText}>{order.shippingAddress}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order Items */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Ionicons name="cart" size={18} color="#ff6b6b" />
|
||||||
|
<Text style={styles.sectionTitle}>Sản phẩm</Text>
|
||||||
|
</View>
|
||||||
|
{order.orderItems.map((item) => (
|
||||||
|
<View key={item.orderItemId} style={styles.productItem}>
|
||||||
|
<View style={styles.productInfo}>
|
||||||
|
<Text style={styles.productName}>{item.productName}</Text>
|
||||||
|
<Text style={styles.productQuantity}>x{item.quantity}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.productPrices}>
|
||||||
|
<Text style={styles.productPrice}>
|
||||||
|
{formatPrice(item.price)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.productSubtotal}>
|
||||||
|
{formatPrice(item.subtotal)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Payment Summary */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Thanh toán</Text>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Tạm tính</Text>
|
||||||
|
<Text style={styles.summaryValue}>
|
||||||
|
{formatPrice(order.totalPrice)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Phí vận chuyển</Text>
|
||||||
|
<Text style={styles.summaryValue}>Miễn phí</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.summaryRow, styles.totalRow]}>
|
||||||
|
<Text style={styles.totalLabel}>Tổng cộng</Text>
|
||||||
|
<Text style={styles.totalValue}>
|
||||||
|
{formatPrice(order.totalPrice)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: 100 }} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Cancel Button */}
|
||||||
|
{(order.orderStatus === 'PENDING' ||
|
||||||
|
order.orderStatus === 'CONFIRMED') && (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cancelButton, cancelling && styles.buttonDisabled]}
|
||||||
|
onPress={handleCancelOrder}
|
||||||
|
disabled={cancelling}
|
||||||
|
>
|
||||||
|
{cancelling ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.cancelButtonText}>Hủy đơn hàng</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -85,17 +85,17 @@ export default function CartItemCard({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: item.productImage }}
|
source={{ uri: item.product.imageUrl }}
|
||||||
style={styles.image}
|
style={styles.image}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.info}>
|
<View style={styles.info}>
|
||||||
<Text style={styles.name} numberOfLines={2}>
|
<Text style={styles.name} numberOfLines={2}>
|
||||||
{item.productName}
|
{item.product.productName}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.price}>{formatPrice(item.price)}</Text>
|
<Text style={styles.price}>{formatPrice(item.product.price)}</Text>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={styles.quantityContainer}>
|
<View style={styles.quantityContainer}>
|
||||||
|
|||||||
82
components/ui/CartButton.tsx
Normal file
82
components/ui/CartButton.tsx
Normal file
@@ -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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.container}
|
||||||
|
onPress={() => router.push('/cart')}
|
||||||
|
>
|
||||||
|
<Ionicons name="cart-outline" size={24} color="#333" />
|
||||||
|
{cartCount > 0 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{cartCount > 99 ? '99+' : cartCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -127,21 +127,32 @@ export const useCart = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Xóa tất cả sản phẩm
|
// Xóa tất cả sản phẩm
|
||||||
const clearCart = useCallback(async () => {
|
const clearCart = useCallback(
|
||||||
try {
|
async (showAlert: boolean = true) => {
|
||||||
const userId = await getUserId();
|
try {
|
||||||
if (!userId) return false;
|
const userId = await getUserId();
|
||||||
|
if (!userId) return false;
|
||||||
await cartService.clearCart(userId);
|
|
||||||
setCart(null);
|
await cartService.clearCart(userId);
|
||||||
Alert.alert('Thành công', 'Đã xóa tất cả sản phẩm');
|
setCart(null);
|
||||||
return true;
|
|
||||||
} catch (error) {
|
if (showAlert) {
|
||||||
console.error('Clear cart error:', error);
|
Alert.alert('Thành công', 'Đã xóa tất cả sản phẩm');
|
||||||
Alert.alert('Lỗi', 'Không thể xóa giỏ hàng');
|
}
|
||||||
return false;
|
|
||||||
}
|
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
|
// Load cart khi component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import api from './api';
|
|||||||
|
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
cartItemId: number;
|
cartItemId: number;
|
||||||
productId: number;
|
|
||||||
productName: string;
|
|
||||||
productImage: string;
|
|
||||||
quantity: number;
|
quantity: number;
|
||||||
price: number;
|
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
|
|
||||||
|
product: {
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CartResponse {
|
export interface CartResponse {
|
||||||
@@ -43,12 +46,20 @@ const cartService = {
|
|||||||
|
|
||||||
// Lấy danh sách sản phẩm trong giỏ hàng
|
// Lấy danh sách sản phẩm trong giỏ hàng
|
||||||
getCart: async (userId: number): Promise<CartResponse> => {
|
getCart: async (userId: number): Promise<CartResponse> => {
|
||||||
console.log('=== GET CART REQUEST ===', { userId });
|
|
||||||
const response = await api.get<CartResponse>(
|
const response = await api.get<CartResponse>(
|
||||||
`/cart/${userId}`,
|
`/cart/${userId}`,
|
||||||
{ requireAuth: true }
|
{ 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;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
136
services/orderApi.ts
Normal file
136
services/orderApi.ts
Normal file
@@ -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<T> {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: T;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderApiService {
|
||||||
|
/**
|
||||||
|
* Tạo đơn hàng
|
||||||
|
*/
|
||||||
|
async createOrder(userId: number,orderData: CreateOrderRequest): Promise<ApiResponse<Order>> {
|
||||||
|
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<ApiResponse<Order[]>> {
|
||||||
|
try {
|
||||||
|
return await api.get<ApiResponse<Order[]>>('/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<ApiResponse<Order[]>> {
|
||||||
|
try {
|
||||||
|
return await api.get<ApiResponse<Order[]>>(
|
||||||
|
`/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<ApiResponse<Order>> {
|
||||||
|
try {
|
||||||
|
return await api.get<ApiResponse<Order>>(`/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<ApiResponse<OrderProgress>> {
|
||||||
|
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<ApiResponse<Order>> {
|
||||||
|
try {
|
||||||
|
return await api.put<ApiResponse<Order>>(
|
||||||
|
`/orders/${orderId}/cancel`,
|
||||||
|
undefined,
|
||||||
|
{ requireAuth: true }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cancel order error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new OrderApiService();
|
||||||
Reference in New Issue
Block a user