From 3cd2a53a0e8cd4a4cee40a250d0552282fe38c5a Mon Sep 17 00:00:00 2001 From: kenduNMT Date: Wed, 19 Nov 2025 14:34:05 +0700 Subject: [PATCH] wishlist --- app/(tabs)/products/[id].tsx | 127 +++++++++- app/(tabs)/wishlist.tsx | 444 ++++++++++++++++++++++++++++++++++ components/ProductCard.tsx | 77 +++++- components/WishlistButton.tsx | 239 ++++++++++++++++++ hooks/useWishlist.ts | 150 ++++++++++++ services/wishlistApi .ts | 84 +++++++ 6 files changed, 1107 insertions(+), 14 deletions(-) create mode 100644 app/(tabs)/wishlist.tsx create mode 100644 components/WishlistButton.tsx create mode 100644 hooks/useWishlist.ts create mode 100644 services/wishlistApi .ts diff --git a/app/(tabs)/products/[id].tsx b/app/(tabs)/products/[id].tsx index ae12969..24309c8 100644 --- a/app/(tabs)/products/[id].tsx +++ b/app/(tabs)/products/[id].tsx @@ -10,10 +10,12 @@ import { Text, TouchableOpacity, View, + Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useCart } from '../../../hooks/useCart'; import { useProduct } from '../../../hooks/useProducts'; +import { wishlistApi } from '../../../services/wishlistApi '; const { width } = Dimensions.get('window'); @@ -25,6 +27,8 @@ export default function ProductDetailScreen() { const { product, loading, error } = useProduct(productId); const { addToCart } = useCart(); const [isAddingToCart, setIsAddingToCart] = useState(false); + const [isInWishlist, setIsInWishlist] = useState(false); + const [isLoadingWishlist, setIsLoadingWishlist] = useState(false); const formatPrice = (price: number) => { return new Intl.NumberFormat('vi-VN', { @@ -54,6 +58,35 @@ export default function ProductDetailScreen() { } }; + const handleToggleWishlist = async () => { + if (!product) return; + + setIsLoadingWishlist(true); + try { + if (isInWishlist) { + const response = await wishlistApi.removeFromWishlist(product.productId); + if (response.success) { + setIsInWishlist(false); + Alert.alert('Thành công', response.message); + } else { + Alert.alert('Lỗi', response.message); + } + } else { + const response = await wishlistApi.addToWishlist(product.productId); + if (response.success) { + setIsInWishlist(true); + Alert.alert('Thành công', response.message); + } else { + Alert.alert('Lỗi', response.message); + } + } + } catch (error) { + Alert.alert('Lỗi', error instanceof Error ? error.message : 'Lỗi không xác định'); + } finally { + setIsLoadingWishlist(false); + } + }; + if (loading) { return ( @@ -90,16 +123,37 @@ export default function ProductDetailScreen() { Chi tiết sản phẩm - + + {isLoadingWishlist ? ( + + ) : ( + + )} + {/* Product Image */} - + + + {product.stockQuantity <= 0 && ( + + HẾT HÀNG + + )} + {/* Product Info */} @@ -182,6 +236,26 @@ export default function ProductDetailScreen() { {/* Bottom Action Buttons */} + + {isLoadingWishlist ? ( + + ) : ( + + )} + + { + const { + wishlists, + loading, + error, + totalElements, + currentPage, + totalPages, + fetchWishlists, + removeFromWishlist, + clearError, + } = useWishlist(); + + const [refreshing, setRefreshing] = useState(false); + const [currentSort, setCurrentSort] = useState<'createdAt' | 'price' | 'name'>('createdAt'); + + useEffect(() => { + fetchWishlists(0, 10, currentSort); + }, [currentSort, fetchWishlists]); + + useEffect(() => { + if (error) { + Alert.alert('Lỗi', error, [{ text: 'OK', onPress: clearError }]); + } + }, [error, clearError]); + + const handleRefresh = async () => { + setRefreshing(true); + await fetchWishlists(currentPage, 10, currentSort); + setRefreshing(false); + }; + + const handleRemoveFromWishlist = (item: WishlistItem) => { + Alert.alert( + 'Xóa khỏi ưu thích', + `Bạn có chắc chắn muốn xóa "${item.productName}" khỏi danh sách ưu thích?`, + [ + { text: 'Hủy', onPress: () => {}, style: 'cancel' }, + { + text: 'Xóa', + onPress: async () => { + const result = await removeFromWishlist(item.productId); + if (result.success) { + Alert.alert('Thành công', result.message); + } else { + Alert.alert('Lỗi', result.message); + } + }, + style: 'destructive', + }, + ] + ); + }; + + const handleLoadMore = () => { + if (currentPage < totalPages - 1 && !loading) { + fetchWishlists(currentPage + 1, 10, currentSort); + } + }; + + const formatPrice = (price: number) => { + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + }).format(price); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('vi-VN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const renderWishlistItem = ({ item }: { item: WishlistItem | any }) => { + // Kiểm tra dữ liệu hợp lệ + if (!item || !item.productId) { + return null; + } + + return ( + + + + + + {item.productName} + + + + {item.description} + + + + {formatPrice(item.price)} + {formatDate(item.addedAt)} + + + + handleRemoveFromWishlist(item)} + > + + + + ); + }; + + const renderEmptyState = () => ( + + + Danh sách ưu thích trống + + Thêm những sản phẩm yêu thích của bạn vào đây + + + ); + + return ( + + {/* Header */} + + Danh sách ưu thích + + {totalElements} + + + + {/* Sort Options */} + + setCurrentSort('createdAt')} + > + + Mới nhất + + + + setCurrentSort('price')} + > + + Giá + + + + setCurrentSort('name')} + > + + Tên + + + + + {/* Content */} + {loading && wishlists.length === 0 ? ( + + + Đang tải... + + ) : ( + { + // Use only item.id with fallback to index to ensure proper typing + return item?.id?.toString() ?? index.toString(); + }} + contentContainerStyle={styles.listContent} + ListEmptyComponent={renderEmptyState()} + refreshControl={ + + } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + loading && wishlists.length > 0 ? ( + + + + ) : null + } + /> + )} + + {/* Pagination Info */} + {totalElements > 0 && ( + + + Trang {currentPage + 1}/{totalPages} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 16, + backgroundColor: COLORS.white, + borderBottomWidth: 1, + borderBottomColor: COLORS.border, + }, + headerTitle: { + fontSize: 24, + fontWeight: '700', + color: COLORS.text, + }, + countBadge: { + backgroundColor: COLORS.primary, + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 6, + }, + countText: { + color: COLORS.white, + fontWeight: '600', + fontSize: 12, + }, + sortContainer: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: COLORS.white, + gap: 8, + }, + sortButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + borderColor: COLORS.border, + backgroundColor: COLORS.white, + }, + sortButtonActive: { + backgroundColor: COLORS.primary, + borderColor: COLORS.primary, + }, + sortButtonText: { + fontSize: 12, + fontWeight: '600', + color: COLORS.text, + }, + sortButtonTextActive: { + color: COLORS.white, + }, + listContent: { + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 20, + }, + itemContainer: { + flexDirection: 'row', + backgroundColor: COLORS.white, + borderRadius: 12, + marginBottom: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + itemImage: { + width: 100, + height: 120, + backgroundColor: COLORS.background, + }, + itemContent: { + flex: 1, + paddingHorizontal: 12, + paddingVertical: 10, + justifyContent: 'space-between', + }, + itemName: { + fontSize: 14, + fontWeight: '700', + color: COLORS.text, + marginBottom: 4, + }, + itemDescription: { + fontSize: 12, + color: COLORS.lightText, + marginBottom: 8, + }, + priceContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + price: { + fontSize: 14, + fontWeight: '700', + color: COLORS.primary, + }, + addedDate: { + fontSize: 10, + color: COLORS.lightText, + }, + deleteButton: { + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 10, + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 14, + color: COLORS.lightText, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + emptyText: { + fontSize: 18, + fontWeight: '700', + color: COLORS.text, + marginTop: 16, + }, + emptySubText: { + fontSize: 14, + color: COLORS.lightText, + marginTop: 8, + textAlign: 'center', + }, + footerLoader: { + paddingVertical: 16, + justifyContent: 'center', + alignItems: 'center', + }, + paginationInfo: { + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: COLORS.white, + borderTopWidth: 1, + borderTopColor: COLORS.border, + alignItems: 'center', + }, + paginationText: { + fontSize: 12, + color: COLORS.lightText, + fontWeight: '600', + }, +}); + +export default WishlistScreen; \ No newline at end of file diff --git a/components/ProductCard.tsx b/components/ProductCard.tsx index 0a966be..7da674a 100644 --- a/components/ProductCard.tsx +++ b/components/ProductCard.tsx @@ -1,28 +1,32 @@ import React, { useState } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator, Alert } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { ProductResponse } from '../services/product'; import { useRouter } from 'expo-router'; import { useCart } from '../hooks/useCart'; +import { wishlistApi } from '../services/wishlistApi '; interface ProductCardProps { product: ProductResponse; + onWishlistToggle?: (isAdded: boolean) => void; } -const ProductCard: React.FC = ({ product }) => { +const ProductCard: React.FC = ({ product, onWishlistToggle }) => { const router = useRouter(); const { addToCart } = useCart(); const [isAdding, setIsAdding] = useState(false); + const [isInWishlist, setIsInWishlist] = useState(false); + const [isLoadingWishlist, setIsLoadingWishlist] = 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 + e.stopPropagation(); if (product.stockQuantity <= 0) { - return; // Không cho thêm vào giỏ nếu hết hàng + return; } setIsAdding(true); @@ -33,6 +37,37 @@ const ProductCard: React.FC = ({ product }) => { } }; + const handleToggleWishlist = async (e: any) => { + e.stopPropagation(); + setIsLoadingWishlist(true); + + try { + if (isInWishlist) { + const response = await wishlistApi.removeFromWishlist(product.productId); + if (response.success) { + setIsInWishlist(false); + onWishlistToggle?.(false); + Alert.alert('Thành công', response.message); + } else { + Alert.alert('Lỗi', response.message); + } + } else { + const response = await wishlistApi.addToWishlist(product.productId); + if (response.success) { + setIsInWishlist(true); + onWishlistToggle?.(true); + Alert.alert('Thành công', response.message); + } else { + Alert.alert('Lỗi', response.message); + } + } + } catch (error) { + Alert.alert('Lỗi', error instanceof Error ? error.message : 'Lỗi không xác định'); + } finally { + setIsLoadingWishlist(false); + } + }; + const formatPrice = (price: number) => { return new Intl.NumberFormat('vi-VN', { style: 'currency', @@ -54,6 +89,26 @@ const ProductCard: React.FC = ({ product }) => { {product.categoryName} + {/* Wishlist Button */} + + {isLoadingWishlist ? ( + + ) : ( + + )} + + {/* Out of Stock Overlay */} {product.stockQuantity <= 0 && ( @@ -138,6 +193,20 @@ const styles = StyleSheet.create({ color: '#fff', fontWeight: '600', }, + wishlistButton: { + position: 'absolute', + top: 12, + right: 12, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + wishlistButtonActive: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + }, outOfStockOverlay: { position: 'absolute', top: 0, diff --git a/components/WishlistButton.tsx b/components/WishlistButton.tsx new file mode 100644 index 0000000..c351aac --- /dev/null +++ b/components/WishlistButton.tsx @@ -0,0 +1,239 @@ +import React, { useState, useEffect } from 'react'; +import { + TouchableOpacity, + StyleSheet, + Text, + View, + Alert, + ActivityIndicator, +} from 'react-native'; +import { AntDesign } from '@expo/vector-icons'; +import { wishlistApi } from '../services/wishlistApi '; + +interface WishlistButtonProps { + productId: number; + onToggle?: (isAdded: boolean) => void; + style?: any; + size?: 'small' | 'medium' | 'large'; + showLabel?: boolean; + isInWishlist?: boolean; +} + +const COLORS = { + primary: '#FF6B6B', + secondary: '#4ECDC4', + white: '#FFFFFF', + text: '#2C3E50', + lightText: '#95A5A6', + border: '#E0E0E0', +}; + +const WishlistButton: React.FC = ({ + productId, + onToggle, + style, + size = 'medium', + showLabel = false, + isInWishlist: initialIsInWishlist = false, +}) => { + const [isInWishlist, setIsInWishlist] = useState(initialIsInWishlist); + const [loading, setLoading] = useState(false); + + const getSizeStyles = () => { + switch (size) { + case 'small': + return { + buttonSize: 32, + iconSize: 16, + paddingHorizontal: 8, + paddingVertical: 4, + fontSize: 12, + }; + case 'large': + return { + buttonSize: 56, + iconSize: 28, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + }; + default: // medium + return { + buttonSize: 44, + iconSize: 20, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 14, + }; + } + }; + + const sizeStyles = getSizeStyles(); + + const handleToggleWishlist = async () => { + setLoading(true); + try { + if (isInWishlist) { + // Remove from wishlist + const response = await wishlistApi.removeFromWishlist(productId); + if (response.success) { + setIsInWishlist(false); + Alert.alert('Thành công', response.message); + onToggle?.(false); + } else { + Alert.alert('Lỗi', response.message); + } + } else { + // Add to wishlist + const response = await wishlistApi.addToWishlist(productId); + if (response.success) { + setIsInWishlist(true); + Alert.alert('Thành công', response.message); + onToggle?.(true); + } else { + Alert.alert('Lỗi', response.message); + } + } + } catch (error) { + Alert.alert( + 'Lỗi', + error instanceof Error ? error.message : 'Lỗi không xác định' + ); + } finally { + setLoading(false); + } + }; + + if (size === 'large' && showLabel) { + return ( + + + {loading ? ( + + ) : ( + + )} + + {isInWishlist ? 'Đã yêu thích' : 'Yêu thích'} + + + + ); + } + + return ( + + {loading ? ( + + ) : ( + <> + + {showLabel && ( + + {isInWishlist ? 'Đã thích' : 'Thích'} + + )} + + )} + + ); +}; + +const styles = StyleSheet.create({ + button: { + justifyContent: 'center', + alignItems: 'center', + backgroundColor: COLORS.white, + borderWidth: 2, + borderColor: COLORS.primary, + }, + buttonActive: { + backgroundColor: COLORS.primary, + borderColor: COLORS.primary, + }, + label: { + color: COLORS.primary, + fontWeight: '600', + marginTop: 4, + }, + labelActive: { + color: COLORS.white, + fontWeight: '600', + marginTop: 4, + }, + largeButtonContainer: { + width: '100%', + }, + largeButton: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + backgroundColor: COLORS.white, + borderWidth: 2, + borderColor: COLORS.primary, + justifyContent: 'center', + alignItems: 'center', + gap: 8, + }, + largeButtonActive: { + backgroundColor: COLORS.primary, + borderColor: COLORS.primary, + }, + largeButtonLabel: { + fontSize: 16, + fontWeight: '700', + color: COLORS.primary, + }, + largeButtonLabelActive: { + color: COLORS.white, + }, +}); + +export default WishlistButton; \ No newline at end of file diff --git a/hooks/useWishlist.ts b/hooks/useWishlist.ts new file mode 100644 index 0000000..dc04443 --- /dev/null +++ b/hooks/useWishlist.ts @@ -0,0 +1,150 @@ +import { useState, useCallback, useEffect } from 'react'; +import { wishlistApi, WishlistProduct } from '../services/wishlistApi '; + +interface UseWishlistState { + wishlists: WishlistProduct[]; + loading: boolean; + error: string | null; + totalElements: number; + totalPages: number; + currentPage: number; + wishlistCount: number; +} + +export const useWishlist = () => { + const [state, setState] = useState({ + wishlists: [], + loading: false, + error: null, + totalElements: 0, + totalPages: 0, + currentPage: 0, + wishlistCount: 0, + }); + + /** + * Lấy danh sách ưu thích + */ + const fetchWishlists = useCallback( + async (page: number = 0, size: number = 10, sortBy: string = 'createdAt') => { + setState((prev) => ({ ...prev, loading: true, error: null })); + try { + const response = await wishlistApi.getWishlists(page, size, sortBy); + if (response.success && response.data) { + // Map API response đến format chính xác + const wishlists = (response.data.wishlists || []).map((item: any) => ({ + id: item.wishlistId || item.id, + wishlistId: item.wishlistId || item.id, + productId: item.productId, + productName: item.productName, + productImage: item.productImage, + price: item.price, + description: item.description, + addedAt: item.addedAt, + })); + + setState((prev) => ({ + ...prev, + wishlists: response.data?.wishlists || [], + totalElements: response.data?.totalElements ?? 0, + totalPages: response.data?.totalPages ?? 0, + currentPage: response.data?.currentPage ?? 0, + loading: false, + })); + } else { + throw new Error(response.message || 'Không thể lấy danh sách ưu thích'); + } + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Lỗi không xác định', + loading: false, + })); + } + }, + [] + ); + + /** + * Thêm sản phẩm vào ưu thích + */ + const addToWishlist = useCallback(async (productId: number) => { + try { + const response = await wishlistApi.addToWishlist(productId); + if (response.success && response.data) { + setState((prev) => ({ + ...prev, + wishlists: [response.data!, ...prev.wishlists], + totalElements: prev.totalElements + 1, + wishlistCount: prev.wishlistCount + 1, + })); + return { success: true, message: response.message }; + } else { + throw new Error(response.message || 'Không thể thêm vào ưu thích'); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Lỗi không xác định'; + setState((prev) => ({ ...prev, error: errorMessage })); + return { success: false, message: errorMessage }; + } + }, []); + + /** + * Xóa sản phẩm khỏi ưu thích + */ + const removeFromWishlist = useCallback(async (productId: number) => { + try { + const response = await wishlistApi.removeFromWishlist(productId); + if (response.success) { + setState((prev) => ({ + ...prev, + wishlists: prev.wishlists.filter((w) => w.productId !== productId), + totalElements: Math.max(0, prev.totalElements - 1), + wishlistCount: Math.max(0, prev.wishlistCount - 1), + })); + return { success: true, message: response.message }; + } else { + throw new Error(response.message || 'Không thể xóa khỏi ưu thích'); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Lỗi không xác định'; + setState((prev) => ({ ...prev, error: errorMessage })); + return { success: false, message: errorMessage }; + } + }, []); + + /** + * Lấy số lượng ưu thích + */ + const fetchWishlistCount = useCallback(async () => { + try { + const response = await wishlistApi.getWishlistCount(); + if (response.success && response.data !== undefined) { + setState((prev) => ({ + ...prev, + wishlistCount: response.data!, + })); + } + } catch (error) { + console.error('Error fetching wishlist count:', error); + } + }, []); + + /** + * Xóa lỗi + */ + const clearError = useCallback(() => { + setState((prev) => ({ ...prev, error: null })); + }, []); + + return { + ...state, + fetchWishlists, + addToWishlist, + removeFromWishlist, + fetchWishlistCount, + clearError, + }; +}; \ No newline at end of file diff --git a/services/wishlistApi .ts b/services/wishlistApi .ts new file mode 100644 index 0000000..bbeb2d1 --- /dev/null +++ b/services/wishlistApi .ts @@ -0,0 +1,84 @@ +import api from './api'; + +export interface WishlistProduct { + id: number; + productId: number; + productName: string; + productImage: string; + price: number; + description: string; + addedAt: string; +} + +export interface WishlistResponse { + success: boolean; + message: string; + data?: WishlistProduct; +} + +export interface WishlistListResponse { + success: boolean; + message: string; + data?: { + wishlists: WishlistProduct[]; + totalPages: number; + totalElements: number; + currentPage: number; + pageSize: number; + }; +} + +export interface WishlistCountResponse { + success: boolean; + message: string; + data?: number; +} + +class WishlistApi { + /** + * Thêm sản phẩm vào danh sách ưu thích + */ + async addToWishlist(productId: number): Promise { + return api.post( + '/v1/wishlists/add', + { productId }, + { requireAuth: true } + ); + } + + /** + * Xóa sản phẩm khỏi danh sách ưu thích + */ + async removeFromWishlist(productId: number): Promise { + return api.delete( + `/v1/wishlists/remove/${productId}`, + { requireAuth: true } + ); + } + + /** + * Lấy danh sách sản phẩm ưu thích + */ + async getWishlists( + page: number = 0, + size: number = 10, + sortBy: string = 'createdAt' + ): Promise { + return api.get( + `/v1/wishlists?page=${page}&size=${size}&sortBy=${sortBy}`, + { requireAuth: true } + ); + } + + /** + * Lấy số lượng sản phẩm ưu thích + */ + async getWishlistCount(): Promise { + return api.get( + '/v1/wishlists/count', + { requireAuth: true } + ); + } +} + +export const wishlistApi = new WishlistApi(); \ No newline at end of file