This commit is contained in:
2025-11-18 16:18:43 +07:00
parent f150a84cf0
commit 08b352f686
10 changed files with 2140 additions and 27 deletions

View File

@@ -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
View 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
View 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}>
{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 đơ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
View 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
View 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',
},
});

View File

@@ -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}>

View 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',
},
});

View File

@@ -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); await cartService.clearCart(userId);
setCart(null); setCart(null);
Alert.alert('Thành công', 'Đã xóa tất cả sản phẩm');
return true; if (showAlert) {
} catch (error) { Alert.alert('Thành công', 'Đã xóa tất cả sản phẩm');
console.error('Clear cart error:', error); }
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(() => {

View File

@@ -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
View 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();