From f150a84cf03b2aac167f42752ec64cdb0ed6d8e0 Mon Sep 17 00:00:00 2001 From: kenduNMT Date: Fri, 14 Nov 2025 14:44:46 +0700 Subject: [PATCH] cart --- app/(tabs)/cart.tsx | 253 +++++++++++++++++++++++ app/(tabs)/products/[id].tsx | 67 +++++-- components/CartItemCard.tsx | 200 +++++++++++++++++++ components/ProductCard.tsx | 167 ++++++++++++---- hooks/useAuth.ts | 106 ++++++++++ hooks/useCart.ts | 162 +++++++++++++++ services/api.ts | 195 ++++++++++++++++++ services/auth.ts | 375 +++++++++++++++++++++++++++++++++-- services/cart.ts | 93 +++++++++ services/product.ts | 147 +++----------- 10 files changed, 1581 insertions(+), 184 deletions(-) create mode 100644 app/(tabs)/cart.tsx create mode 100644 components/CartItemCard.tsx diff --git a/app/(tabs)/cart.tsx b/app/(tabs)/cart.tsx new file mode 100644 index 0000000..a28e492 --- /dev/null +++ b/app/(tabs)/cart.tsx @@ -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 ( + + + + Đang tải giỏ hàng... + + + ); + } + + if (!cart || cart.items.length === 0) { + return ( + + + Giỏ hàng + + + + + Giỏ hàng trống + + Hãy thêm sản phẩm vào giỏ hàng để mua sắm + + + + ); + } + + return ( + + + Giỏ hàng + + + Xóa tất cả + + + + item.cartItemId.toString()} + renderItem={({ item }) => ( + + )} + contentContainerStyle={styles.listContent} + refreshControl={ + + } + /> + + + + Tổng số lượng: + {cart.totalItems} sản phẩm + + + + Tổng tiền: + + {formatPrice(cart.totalAmount)} + + + + + Thanh toán + + + + + ); +} + +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, + }, +}); \ No newline at end of file diff --git a/app/(tabs)/products/[id].tsx b/app/(tabs)/products/[id].tsx index 01b706f..ae12969 100644 --- a/app/(tabs)/products/[id].tsx +++ b/app/(tabs)/products/[id].tsx @@ -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 { - View, - Text, - StyleSheet, - ScrollView, - Image, ActivityIndicator, - TouchableOpacity, Dimensions, + Image, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, } from 'react-native'; 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 { Ionicons } from '@expo/vector-icons'; const { width } = Dimensions.get('window'); @@ -22,6 +23,8 @@ export default function ProductDetailScreen() { const productId = typeof id === 'string' ? parseInt(id) : null; const { product, loading, error } = useProduct(productId); + const { addToCart } = useCart(); + const [isAddingToCart, setIsAddingToCart] = useState(false); const formatPrice = (price: number) => { 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) { return ( @@ -167,16 +183,36 @@ export default function ProductDetailScreen() { {/* Bottom Action Buttons */} - + {isAddingToCart ? ( + + ) : ( + + )} - {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'} - + Mua ngay @@ -344,6 +380,9 @@ const styles = StyleSheet.create({ fontWeight: '600', color: '#fff', }, + actionButtonDisabled: { + opacity: 0.5, + }, loadingContainer: { flex: 1, justifyContent: 'center', diff --git a/components/CartItemCard.tsx b/components/CartItemCard.tsx new file mode 100644 index 0000000..71f91b9 --- /dev/null +++ b/components/CartItemCard.tsx @@ -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; + onRemove: (cartItemId: number) => Promise; +} + +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 ( + + + + + + {item.productName} + + + {formatPrice(item.price)} + + + + + + + + {item.quantity} + + + + + + + {formatPrice(item.subtotal)} + + + + + + + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/components/ProductCard.tsx b/components/ProductCard.tsx index f925015..0a966be 100644 --- a/components/ProductCard.tsx +++ b/components/ProductCard.tsx @@ -1,7 +1,9 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Image } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { ProductResponse } from '../services/product'; import { useRouter } from 'expo-router'; +import { useCart } from '../hooks/useCart'; interface ProductCardProps { product: ProductResponse; @@ -9,11 +11,28 @@ interface ProductCardProps { const ProductCard: React.FC = ({ product }) => { const router = useRouter(); + const { addToCart } = useCart(); + const [isAdding, setIsAdding] = useState(false); const handlePress = () => { 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) => { return new Intl.NumberFormat('vi-VN', { style: 'currency', @@ -23,29 +42,59 @@ const ProductCard: React.FC = ({ product }) => { return ( - + + + + {/* Category Badge */} + + {product.categoryName} + + + {/* Out of Stock Overlay */} + {product.stockQuantity <= 0 && ( + + HẾT HÀNG + + )} + + {product.productName} - - {product.categoryName} - - - {formatPrice(product.price)} - {product.stockQuantity > 0 ? ( - + + + + {formatPrice(product.price)} + {product.stockQuantity > 0 && ( Còn {product.stockQuantity} - - ) : ( - - Hết hàng - - )} + )} + + + {/* Add to Cart Button */} + + {isAdding ? ( + + ) : ( + + )} + @@ -65,11 +114,46 @@ const styles = StyleSheet.create({ elevation: 3, overflow: 'hidden', }, - image: { + imageContainer: { + position: 'relative', width: '100%', height: 200, + }, + image: { + width: '100%', + height: '100%', 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: { padding: 12, }, @@ -77,39 +161,44 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', color: '#333', - marginBottom: 4, - }, - categoryName: { - fontSize: 13, - color: '#666', marginBottom: 8, + minHeight: 44, }, - priceContainer: { + footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, + priceSection: { + flex: 1, + }, price: { fontSize: 18, fontWeight: '700', - color: '#e91e63', - }, - stockBadge: { - backgroundColor: '#4caf50', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, + color: '#ff6b6b', + marginBottom: 2, }, stockText: { fontSize: 12, - color: '#fff', + color: '#4caf50', fontWeight: '500', }, - outOfStock: { - backgroundColor: '#f44336', + addButton: { + 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: { - color: '#fff', + addButtonDisabled: { + backgroundColor: '#ccc', + shadowOpacity: 0, }, }); diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index e69de29..b433971 100644 --- a/hooks/useAuth.ts +++ b/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(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, + }; +}; \ No newline at end of file diff --git a/hooks/useCart.ts b/hooks/useCart.ts index e69de29..defec64 100644 --- a/hooks/useCart.ts +++ b/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(null); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + // Lấy userId từ auth service + const getUserId = async (): Promise => { + 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, + }; +}; \ No newline at end of file diff --git a/services/api.ts b/services/api.ts index e69de29..06ba9f0 100644 --- a/services/api.ts +++ b/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 { + 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 { + 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(response: Response): Promise { + 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(endpoint: string, options: RequestOptions = {}): Promise { + 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(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(endpoint: string, data?: any, options: RequestOptions = {}): Promise { + 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(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(endpoint: string, data?: any, options: RequestOptions = {}): Promise { + 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(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(endpoint: string, options: RequestOptions = {}): Promise { + 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(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(endpoint: string, data?: any, options: RequestOptions = {}): Promise { + 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(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; \ No newline at end of file diff --git a/services/auth.ts b/services/auth.ts index 75a1477..6d212ef 100644 --- a/services/auth.ts +++ b/services/auth.ts @@ -1,34 +1,379 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + 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 { const response = await fetch(`${API_URL}/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); - if (!response.ok) throw new Error("Đăng ký thất bại"); - return await response.json(); + if (!response.ok) { + 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 { const response = await fetch(`${API_URL}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); - if (!response.ok) throw new Error("Đăng nhập thất bại"); - return await response.json(); + if (!response.ok) { + 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) { - const response = await fetch(`${API_URL}/logout`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - return response.ok; +export async function logout(token: string): Promise { + try { + const response = await fetch(`${API_URL}/logout`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + // Xóa token và user info khỏi AsyncStorage + await clearAuthData(); + + 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 { + 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 { + 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 { + 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 { + try { + const token = await AsyncStorage.getItem('authToken'); + const user = await AsyncStorage.getItem('user'); + return !!(token && user); + } catch (error) { + return false; + } +} \ No newline at end of file diff --git a/services/cart.ts b/services/cart.ts index e69de29..0343794 100644 --- a/services/cart.ts +++ b/services/cart.ts @@ -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 => { + console.log('=== ADD TO CART REQUEST ===', { userId, data }); + const response = await api.post( + `/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 => { + console.log('=== GET CART REQUEST ===', { userId }); + const response = await api.get( + `/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 => { + console.log('=== UPDATE CART ITEM REQUEST ===', { userId, cartItemId, data }); + const response = await api.put( + `/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 => { + console.log('=== REMOVE CART ITEM REQUEST ===', { userId, cartItemId }); + const response = await api.delete( + `/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 => { + console.log('=== CLEAR CART REQUEST ===', { userId }); + await api.delete( + `/cart/${userId}/clear`, + { requireAuth: true } + ); + console.log('=== CLEAR CART SUCCESS ==='); + }, +}; + +export default cartService; \ No newline at end of file diff --git a/services/product.ts b/services/product.ts index 601f145..884e8fc 100644 --- a/services/product.ts +++ b/services/product.ts @@ -1,6 +1,4 @@ -import { API_CONFIG } from '../constants/Config'; - -const API_URL = API_CONFIG.BASE_URL; +import api from './api'; export interface ProductResponse { productId: number; @@ -34,71 +32,21 @@ class ProductService { sortBy: string = 'productId', sortDir: 'asc' | 'desc' = 'asc' ): Promise { - try { - const params = new URLSearchParams({ - page: page.toString(), - size: size.toString(), - sortBy, - sortDir, - }); + const params = new URLSearchParams({ + page: page.toString(), + size: size.toString(), + sortBy, + sortDir, + }); - const url = `${API_URL}/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; - } + return await api.get(`/products?${params}`); } /** * Lấy chi tiết sản phẩm theo ID */ async getProductById(id: number): Promise { - try { - 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; - } + return await api.get(`/products/${id}`); } /** @@ -111,25 +59,14 @@ class ProductService { sortBy: string = 'productId', sortDir: 'asc' | 'desc' = 'asc' ): Promise { - try { - const params = new URLSearchParams({ - page: page.toString(), - size: size.toString(), - sortBy, - sortDir, - }); + const params = new URLSearchParams({ + page: page.toString(), + size: size.toString(), + sortBy, + sortDir, + }); - const response = await fetch(`${API_URL}/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; - } + return await api.get(`/products/category/${categoryId}?${params}`); } /** @@ -142,26 +79,15 @@ class ProductService { sortBy: string = 'productId', sortDir: 'asc' | 'desc' = 'asc' ): Promise { - try { - const params = new URLSearchParams({ - keyword, - page: page.toString(), - size: size.toString(), - sortBy, - sortDir, - }); + const params = new URLSearchParams({ + keyword, + page: page.toString(), + size: size.toString(), + sortBy, + sortDir, + }); - const response = await fetch(`${API_URL}/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; - } + return await api.get(`/products/search?${params}`); } /** @@ -173,25 +99,14 @@ class ProductService { sortBy: string = 'productId', sortDir: 'asc' | 'desc' = 'asc' ): Promise { - try { - const params = new URLSearchParams({ - page: page.toString(), - size: size.toString(), - sortBy, - sortDir, - }); + const params = new URLSearchParams({ + page: page.toString(), + size: size.toString(), + sortBy, + sortDir, + }); - const response = await fetch(`${API_URL}/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; - } + return await api.get(`/products/available?${params}`); } }