wishlist
This commit is contained in:
@@ -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 (
|
||||
<SafeAreaView style={styles.container}>
|
||||
@@ -90,16 +123,37 @@ export default function ProductDetailScreen() {
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
Chi tiết sản phẩm
|
||||
</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
<TouchableOpacity
|
||||
style={styles.wishlistHeaderBtn}
|
||||
onPress={handleToggleWishlist}
|
||||
disabled={isLoadingWishlist}
|
||||
>
|
||||
{isLoadingWishlist ? (
|
||||
<ActivityIndicator size="small" color="#ff6b6b" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={isInWishlist ? 'heart' : 'heart-outline'}
|
||||
size={24}
|
||||
color={isInWishlist ? '#ff6b6b' : '#333'}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
{/* Product Image */}
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{ uri: product.imageUrl || 'https://via.placeholder.com/400' }}
|
||||
style={styles.productImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{product.stockQuantity <= 0 && (
|
||||
<View style={styles.outOfStockOverlay}>
|
||||
<Text style={styles.outOfStockText}>HẾT HÀNG</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Product Info */}
|
||||
<View style={styles.content}>
|
||||
@@ -182,6 +236,26 @@ export default function ProductDetailScreen() {
|
||||
|
||||
{/* Bottom Action Buttons */}
|
||||
<View style={styles.bottomActions}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.wishlistActionButton,
|
||||
isInWishlist && styles.wishlistActionButtonActive,
|
||||
]}
|
||||
onPress={handleToggleWishlist}
|
||||
disabled={isLoadingWishlist}
|
||||
>
|
||||
{isLoadingWishlist ? (
|
||||
<ActivityIndicator size="small" color={isInWishlist ? '#fff' : '#ff6b6b'} />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={isInWishlist ? 'heart' : 'heart-outline'}
|
||||
size={24}
|
||||
color={isInWishlist ? '#fff' : '#ff6b6b'}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
@@ -249,14 +323,41 @@ const styles = StyleSheet.create({
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
},
|
||||
wishlistHeaderBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
productImage: {
|
||||
imageContainer: {
|
||||
position: 'relative',
|
||||
width: width,
|
||||
height: width,
|
||||
},
|
||||
productImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#f0f0f0',
|
||||
},
|
||||
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: 16,
|
||||
},
|
||||
@@ -308,9 +409,6 @@ const styles = StyleSheet.create({
|
||||
outOfStockBadge: {
|
||||
backgroundColor: '#ffebee',
|
||||
},
|
||||
outOfStockText: {
|
||||
color: '#f44336',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#e0e0e0',
|
||||
@@ -369,6 +467,15 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
gap: 8,
|
||||
},
|
||||
wishlistActionButton: {
|
||||
flex: 0.5,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 2,
|
||||
borderColor: '#ff6b6b',
|
||||
},
|
||||
wishlistActionButtonActive: {
|
||||
backgroundColor: '#ff6b6b',
|
||||
},
|
||||
addToCartButton: {
|
||||
backgroundColor: '#ff9800',
|
||||
},
|
||||
|
||||
444
app/(tabs)/wishlist.tsx
Normal file
444
app/(tabs)/wishlist.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { AntDesign, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useWishlist } from '../../hooks/useWishlist';
|
||||
|
||||
const COLORS = {
|
||||
primary: '#FF6B6B',
|
||||
secondary: '#4ECDC4',
|
||||
background: '#F7F9FC',
|
||||
white: '#FFFFFF',
|
||||
text: '#2C3E50',
|
||||
lightText: '#95A5A6',
|
||||
border: '#E0E0E0',
|
||||
success: '#27AE60',
|
||||
};
|
||||
|
||||
interface WishlistItem {
|
||||
id?: number;
|
||||
wishlistId?: number;
|
||||
productId: number;
|
||||
productName: string;
|
||||
productImage: string;
|
||||
price: number;
|
||||
description: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
const WishlistScreen = () => {
|
||||
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 (
|
||||
<View style={styles.itemContainer}>
|
||||
<Image
|
||||
source={{ uri: item.productImage || 'https://via.placeholder.com/120' }}
|
||||
style={styles.itemImage}
|
||||
/>
|
||||
|
||||
<View style={styles.itemContent}>
|
||||
<Text style={styles.itemName} numberOfLines={2}>
|
||||
{item.productName}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.itemDescription} numberOfLines={2}>
|
||||
{item.description}
|
||||
</Text>
|
||||
|
||||
<View style={styles.priceContainer}>
|
||||
<Text style={styles.price}>{formatPrice(item.price)}</Text>
|
||||
<Text style={styles.addedDate}>{formatDate(item.addedAt)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => handleRemoveFromWishlist(item)}
|
||||
>
|
||||
<AntDesign name="delete" size={20} color={COLORS.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="heart-outline"
|
||||
size={80}
|
||||
color={COLORS.lightText}
|
||||
/>
|
||||
<Text style={styles.emptyText}>Danh sách ưu thích trống</Text>
|
||||
<Text style={styles.emptySubText}>
|
||||
Thêm những sản phẩm yêu thích của bạn vào đây
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Danh sách ưu thích</Text>
|
||||
<View style={styles.countBadge}>
|
||||
<Text style={styles.countText}>{totalElements}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sort Options */}
|
||||
<View style={styles.sortContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sortButton,
|
||||
currentSort === 'createdAt' && styles.sortButtonActive,
|
||||
]}
|
||||
onPress={() => setCurrentSort('createdAt')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.sortButtonText,
|
||||
currentSort === 'createdAt' && styles.sortButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Mới nhất
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sortButton,
|
||||
currentSort === 'price' && styles.sortButtonActive,
|
||||
]}
|
||||
onPress={() => setCurrentSort('price')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.sortButtonText,
|
||||
currentSort === 'price' && styles.sortButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Giá
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sortButton,
|
||||
currentSort === 'name' && styles.sortButtonActive,
|
||||
]}
|
||||
onPress={() => setCurrentSort('name')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.sortButtonText,
|
||||
currentSort === 'name' && styles.sortButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Tên
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{loading && wishlists.length === 0 ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color={COLORS.primary} />
|
||||
<Text style={styles.loadingText}>Đang tải...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={wishlists}
|
||||
renderItem={renderWishlistItem}
|
||||
keyExtractor={(item, index) => {
|
||||
// Use only item.id with fallback to index to ensure proper typing
|
||||
return item?.id?.toString() ?? index.toString();
|
||||
}}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={renderEmptyState()}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={COLORS.primary}
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
loading && wishlists.length > 0 ? (
|
||||
<View style={styles.footerLoader}>
|
||||
<ActivityIndicator size="small" color={COLORS.primary} />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination Info */}
|
||||
{totalElements > 0 && (
|
||||
<View style={styles.paginationInfo}>
|
||||
<Text style={styles.paginationText}>
|
||||
Trang {currentPage + 1}/{totalPages}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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<ProductCardProps> = ({ product }) => {
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ 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<ProductCardProps> = ({ 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<ProductCardProps> = ({ product }) => {
|
||||
<Text style={styles.categoryText}>{product.categoryName}</Text>
|
||||
</View>
|
||||
|
||||
{/* Wishlist Button */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.wishlistButton,
|
||||
isInWishlist && styles.wishlistButtonActive,
|
||||
]}
|
||||
onPress={handleToggleWishlist}
|
||||
disabled={isLoadingWishlist}
|
||||
>
|
||||
{isLoadingWishlist ? (
|
||||
<ActivityIndicator size="small" color={isInWishlist ? '#ff6b6b' : '#fff'} />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={isInWishlist ? 'heart' : 'heart-outline'}
|
||||
size={20}
|
||||
color={isInWishlist ? '#ff6b6b' : '#fff'}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Out of Stock Overlay */}
|
||||
{product.stockQuantity <= 0 && (
|
||||
<View style={styles.outOfStockOverlay}>
|
||||
@@ -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,
|
||||
|
||||
239
components/WishlistButton.tsx
Normal file
239
components/WishlistButton.tsx
Normal file
@@ -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<WishlistButtonProps> = ({
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
style={[styles.largeButtonContainer, style]}
|
||||
onPress={handleToggleWishlist}
|
||||
disabled={loading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.largeButton,
|
||||
isInWishlist && styles.largeButtonActive,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={isInWishlist ? COLORS.white : COLORS.primary} />
|
||||
) : (
|
||||
<AntDesign
|
||||
name={isInWishlist ? ('heart' as any) : ('hearto' as any)}
|
||||
size={sizeStyles.iconSize}
|
||||
color={isInWishlist ? COLORS.white : COLORS.primary}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.largeButtonLabel,
|
||||
isInWishlist && styles.largeButtonLabelActive,
|
||||
]}
|
||||
>
|
||||
{isInWishlist ? 'Đã yêu thích' : 'Yêu thích'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
width: sizeStyles.buttonSize,
|
||||
height: sizeStyles.buttonSize,
|
||||
borderRadius: sizeStyles.buttonSize / 2,
|
||||
paddingHorizontal: sizeStyles.paddingHorizontal,
|
||||
paddingVertical: sizeStyles.paddingVertical,
|
||||
},
|
||||
isInWishlist && styles.buttonActive,
|
||||
style,
|
||||
]}
|
||||
onPress={handleToggleWishlist}
|
||||
disabled={loading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
color={isInWishlist ? COLORS.white : COLORS.primary}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AntDesign
|
||||
name={isInWishlist ? ('heart' as any) : ('hearto' as any)}
|
||||
size={sizeStyles.iconSize}
|
||||
color={isInWishlist ? COLORS.white : COLORS.primary}
|
||||
/>
|
||||
{showLabel && (
|
||||
<Text
|
||||
style={[
|
||||
{ fontSize: sizeStyles.fontSize },
|
||||
isInWishlist ? styles.labelActive : styles.label,
|
||||
]}
|
||||
>
|
||||
{isInWishlist ? 'Đã thích' : 'Thích'}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
150
hooks/useWishlist.ts
Normal file
150
hooks/useWishlist.ts
Normal file
@@ -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<UseWishlistState>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
84
services/wishlistApi .ts
Normal file
84
services/wishlistApi .ts
Normal file
@@ -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<WishlistResponse> {
|
||||
return api.post<WishlistResponse>(
|
||||
'/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<WishlistResponse> {
|
||||
return api.delete<WishlistResponse>(
|
||||
`/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<WishlistListResponse> {
|
||||
return api.get<WishlistListResponse>(
|
||||
`/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<WishlistCountResponse> {
|
||||
return api.get<WishlistCountResponse>(
|
||||
'/v1/wishlists/count',
|
||||
{ requireAuth: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const wishlistApi = new WishlistApi();
|
||||
Reference in New Issue
Block a user