cart
This commit is contained in:
253
app/(tabs)/cart.tsx
Normal file
253
app/(tabs)/cart.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { useCart } from '../../hooks/useCart';
|
||||||
|
import CartItemCard from '../../components/CartItemCard';
|
||||||
|
|
||||||
|
export default function CartScreen() {
|
||||||
|
const {
|
||||||
|
cart,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
refreshCart,
|
||||||
|
updateQuantity,
|
||||||
|
removeItem,
|
||||||
|
clearCart,
|
||||||
|
} = useCart();
|
||||||
|
|
||||||
|
// Tự động refresh cart mỗi khi vào trang
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
console.log('=== CartScreen focused - Refreshing cart ===');
|
||||||
|
refreshCart();
|
||||||
|
}, [refreshCart])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearCart = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Xác nhận',
|
||||||
|
'Bạn có chắc muốn xóa tất cả sản phẩm trong giỏ hàng?',
|
||||||
|
[
|
||||||
|
{ text: 'Hủy', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Xóa tất cả',
|
||||||
|
onPress: clearCart,
|
||||||
|
style: 'destructive'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckout = () => {
|
||||||
|
Alert.alert('Thông báo', 'Chức năng thanh toán đang được phát triển');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'VND',
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !cart) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#ff6b6b" />
|
||||||
|
<Text style={styles.loadingText}>Đang tải giỏ hàng...</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cart || cart.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Giỏ hàng</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons name="cart-outline" size={100} color="#ccc" />
|
||||||
|
<Text style={styles.emptyText}>Giỏ hàng trống</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
Hãy thêm sản phẩm vào giỏ hàng để mua sắm
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Giỏ hàng</Text>
|
||||||
|
<TouchableOpacity onPress={handleClearCart} style={styles.clearButton}>
|
||||||
|
<Ionicons name="trash-outline" size={22} color="#ff4444" />
|
||||||
|
<Text style={styles.clearText}>Xóa tất cả</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={cart.items}
|
||||||
|
keyExtractor={(item) => item.cartItemId.toString()}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<CartItemCard
|
||||||
|
item={item}
|
||||||
|
onUpdateQuantity={updateQuantity}
|
||||||
|
onRemove={removeItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={refreshCart}
|
||||||
|
colors={['#ff6b6b']}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Tổng số lượng:</Text>
|
||||||
|
<Text style={styles.summaryValue}>{cart.totalItems} sản phẩm</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.totalLabel}>Tổng tiền:</Text>
|
||||||
|
<Text style={styles.totalValue}>
|
||||||
|
{formatPrice(cart.totalAmount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.checkoutButton}
|
||||||
|
onPress={handleCheckout}
|
||||||
|
>
|
||||||
|
<Text style={styles.checkoutText}>Thanh toán</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={20} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e0e0e0',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
clearText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#ff4444',
|
||||||
|
marginLeft: 4,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#e0e0e0',
|
||||||
|
},
|
||||||
|
summaryRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#333',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#ff6b6b',
|
||||||
|
},
|
||||||
|
checkoutButton: {
|
||||||
|
backgroundColor: '#ff6b6b',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
checkoutText: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#fff',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import React from 'react';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
ScrollView,
|
|
||||||
Image,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useCart } from '../../../hooks/useCart';
|
||||||
import { useProduct } from '../../../hooks/useProducts';
|
import { useProduct } from '../../../hooks/useProducts';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ export default function ProductDetailScreen() {
|
|||||||
const productId = typeof id === 'string' ? parseInt(id) : null;
|
const productId = typeof id === 'string' ? parseInt(id) : null;
|
||||||
|
|
||||||
const { product, loading, error } = useProduct(productId);
|
const { product, loading, error } = useProduct(productId);
|
||||||
|
const { addToCart } = useCart();
|
||||||
|
const [isAddingToCart, setIsAddingToCart] = useState(false);
|
||||||
|
|
||||||
const formatPrice = (price: number) => {
|
const formatPrice = (price: number) => {
|
||||||
return new Intl.NumberFormat('vi-VN', {
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
@@ -38,6 +41,19 @@ export default function ProductDetailScreen() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddToCart = async () => {
|
||||||
|
if (!product || product.stockQuantity <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAddingToCart(true);
|
||||||
|
try {
|
||||||
|
await addToCart(product.productId, 1);
|
||||||
|
} finally {
|
||||||
|
setIsAddingToCart(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
@@ -167,16 +183,36 @@ export default function ProductDetailScreen() {
|
|||||||
{/* Bottom Action Buttons */}
|
{/* Bottom Action Buttons */}
|
||||||
<View style={styles.bottomActions}>
|
<View style={styles.bottomActions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionButton, styles.addToCartButton]}
|
style={[
|
||||||
disabled={product.stockQuantity === 0}
|
styles.actionButton,
|
||||||
|
styles.addToCartButton,
|
||||||
|
(product.stockQuantity === 0 || isAddingToCart) && styles.actionButtonDisabled
|
||||||
|
]}
|
||||||
|
onPress={handleAddToCart}
|
||||||
|
disabled={product.stockQuantity === 0 || isAddingToCart}
|
||||||
>
|
>
|
||||||
|
{isAddingToCart ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
<Ionicons name="cart-outline" size={24} color="#fff" />
|
<Ionicons name="cart-outline" size={24} color="#fff" />
|
||||||
|
)}
|
||||||
<Text style={styles.actionButtonText}>
|
<Text style={styles.actionButtonText}>
|
||||||
{product.stockQuantity > 0 ? 'Thêm vào giỏ' : 'Hết hàng'}
|
{isAddingToCart
|
||||||
|
? 'Đang thêm...'
|
||||||
|
: product.stockQuantity > 0
|
||||||
|
? 'Thêm vào giỏ'
|
||||||
|
: 'Hết hàng'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity style={[styles.actionButton, styles.buyNowButton]}>
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
styles.buyNowButton,
|
||||||
|
product.stockQuantity === 0 && styles.actionButtonDisabled
|
||||||
|
]}
|
||||||
|
disabled={product.stockQuantity === 0}
|
||||||
|
>
|
||||||
<Ionicons name="flash-outline" size={24} color="#fff" />
|
<Ionicons name="flash-outline" size={24} color="#fff" />
|
||||||
<Text style={styles.actionButtonText}>Mua ngay</Text>
|
<Text style={styles.actionButtonText}>Mua ngay</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -344,6 +380,9 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
},
|
},
|
||||||
|
actionButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
200
components/CartItemCard.tsx
Normal file
200
components/CartItemCard.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { CartItem } from '../services/cart';
|
||||||
|
|
||||||
|
interface CartItemCardProps {
|
||||||
|
item: CartItem;
|
||||||
|
onUpdateQuantity: (cartItemId: number, quantity: number) => Promise<boolean>;
|
||||||
|
onRemove: (cartItemId: number) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CartItemCard({
|
||||||
|
item,
|
||||||
|
onUpdateQuantity,
|
||||||
|
onRemove,
|
||||||
|
}: CartItemCardProps) {
|
||||||
|
const handleDecrease = async () => {
|
||||||
|
if (item.quantity > 1) {
|
||||||
|
const success = await onUpdateQuantity(item.cartItemId, item.quantity - 1);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert('Lỗi', 'Không thể cập nhật số lượng');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'Xác nhận',
|
||||||
|
'Bạn có muốn xóa sản phẩm này khỏi giỏ hàng?',
|
||||||
|
[
|
||||||
|
{ text: 'Hủy', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Xóa',
|
||||||
|
onPress: async () => {
|
||||||
|
const success = await onRemove(item.cartItemId);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert('Lỗi', 'Không thể xóa sản phẩm');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: 'destructive'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncrease = async () => {
|
||||||
|
const success = await onUpdateQuantity(item.cartItemId, item.quantity + 1);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert('Lỗi', 'Không thể cập nhật số lượng');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Xác nhận',
|
||||||
|
'Bạn có chắc muốn xóa sản phẩm này?',
|
||||||
|
[
|
||||||
|
{ text: 'Hủy', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Xóa',
|
||||||
|
onPress: async () => {
|
||||||
|
const success = await onRemove(item.cartItemId);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert('Lỗi', 'Không thể xóa sản phẩm');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: 'destructive'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'VND',
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.productImage }}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.name} numberOfLines={2}>
|
||||||
|
{item.productName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.price}>{formatPrice(item.price)}</Text>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.quantityContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.quantityButton}
|
||||||
|
onPress={handleDecrease}
|
||||||
|
>
|
||||||
|
<Ionicons name="remove" size={18} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.quantity}>{item.quantity}</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.quantityButton}
|
||||||
|
onPress={handleIncrease}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={18} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.subtotal}>{formatPrice(item.subtotal)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.deleteButton} onPress={handleRemove}>
|
||||||
|
<Ionicons name="trash-outline" size={20} color="#ff4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#ff6b6b',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
quantityContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
quantityButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
minWidth: 30,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtotal: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#ff6b6b',
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: 8,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Image } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { ProductResponse } from '../services/product';
|
import { ProductResponse } from '../services/product';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useCart } from '../hooks/useCart';
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: ProductResponse;
|
product: ProductResponse;
|
||||||
@@ -9,11 +11,28 @@ interface ProductCardProps {
|
|||||||
|
|
||||||
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { addToCart } = useCart();
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
router.push(`/products/${product.productId}`);
|
router.push(`/products/${product.productId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddToCart = async (e: any) => {
|
||||||
|
e.stopPropagation(); // Ngăn chặn việc navigate khi click vào nút giỏ hàng
|
||||||
|
|
||||||
|
if (product.stockQuantity <= 0) {
|
||||||
|
return; // Không cho thêm vào giỏ nếu hết hàng
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
await addToCart(product.productId, 1);
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatPrice = (price: number) => {
|
const formatPrice = (price: number) => {
|
||||||
return new Intl.NumberFormat('vi-VN', {
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -23,30 +42,60 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
|
||||||
|
<View style={styles.imageContainer}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: product.imageUrl || 'https://via.placeholder.com/150' }}
|
source={{ uri: product.imageUrl || 'https://via.placeholder.com/150' }}
|
||||||
style={styles.image}
|
style={styles.image}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Category Badge */}
|
||||||
|
<View style={styles.categoryBadge}>
|
||||||
|
<Text style={styles.categoryText}>{product.categoryName}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Out of Stock Overlay */}
|
||||||
|
{product.stockQuantity <= 0 && (
|
||||||
|
<View style={styles.outOfStockOverlay}>
|
||||||
|
<Text style={styles.outOfStockText}>HẾT HÀNG</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.productName} numberOfLines={2}>
|
<Text style={styles.productName} numberOfLines={2}>
|
||||||
{product.productName}
|
{product.productName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.categoryName} numberOfLines={1}>
|
|
||||||
{product.categoryName}
|
<View style={styles.footer}>
|
||||||
</Text>
|
<View style={styles.priceSection}>
|
||||||
<View style={styles.priceContainer}>
|
|
||||||
<Text style={styles.price}>{formatPrice(product.price)}</Text>
|
<Text style={styles.price}>{formatPrice(product.price)}</Text>
|
||||||
{product.stockQuantity > 0 ? (
|
{product.stockQuantity > 0 && (
|
||||||
<View style={styles.stockBadge}>
|
|
||||||
<Text style={styles.stockText}>Còn {product.stockQuantity}</Text>
|
<Text style={styles.stockText}>Còn {product.stockQuantity}</Text>
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.stockBadge, styles.outOfStock]}>
|
|
||||||
<Text style={[styles.stockText, styles.outOfStockText]}>Hết hàng</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Add to Cart Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.addButton,
|
||||||
|
product.stockQuantity <= 0 && styles.addButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleAddToCart}
|
||||||
|
disabled={product.stockQuantity <= 0 || isAdding}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isAdding ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name="cart-outline"
|
||||||
|
size={22}
|
||||||
|
color={product.stockQuantity <= 0 ? '#999' : '#fff'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@@ -65,11 +114,46 @@ const styles = StyleSheet.create({
|
|||||||
elevation: 3,
|
elevation: 3,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
image: {
|
imageContainer: {
|
||||||
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: 200,
|
height: 200,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
backgroundColor: '#f0f0f0',
|
backgroundColor: '#f0f0f0',
|
||||||
},
|
},
|
||||||
|
categoryBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
backgroundColor: 'rgba(255, 107, 107, 0.95)',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
outOfStockOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
outOfStockText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#fff',
|
||||||
|
letterSpacing: 2,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
padding: 12,
|
padding: 12,
|
||||||
},
|
},
|
||||||
@@ -77,39 +161,44 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#333',
|
color: '#333',
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
categoryName: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
|
minHeight: 44,
|
||||||
},
|
},
|
||||||
priceContainer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
priceSection: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
price: {
|
price: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#e91e63',
|
color: '#ff6b6b',
|
||||||
},
|
marginBottom: 2,
|
||||||
stockBadge: {
|
|
||||||
backgroundColor: '#4caf50',
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
},
|
||||||
stockText: {
|
stockText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#fff',
|
color: '#4caf50',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
outOfStock: {
|
addButton: {
|
||||||
backgroundColor: '#f44336',
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
backgroundColor: '#ff6b6b',
|
||||||
|
borderRadius: 22,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#ff6b6b',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 4,
|
||||||
},
|
},
|
||||||
outOfStockText: {
|
addButtonDisabled: {
|
||||||
color: '#fff',
|
backgroundColor: '#ccc',
|
||||||
|
shadowOpacity: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
106
hooks/useAuth.ts
106
hooks/useAuth.ts
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import * as authService from '../services/auth';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
|
// Kiểm tra trạng thái đăng nhập khi khởi động
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuthStatus = async () => {
|
||||||
|
try {
|
||||||
|
const authenticated = await authService.isAuthenticated();
|
||||||
|
setIsAuthenticated(authenticated);
|
||||||
|
|
||||||
|
if (authenticated) {
|
||||||
|
const userData = await authService.getUser();
|
||||||
|
setUser(userData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check auth status error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await authService.login(email, password);
|
||||||
|
|
||||||
|
setUser({
|
||||||
|
userId: response.userId,
|
||||||
|
email: response.email,
|
||||||
|
});
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
Alert.alert('Thành công', 'Đăng nhập thành công!');
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
Alert.alert('Lỗi', error.message || 'Đăng nhập thất bại');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await authService.register(email, password);
|
||||||
|
|
||||||
|
// Sau khi đăng ký thành công, tự động đăng nhập
|
||||||
|
if (response.token) {
|
||||||
|
await authService.saveAuthData(response);
|
||||||
|
setUser({
|
||||||
|
userId: response.userId,
|
||||||
|
email: response.email,
|
||||||
|
});
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert('Thành công', 'Đăng ký thành công!');
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Register error:', error);
|
||||||
|
Alert.alert('Lỗi', error.message || 'Đăng ký thất bại');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
const token = await authService.getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
await authService.logout(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
|
||||||
|
Alert.alert('Thành công', 'Đăng xuất thành công!');
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
Alert.alert('Lỗi', 'Đăng xuất thất bại');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
checkAuthStatus,
|
||||||
|
};
|
||||||
|
};
|
||||||
162
hooks/useCart.ts
162
hooks/useCart.ts
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import * as authService from '../services/auth';
|
||||||
|
import cartService, { AddToCartRequest, CartResponse } from '../services/cart';
|
||||||
|
|
||||||
|
export const useCart = () => {
|
||||||
|
const [cart, setCart] = useState<CartResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Lấy userId từ auth service
|
||||||
|
const getUserId = async (): Promise<number | null> => {
|
||||||
|
try {
|
||||||
|
const isAuthenticated = await authService.isAuthenticated();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
console.log('User is not authenticated');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await authService.getUser();
|
||||||
|
console.log('getUserId - User data:', user);
|
||||||
|
|
||||||
|
if (user && typeof user.userId === 'number') {
|
||||||
|
return user.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
console.warn('User exists but userId is missing or invalid:', user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting userId:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tải giỏ hàng
|
||||||
|
const loadCart = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const userId = await getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
setCart(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await cartService.getCart(userId);
|
||||||
|
setCart(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Load cart error:', error);
|
||||||
|
const errorMessage = error?.message || '';
|
||||||
|
|
||||||
|
if (errorMessage.includes('404') || errorMessage.includes('Not Found') || errorMessage.includes('not found')) {
|
||||||
|
setCart(null);
|
||||||
|
} else if (!errorMessage.includes('User not logged in')) {
|
||||||
|
console.warn('Failed to load cart:', errorMessage);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh giỏ hàng
|
||||||
|
const refreshCart = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadCart();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [loadCart]);
|
||||||
|
|
||||||
|
// Thêm sản phẩm vào giỏ hàng
|
||||||
|
const addToCart = useCallback(async (productId: number, quantity: number = 1) => {
|
||||||
|
try {
|
||||||
|
console.log('=== useCart: addToCart START ===', { productId, quantity });
|
||||||
|
const userId = await getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
Alert.alert('Thông báo', 'Vui lòng đăng nhập để thêm vào giỏ hàng');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCart = await cartService.addToCart(userId, { productId, quantity });
|
||||||
|
console.log('=== useCart: addToCart SUCCESS - Setting cart ===', updatedCart);
|
||||||
|
setCart(updatedCart); // Cập nhật state ngay lập tức
|
||||||
|
Alert.alert('Thành công', 'Đã thêm sản phẩm vào giỏ hàng');
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('=== useCart: addToCart ERROR ===', error);
|
||||||
|
Alert.alert('Lỗi', 'Không thể thêm sản phẩm vào giỏ hàng');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cập nhật số lượng sản phẩm
|
||||||
|
const updateQuantity = useCallback(async (cartItemId: number, quantity: number) => {
|
||||||
|
try {
|
||||||
|
const userId = await getUserId();
|
||||||
|
if (!userId) return false;
|
||||||
|
|
||||||
|
const updatedCart = await cartService.updateCartItem(userId, cartItemId, { quantity });
|
||||||
|
setCart(updatedCart); // Cập nhật state ngay lập tức
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update quantity error:', error);
|
||||||
|
Alert.alert('Lỗi', 'Không thể cập nhật số lượng');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Xóa một sản phẩm
|
||||||
|
const removeItem = useCallback(async (cartItemId: number) => {
|
||||||
|
try {
|
||||||
|
const userId = await getUserId();
|
||||||
|
if (!userId) return false;
|
||||||
|
|
||||||
|
const updatedCart = await cartService.removeCartItem(userId, cartItemId);
|
||||||
|
setCart(updatedCart); // Cập nhật state ngay lập tức
|
||||||
|
Alert.alert('Thành công', 'Đã xóa sản phẩm khỏi giỏ hàng');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Remove item error:', error);
|
||||||
|
Alert.alert('Lỗi', 'Không thể xóa sản phẩm');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Xóa tất cả sản phẩm
|
||||||
|
const clearCart = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const userId = await getUserId();
|
||||||
|
if (!userId) return false;
|
||||||
|
|
||||||
|
await cartService.clearCart(userId);
|
||||||
|
setCart(null);
|
||||||
|
Alert.alert('Thành công', 'Đã xóa tất cả sản phẩm');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clear cart error:', error);
|
||||||
|
Alert.alert('Lỗi', 'Không thể xóa giỏ hàng');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load cart khi component mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadCart();
|
||||||
|
}, [loadCart]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cart,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
loadCart,
|
||||||
|
refreshCart,
|
||||||
|
addToCart,
|
||||||
|
updateQuantity,
|
||||||
|
removeItem,
|
||||||
|
clearCart,
|
||||||
|
};
|
||||||
|
};
|
||||||
195
services/api.ts
195
services/api.ts
@@ -0,0 +1,195 @@
|
|||||||
|
import { API_CONFIG } from '../constants/Config';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const API_URL = API_CONFIG.BASE_URL;
|
||||||
|
|
||||||
|
interface RequestOptions extends RequestInit {
|
||||||
|
requireAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private baseURL: string;
|
||||||
|
|
||||||
|
constructor(baseURL: string) {
|
||||||
|
this.baseURL = baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy token từ AsyncStorage
|
||||||
|
*/
|
||||||
|
private async getAuthToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await AsyncStorage.getItem('authToken');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting auth token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tạo headers cho request
|
||||||
|
*/
|
||||||
|
private async createHeaders(requireAuth: boolean = false): Promise<HeadersInit> {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requireAuth) {
|
||||||
|
const token = await this.getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xử lý response
|
||||||
|
*/
|
||||||
|
private async handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.message || errorData.error || errorMessage;
|
||||||
|
} catch {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
errorMessage = text || errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xử lý response 204 No Content
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET request
|
||||||
|
*/
|
||||||
|
async get<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
const { requireAuth = false, ...fetchOptions } = options;
|
||||||
|
const headers = await this.createHeaders(requireAuth);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
...fetchOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.handleResponse<T>(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
throw new Error('Không thể kết nối đến server. Vui lòng kiểm tra kết nối mạng.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST request
|
||||||
|
*/
|
||||||
|
async post<T>(endpoint: string, data?: any, options: RequestOptions = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
const { requireAuth = false, ...fetchOptions } = options;
|
||||||
|
const headers = await this.createHeaders(requireAuth);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...fetchOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.handleResponse<T>(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
throw new Error('Không thể kết nối đến server. Vui lòng kiểm tra kết nối mạng.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT request
|
||||||
|
*/
|
||||||
|
async put<T>(endpoint: string, data?: any, options: RequestOptions = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
const { requireAuth = false, ...fetchOptions } = options;
|
||||||
|
const headers = await this.createHeaders(requireAuth);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...fetchOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.handleResponse<T>(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
throw new Error('Không thể kết nối đến server. Vui lòng kiểm tra kết nối mạng.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE request
|
||||||
|
*/
|
||||||
|
async delete<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
const { requireAuth = false, ...fetchOptions } = options;
|
||||||
|
const headers = await this.createHeaders(requireAuth);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
...fetchOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.handleResponse<T>(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
throw new Error('Không thể kết nối đến server. Vui lòng kiểm tra kết nối mạng.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH request
|
||||||
|
*/
|
||||||
|
async patch<T>(endpoint: string, data?: any, options: RequestOptions = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
const { requireAuth = false, ...fetchOptions } = options;
|
||||||
|
const headers = await this.createHeaders(requireAuth);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...fetchOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.handleResponse<T>(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
throw new Error('Không thể kết nối đến server. Vui lòng kiểm tra kết nối mạng.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
const api = new ApiClient(API_URL);
|
||||||
|
export default api;
|
||||||
359
services/auth.ts
359
services/auth.ts
@@ -1,28 +1,277 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
const API_URL = "http://192.168.2.141:8080/api/auth";
|
const API_URL = "http://192.168.2.141:8080/api/auth";
|
||||||
|
|
||||||
export async function register(email: string, password: string) {
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode base64Url string
|
||||||
|
* React Native compatible - không dùng atob
|
||||||
|
*/
|
||||||
|
function base64UrlDecode(str: string): string {
|
||||||
|
try {
|
||||||
|
// Replace URL-safe characters
|
||||||
|
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
|
||||||
|
// Add padding
|
||||||
|
while (base64.length % 4) {
|
||||||
|
base64 += '=';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to use atob if available (browser environment)
|
||||||
|
if (typeof atob !== 'undefined') {
|
||||||
|
return atob(base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For React Native - use Buffer or manual decode
|
||||||
|
// React Native có thể có Buffer từ polyfills
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
return Buffer.from(base64, 'base64').toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: manual base64 decode (simple implementation)
|
||||||
|
// Chỉ hoạt động với ASCII characters
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
let result = '';
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
str = str.replace(/[^A-Za-z0-9\+\/]/g, '');
|
||||||
|
|
||||||
|
while (i < str.length) {
|
||||||
|
const encoded1 = chars.indexOf(str.charAt(i++));
|
||||||
|
const encoded2 = chars.indexOf(str.charAt(i++));
|
||||||
|
const encoded3 = chars.indexOf(str.charAt(i++));
|
||||||
|
const encoded4 = chars.indexOf(str.charAt(i++));
|
||||||
|
|
||||||
|
const bitmap = (encoded1 << 18) | (encoded2 << 12) | (encoded3 << 6) | encoded4;
|
||||||
|
|
||||||
|
result += String.fromCharCode((bitmap >> 16) & 255);
|
||||||
|
if (encoded3 !== 64) result += String.fromCharCode((bitmap >> 8) & 255);
|
||||||
|
if (encoded4 !== 64) result += String.fromCharCode(bitmap & 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding base64:', error);
|
||||||
|
throw new Error('Không thể decode base64: ' + (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode JWT token và lấy payload
|
||||||
|
*/
|
||||||
|
function decodeJWT(token: string): any {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('Token không đúng format JWT (phải có 3 phần)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parts[1];
|
||||||
|
const decoded = base64UrlDecode(payload);
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding JWT:', error);
|
||||||
|
throw new Error('Không thể decode JWT token: ' + (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email: string, password: string): Promise<AuthResponse> {
|
||||||
const response = await fetch(`${API_URL}/register`, {
|
const response = await fetch(`${API_URL}/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Đăng ký thất bại");
|
if (!response.ok) {
|
||||||
return await response.json();
|
const error = await response.json().catch(() => ({ message: "Đăng ký thất bại" }));
|
||||||
|
throw new Error(error.message || "Đăng ký thất bại");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = await response.json();
|
||||||
|
console.log('Register API raw response:', JSON.stringify(rawData, null, 2));
|
||||||
|
|
||||||
|
// Kiểm tra token
|
||||||
|
if (!rawData.token) {
|
||||||
|
throw new Error('Response không có token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy userId từ response (server trả về kiểu Long)
|
||||||
|
let userId: number;
|
||||||
|
let userEmail: string = rawData.email || email;
|
||||||
|
|
||||||
|
// Lấy userId từ response - ưu tiên userId (camelCase) trước
|
||||||
|
if (rawData.userId !== undefined && rawData.userId !== null) {
|
||||||
|
userId = typeof rawData.userId === 'string' ? parseInt(rawData.userId, 10) : rawData.userId;
|
||||||
|
} else if (rawData.user_id !== undefined && rawData.user_id !== null) {
|
||||||
|
userId = typeof rawData.user_id === 'string' ? parseInt(rawData.user_id, 10) : rawData.user_id;
|
||||||
|
} else if (rawData.id !== undefined && rawData.id !== null) {
|
||||||
|
userId = typeof rawData.id === 'string' ? parseInt(rawData.id, 10) : rawData.id;
|
||||||
|
} else {
|
||||||
|
// Fallback: thử decode JWT token nếu không có trong response
|
||||||
|
console.warn('UserId không có trong response, thử decode JWT token...');
|
||||||
|
try {
|
||||||
|
const payload = decodeJWT(rawData.token);
|
||||||
|
console.log('JWT payload decoded (register):', payload);
|
||||||
|
|
||||||
|
userId = payload.userId || payload.user_id || payload.id || payload.sub;
|
||||||
|
if (typeof userId === 'string') {
|
||||||
|
userId = parseInt(userId, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId && userId !== 0) {
|
||||||
|
throw new Error('Không tìm thấy userId trong JWT token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.email) {
|
||||||
|
userEmail = payload.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Extracted userId from JWT token (register):', userId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error decoding JWT token (register):', error);
|
||||||
|
throw new Error(
|
||||||
|
'Không thể lấy userId từ response. ' +
|
||||||
|
'Response fields: ' + Object.keys(rawData).join(', ') + '. ' +
|
||||||
|
'Vui lòng đảm bảo server trả về userId trong AuthResponse.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate userId
|
||||||
|
if (userId === undefined || userId === null) {
|
||||||
|
throw new Error('UserId không tồn tại trong response');
|
||||||
|
}
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
throw new Error('UserId không hợp lệ (phải là số): ' + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Got userId from response (register):', userId, '(type: number)');
|
||||||
|
|
||||||
|
// Tạo AuthResponse với userId
|
||||||
|
const data: AuthResponse = {
|
||||||
|
token: rawData.token,
|
||||||
|
userId: userId,
|
||||||
|
email: userEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Final register data:', data);
|
||||||
|
|
||||||
|
// Validate data
|
||||||
|
if (!data.token) {
|
||||||
|
throw new Error('Token không tồn tại trong response');
|
||||||
|
}
|
||||||
|
if (data.userId === undefined || data.userId === null) {
|
||||||
|
throw new Error('UserId không tồn tại');
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveAuthData(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string): Promise<AuthResponse> {
|
||||||
const response = await fetch(`${API_URL}/login`, {
|
const response = await fetch(`${API_URL}/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Đăng nhập thất bại");
|
if (!response.ok) {
|
||||||
return await response.json();
|
const error = await response.json().catch(() => ({ message: "Đăng nhập thất bại" }));
|
||||||
|
throw new Error(error.message || "Đăng nhập thất bại");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = await response.json();
|
||||||
|
console.log('Login API raw response:', JSON.stringify(rawData, null, 2));
|
||||||
|
|
||||||
|
// Kiểm tra token
|
||||||
|
if (!rawData.token) {
|
||||||
|
throw new Error('Response không có token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy userId từ response (server trả về kiểu Long)
|
||||||
|
let userId: number;
|
||||||
|
let userEmail: string = rawData.email || email;
|
||||||
|
|
||||||
|
// Lấy userId từ response - ưu tiên userId (camelCase) trước
|
||||||
|
if (rawData.userId !== undefined && rawData.userId !== null) {
|
||||||
|
userId = typeof rawData.userId === 'string' ? parseInt(rawData.userId, 10) : rawData.userId;
|
||||||
|
} else if (rawData.user_id !== undefined && rawData.user_id !== null) {
|
||||||
|
userId = typeof rawData.user_id === 'string' ? parseInt(rawData.user_id, 10) : rawData.user_id;
|
||||||
|
} else if (rawData.id !== undefined && rawData.id !== null) {
|
||||||
|
userId = typeof rawData.id === 'string' ? parseInt(rawData.id, 10) : rawData.id;
|
||||||
|
} else {
|
||||||
|
// Fallback: thử decode JWT token nếu không có trong response
|
||||||
|
console.warn('UserId không có trong response, thử decode JWT token...');
|
||||||
|
try {
|
||||||
|
const payload = decodeJWT(rawData.token);
|
||||||
|
console.log('JWT payload decoded:', payload);
|
||||||
|
|
||||||
|
userId = payload.userId || payload.user_id || payload.id || payload.sub;
|
||||||
|
if (typeof userId === 'string') {
|
||||||
|
userId = parseInt(userId, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId && userId !== 0) {
|
||||||
|
throw new Error('Không tìm thấy userId trong JWT token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.email) {
|
||||||
|
userEmail = payload.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Extracted userId from JWT token:', userId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error decoding JWT token:', error);
|
||||||
|
throw new Error(
|
||||||
|
'Không thể lấy userId từ response. ' +
|
||||||
|
'Response fields: ' + Object.keys(rawData).join(', ') + '. ' +
|
||||||
|
'Vui lòng đảm bảo server trả về userId trong AuthResponse.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate userId
|
||||||
|
if (userId === undefined || userId === null) {
|
||||||
|
throw new Error('UserId không tồn tại trong response');
|
||||||
|
}
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
throw new Error('UserId không hợp lệ (phải là số): ' + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Got userId from response:', userId, '(type: number)');
|
||||||
|
|
||||||
|
// Tạo AuthResponse với userId
|
||||||
|
const data: AuthResponse = {
|
||||||
|
token: rawData.token,
|
||||||
|
userId: userId,
|
||||||
|
email: userEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Final auth data:', data);
|
||||||
|
|
||||||
|
// Validate data
|
||||||
|
if (!data.token) {
|
||||||
|
throw new Error('Token không tồn tại trong response');
|
||||||
|
}
|
||||||
|
if (data.userId === undefined || data.userId === null) {
|
||||||
|
throw new Error('UserId không tồn tại');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lưu token và user info vào AsyncStorage
|
||||||
|
await saveAuthData(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(token: string) {
|
export async function logout(token: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
const response = await fetch(`${API_URL}/logout`, {
|
const response = await fetch(`${API_URL}/logout`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -30,5 +279,101 @@ export async function logout(token: string) {
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Xóa token và user info khỏi AsyncStorage
|
||||||
|
await clearAuthData();
|
||||||
|
|
||||||
return response.ok;
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
// Vẫn xóa dữ liệu local ngay cả khi API call thất bại
|
||||||
|
await clearAuthData();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lưu thông tin auth vào AsyncStorage
|
||||||
|
*/
|
||||||
|
export async function saveAuthData(data: AuthResponse): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userData = {
|
||||||
|
userId: data.userId,
|
||||||
|
email: data.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Saving auth data:', {
|
||||||
|
hasToken: !!data.token,
|
||||||
|
userId: data.userId,
|
||||||
|
email: data.email
|
||||||
|
});
|
||||||
|
|
||||||
|
await AsyncStorage.multiSet([
|
||||||
|
['authToken', data.token],
|
||||||
|
['user', JSON.stringify(userData)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify data was saved
|
||||||
|
const savedUser = await AsyncStorage.getItem('user');
|
||||||
|
const savedToken = await AsyncStorage.getItem('authToken');
|
||||||
|
console.log('Auth data saved - User:', savedUser, 'Token exists:', !!savedToken);
|
||||||
|
|
||||||
|
if (!savedUser || !savedToken) {
|
||||||
|
throw new Error('Failed to save auth data');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving auth data:', error);
|
||||||
|
throw new Error('Không thể lưu thông tin đăng nhập');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xóa thông tin auth khỏi AsyncStorage
|
||||||
|
*/
|
||||||
|
export async function clearAuthData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.multiRemove(['authToken', 'user']);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing auth data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy thông tin user từ AsyncStorage
|
||||||
|
*/
|
||||||
|
export async function getUser(): Promise<{ userId: number; email: string } | null> {
|
||||||
|
try {
|
||||||
|
const userStr = await AsyncStorage.getItem('user');
|
||||||
|
if (userStr) {
|
||||||
|
return JSON.parse(userStr);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy auth token từ AsyncStorage
|
||||||
|
*/
|
||||||
|
export async function getAuthToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await AsyncStorage.getItem('authToken');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting auth token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra user đã đăng nhập chưa
|
||||||
|
*/
|
||||||
|
export async function isAuthenticated(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const token = await AsyncStorage.getItem('authToken');
|
||||||
|
const user = await AsyncStorage.getItem('user');
|
||||||
|
return !!(token && user);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
cartItemId: number;
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
productImage: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
subtotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartResponse {
|
||||||
|
cartId: number;
|
||||||
|
userId: number;
|
||||||
|
items: CartItem[];
|
||||||
|
totalAmount: number;
|
||||||
|
totalItems: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddToCartRequest {
|
||||||
|
productId: number;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCartItemRequest {
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartService = {
|
||||||
|
// Thêm sản phẩm vào giỏ hàng
|
||||||
|
addToCart: async (userId: number, data: AddToCartRequest): Promise<CartResponse> => {
|
||||||
|
console.log('=== ADD TO CART REQUEST ===', { userId, data });
|
||||||
|
const response = await api.post<CartResponse>(
|
||||||
|
`/cart/${userId}`,
|
||||||
|
data,
|
||||||
|
{ requireAuth: true }
|
||||||
|
);
|
||||||
|
console.log('=== ADD TO CART RESPONSE ===', response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lấy danh sách sản phẩm trong giỏ hàng
|
||||||
|
getCart: async (userId: number): Promise<CartResponse> => {
|
||||||
|
console.log('=== GET CART REQUEST ===', { userId });
|
||||||
|
const response = await api.get<CartResponse>(
|
||||||
|
`/cart/${userId}`,
|
||||||
|
{ requireAuth: true }
|
||||||
|
);
|
||||||
|
console.log('=== GET CART RESPONSE ===', response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cập nhật số lượng sản phẩm
|
||||||
|
updateCartItem: async (
|
||||||
|
userId: number,
|
||||||
|
cartItemId: number,
|
||||||
|
data: UpdateCartItemRequest
|
||||||
|
): Promise<CartResponse> => {
|
||||||
|
console.log('=== UPDATE CART ITEM REQUEST ===', { userId, cartItemId, data });
|
||||||
|
const response = await api.put<CartResponse>(
|
||||||
|
`/cart/${userId}/items/${cartItemId}`,
|
||||||
|
data,
|
||||||
|
{ requireAuth: true }
|
||||||
|
);
|
||||||
|
console.log('=== UPDATE CART ITEM RESPONSE ===', response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Xóa một sản phẩm khỏi giỏ hàng
|
||||||
|
removeCartItem: async (userId: number, cartItemId: number): Promise<CartResponse> => {
|
||||||
|
console.log('=== REMOVE CART ITEM REQUEST ===', { userId, cartItemId });
|
||||||
|
const response = await api.delete<CartResponse>(
|
||||||
|
`/cart/${userId}/items/${cartItemId}`,
|
||||||
|
{ requireAuth: true }
|
||||||
|
);
|
||||||
|
console.log('=== REMOVE CART ITEM RESPONSE ===', response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Xóa tất cả sản phẩm khỏi giỏ hàng
|
||||||
|
clearCart: async (userId: number): Promise<void> => {
|
||||||
|
console.log('=== CLEAR CART REQUEST ===', { userId });
|
||||||
|
await api.delete<void>(
|
||||||
|
`/cart/${userId}/clear`,
|
||||||
|
{ requireAuth: true }
|
||||||
|
);
|
||||||
|
console.log('=== CLEAR CART SUCCESS ===');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default cartService;
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { API_CONFIG } from '../constants/Config';
|
import api from './api';
|
||||||
|
|
||||||
const API_URL = API_CONFIG.BASE_URL;
|
|
||||||
|
|
||||||
export interface ProductResponse {
|
export interface ProductResponse {
|
||||||
productId: number;
|
productId: number;
|
||||||
@@ -34,7 +32,6 @@ class ProductService {
|
|||||||
sortBy: string = 'productId',
|
sortBy: string = 'productId',
|
||||||
sortDir: 'asc' | 'desc' = 'asc'
|
sortDir: 'asc' | 'desc' = 'asc'
|
||||||
): Promise<ProductListResponse> {
|
): Promise<ProductListResponse> {
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
size: size.toString(),
|
size: size.toString(),
|
||||||
@@ -42,63 +39,14 @@ class ProductService {
|
|||||||
sortDir,
|
sortDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `${API_URL}/products?${params}`;
|
return await api.get<ProductListResponse>(`/products?${params}`);
|
||||||
console.log('Fetching products from:', url);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `Failed to fetch products: ${response.status} ${response.statusText}`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
errorMessage = errorData.message || errorData.error || errorMessage;
|
|
||||||
} catch {
|
|
||||||
// If response is not JSON, use the status text
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
errorMessage = text || errorMessage;
|
|
||||||
}
|
|
||||||
console.error('API Error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
||||||
console.error('Network error - API may be unreachable:', error);
|
|
||||||
throw new Error('Không thể kết nối đến server. Vui lòng kiểm tra kết nối mạng.');
|
|
||||||
}
|
|
||||||
console.error('Error fetching products:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lấy chi tiết sản phẩm theo ID
|
* Lấy chi tiết sản phẩm theo ID
|
||||||
*/
|
*/
|
||||||
async getProductById(id: number): Promise<ProductResponse> {
|
async getProductById(id: number): Promise<ProductResponse> {
|
||||||
try {
|
return await api.get<ProductResponse>(`/products/${id}`);
|
||||||
const response = await fetch(`${API_URL}/products/${id}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Product not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching product:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +59,6 @@ class ProductService {
|
|||||||
sortBy: string = 'productId',
|
sortBy: string = 'productId',
|
||||||
sortDir: 'asc' | 'desc' = 'asc'
|
sortDir: 'asc' | 'desc' = 'asc'
|
||||||
): Promise<ProductListResponse> {
|
): Promise<ProductListResponse> {
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
size: size.toString(),
|
size: size.toString(),
|
||||||
@@ -119,17 +66,7 @@ class ProductService {
|
|||||||
sortDir,
|
sortDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/products/category/${categoryId}?${params}`);
|
return await api.get<ProductListResponse>(`/products/category/${categoryId}?${params}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch products by category');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching products by category:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,7 +79,6 @@ class ProductService {
|
|||||||
sortBy: string = 'productId',
|
sortBy: string = 'productId',
|
||||||
sortDir: 'asc' | 'desc' = 'asc'
|
sortDir: 'asc' | 'desc' = 'asc'
|
||||||
): Promise<ProductListResponse> {
|
): Promise<ProductListResponse> {
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
keyword,
|
keyword,
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
@@ -151,17 +87,7 @@ class ProductService {
|
|||||||
sortDir,
|
sortDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/products/search?${params}`);
|
return await api.get<ProductListResponse>(`/products/search?${params}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to search products');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching products:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,7 +99,6 @@ class ProductService {
|
|||||||
sortBy: string = 'productId',
|
sortBy: string = 'productId',
|
||||||
sortDir: 'asc' | 'desc' = 'asc'
|
sortDir: 'asc' | 'desc' = 'asc'
|
||||||
): Promise<ProductListResponse> {
|
): Promise<ProductListResponse> {
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
size: size.toString(),
|
size: size.toString(),
|
||||||
@@ -181,17 +106,7 @@ class ProductService {
|
|||||||
sortDir,
|
sortDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/products/available?${params}`);
|
return await api.get<ProductListResponse>(`/products/available?${params}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch available products');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching available products:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user