This commit is contained in:
2025-11-12 16:03:57 +07:00
parent 87269a6615
commit 0ca7536b70
11 changed files with 1250 additions and 60 deletions

View File

@@ -0,0 +1,381 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Image,
ActivityIndicator,
TouchableOpacity,
Dimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useProduct } from '../../../hooks/useProducts';
import { Ionicons } from '@expo/vector-icons';
const { width } = Dimensions.get('window');
export default function ProductDetailScreen() {
const router = useRouter();
const { id } = useLocalSearchParams();
const productId = typeof id === 'string' ? parseInt(id) : null;
const { product, loading, error } = useProduct(productId);
const formatPrice = (price: number) => {
return new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
}).format(price);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
if (loading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Đang tải...</Text>
</View>
</SafeAreaView>
);
}
if (error || !product) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={64} color="#f44336" />
<Text style={styles.errorText}>
{error || 'Không tìm thấy sản phẩm'}
</Text>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backButtonText}>Quay lại</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color="#333" />
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>
Chi tiết sản phẩm
</Text>
<View style={{ width: 40 }} />
</View>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
{/* Product Image */}
<Image
source={{ uri: product.imageUrl || 'https://via.placeholder.com/400' }}
style={styles.productImage}
resizeMode="cover"
/>
{/* Product Info */}
<View style={styles.content}>
{/* Category Badge */}
<View style={styles.categoryBadge}>
<Text style={styles.categoryBadgeText}>{product.categoryName}</Text>
</View>
{/* Product Name */}
<Text style={styles.productName}>{product.productName}</Text>
{/* Price & Stock */}
<View style={styles.priceStockContainer}>
<Text style={styles.price}>{formatPrice(product.price)}</Text>
{product.stockQuantity > 0 ? (
<View style={styles.stockBadge}>
<Ionicons name="checkmark-circle" size={20} color="#4caf50" />
<Text style={styles.stockText}>Còn {product.stockQuantity} sản phẩm</Text>
</View>
) : (
<View style={[styles.stockBadge, styles.outOfStockBadge]}>
<Ionicons name="close-circle" size={20} color="#f44336" />
<Text style={[styles.stockText, styles.outOfStockText]}>Hết hàng</Text>
</View>
)}
</View>
{/* Divider */}
<View style={styles.divider} />
{/* Description */}
<View style={styles.section}>
<Text style={styles.sectionTitle}> tả sản phẩm</Text>
<Text style={styles.description}>
{product.description || 'Chưa có mô tả cho sản phẩm này.'}
</Text>
</View>
{/* Divider */}
<View style={styles.divider} />
{/* Product Details */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Thông tin chi tiết</Text>
<View style={styles.detailRow}>
<View style={styles.detailLabel}>
<Ionicons name="pricetag-outline" size={20} color="#666" />
<Text style={styles.detailLabelText}> sản phẩm</Text>
</View>
<Text style={styles.detailValue}>#{product.productId}</Text>
</View>
<View style={styles.detailRow}>
<View style={styles.detailLabel}>
<Ionicons name="cube-outline" size={20} color="#666" />
<Text style={styles.detailLabelText}>Danh mục</Text>
</View>
<Text style={styles.detailValue}>{product.categoryName}</Text>
</View>
<View style={styles.detailRow}>
<View style={styles.detailLabel}>
<Ionicons name="calendar-outline" size={20} color="#666" />
<Text style={styles.detailLabelText}>Ngày tạo</Text>
</View>
<Text style={styles.detailValue}>{formatDate(product.createdAt)}</Text>
</View>
<View style={styles.detailRow}>
<View style={styles.detailLabel}>
<Ionicons name="time-outline" size={20} color="#666" />
<Text style={styles.detailLabelText}>Cập nhật lần cuối</Text>
</View>
<Text style={styles.detailValue}>{formatDate(product.updatedAt)}</Text>
</View>
</View>
</View>
</ScrollView>
{/* Bottom Action Buttons */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={[styles.actionButton, styles.addToCartButton]}
disabled={product.stockQuantity === 0}
>
<Ionicons name="cart-outline" size={24} color="#fff" />
<Text style={styles.actionButtonText}>
{product.stockQuantity > 0 ? 'Thêm vào giỏ' : 'Hết hàng'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, styles.buyNowButton]}>
<Ionicons name="flash-outline" size={24} color="#fff" />
<Text style={styles.actionButtonText}>Mua ngay</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
backBtn: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
flex: 1,
fontSize: 18,
fontWeight: '600',
color: '#333',
textAlign: 'center',
},
scrollView: {
flex: 1,
},
productImage: {
width: width,
height: width,
backgroundColor: '#f0f0f0',
},
content: {
padding: 16,
},
categoryBadge: {
alignSelf: 'flex-start',
backgroundColor: '#e3f2fd',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
marginBottom: 12,
},
categoryBadgeText: {
fontSize: 13,
color: '#1976d2',
fontWeight: '600',
},
productName: {
fontSize: 24,
fontWeight: '700',
color: '#333',
marginBottom: 16,
lineHeight: 32,
},
priceStockContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
price: {
fontSize: 28,
fontWeight: '700',
color: '#e91e63',
},
stockBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#e8f5e9',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
gap: 6,
},
stockText: {
fontSize: 14,
color: '#4caf50',
fontWeight: '600',
},
outOfStockBadge: {
backgroundColor: '#ffebee',
},
outOfStockText: {
color: '#f44336',
},
divider: {
height: 1,
backgroundColor: '#e0e0e0',
marginVertical: 20,
},
section: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#333',
marginBottom: 12,
},
description: {
fontSize: 15,
color: '#666',
lineHeight: 24,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
detailLabel: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
detailLabelText: {
fontSize: 15,
color: '#666',
},
detailValue: {
fontSize: 15,
color: '#333',
fontWeight: '600',
},
bottomActions: {
flexDirection: 'row',
padding: 16,
gap: 12,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 12,
gap: 8,
},
addToCartButton: {
backgroundColor: '#ff9800',
},
buyNowButton: {
backgroundColor: '#e91e63',
},
actionButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: '#666',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
marginTop: 16,
fontSize: 16,
color: '#f44336',
textAlign: 'center',
},
backButton: {
marginTop: 16,
backgroundColor: '#007AFF',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
backButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,17 @@
import { Stack } from "expo-router";
export default function ProductsLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen
name="[id]"
options={{
presentation: "card",
headerShown: false,
tabBarStyle: { display: "none" }, // Ẩn tab bar
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,302 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
ActivityIndicator,
RefreshControl,
TextInput,
TouchableOpacity,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useProducts } from '../../../hooks/useProducts';
import ProductCard from '../../../components/ProductCard';
import { Ionicons } from '@expo/vector-icons';
export default function ProductsScreen() {
const [page, setPage] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const { products, loading, error, refreshing, refresh, hasNext, hasPrevious } = useProducts({
page,
size: 10,
keyword: isSearching ? searchQuery : undefined,
});
const handleSearch = () => {
if (searchQuery.trim()) {
setIsSearching(true);
setPage(0);
}
};
const clearSearch = () => {
setSearchQuery('');
setIsSearching(false);
setPage(0);
};
const handleLoadMore = () => {
if (hasNext && !loading) {
setPage((prev) => prev + 1);
}
};
const handleLoadPrevious = () => {
if (hasPrevious && !loading && page > 0) {
setPage((prev) => prev - 1);
}
};
const renderHeader = () => (
<View style={styles.header}>
<Text style={styles.title}>Sản phẩm</Text>
<View style={styles.searchContainer}>
<View style={styles.searchInputContainer}>
<Ionicons name="search" size={20} color="#666" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="Tìm kiếm sản phẩm..."
value={searchQuery}
onChangeText={setSearchQuery}
onSubmitEditing={handleSearch}
returnKeyType="search"
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={clearSearch} style={styles.clearButton}>
<Ionicons name="close-circle" size={20} color="#666" />
</TouchableOpacity>
)}
</View>
{searchQuery.length > 0 && (
<TouchableOpacity style={styles.searchButton} onPress={handleSearch}>
<Text style={styles.searchButtonText}>Tìm</Text>
</TouchableOpacity>
)}
</View>
</View>
);
const renderFooter = () => {
if (!loading) return null;
return (
<View style={styles.footer}>
<ActivityIndicator size="small" color="#007AFF" />
</View>
);
};
const renderPagination = () => (
<View style={styles.pagination}>
<TouchableOpacity
style={[styles.pageButton, !hasPrevious && styles.pageButtonDisabled]}
onPress={handleLoadPrevious}
disabled={!hasPrevious || loading}
>
<Ionicons name="chevron-back" size={24} color={hasPrevious ? '#007AFF' : '#ccc'} />
<Text style={[styles.pageButtonText, !hasPrevious && styles.pageButtonTextDisabled]}>
Trước
</Text>
</TouchableOpacity>
<Text style={styles.pageInfo}>Trang {page + 1}</Text>
<TouchableOpacity
style={[styles.pageButton, !hasNext && styles.pageButtonDisabled]}
onPress={handleLoadMore}
disabled={!hasNext || loading}
>
<Text style={[styles.pageButtonText, !hasNext && styles.pageButtonTextDisabled]}>
Sau
</Text>
<Ionicons name="chevron-forward" size={24} color={hasNext ? '#007AFF' : '#ccc'} />
</TouchableOpacity>
</View>
);
if (error) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={64} color="#f44336" />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
<Text style={styles.retryButtonText}>Thử lại</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<FlatList
ListHeaderComponent={renderHeader}
data={products}
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={(item) => item.productId.toString()}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Đang tải...</Text>
</View>
) : (
<View style={styles.emptyContainer}>
<Ionicons name="cube-outline" size={64} color="#ccc" />
<Text style={styles.emptyText}>Không sản phẩm nào</Text>
</View>
)
}
ListFooterComponent={
<>
{renderFooter()}
{products.length > 0 && renderPagination()}
</>
}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
backgroundColor: '#fff',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 12,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
searchInputContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f0f0f0',
borderRadius: 8,
paddingHorizontal: 12,
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
height: 40,
fontSize: 16,
color: '#333',
},
clearButton: {
padding: 4,
},
searchButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
},
searchButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
listContent: {
paddingVertical: 8,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: '#666',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
emptyText: {
marginTop: 16,
fontSize: 16,
color: '#999',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
marginTop: 16,
fontSize: 16,
color: '#f44336',
textAlign: 'center',
},
retryButton: {
marginTop: 16,
backgroundColor: '#007AFF',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
footer: {
paddingVertical: 20,
},
pagination: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 20,
backgroundColor: '#fff',
marginTop: 8,
},
pageButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
},
pageButtonDisabled: {
opacity: 0.5,
},
pageButtonText: {
fontSize: 16,
color: '#007AFF',
fontWeight: '600',
},
pageButtonTextDisabled: {
color: '#ccc',
},
pageInfo: {
fontSize: 16,
color: '#666',
fontWeight: '500',
},
});