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

@@ -5,44 +5,36 @@
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "projectit207", "scheme": "myapp",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "com.yourcompany.projectit207"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/adaptive-icon.png",
"foregroundImage": "./assets/images/android-icon-foreground.png", "backgroundColor": "#ffffff"
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"edgeToEdgeEnabled": true, "package": "com.yourcompany.projectit207",
"predictiveBackGestureEnabled": false "permissions": ["INTERNET"]
}, },
"web": { "web": {
"bundler": "metro",
"output": "static", "output": "static",
"favicon": "./assets/images/favicon.png" "favicon": "./assets/images/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
[ "expo-font"
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true
"reactCompiler": true
} }
} }
} }

View File

@@ -1,31 +1,10 @@
import { Tabs } from "expo-router"; import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Button, Alert } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useRouter } from "expo-router";
export default function TabsLayout() { export default function TabsLayout() {
const router = useRouter();
const handleLogout = async () => {
Alert.alert("Đăng xuất", "Bạn có chắc muốn đăng xuất?", [
{
text: "Hủy",
style: "cancel",
},
{
text: "Đăng xuất",
style: "destructive",
onPress: async () => {
await AsyncStorage.removeItem("authToken");
router.replace("/"); // quay về màn hình đăng nhập
},
},
]);
};
return ( return (
<Tabs screenOptions={{ headerShown: true, tabBarActiveTintColor: "#007AFF" }}> <Tabs screenOptions={{ headerShown: false, tabBarActiveTintColor: "#007AFF" }}>
<Tabs.Screen <Tabs.Screen
name="home" name="home"
options={{ options={{
@@ -45,7 +24,6 @@ export default function TabsLayout() {
options={{ options={{
title: "Tài khoản", title: "Tài khoản",
tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />, tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />,
headerRight: () => <Button title="Logout" onPress={handleLogout} color="#FF3B30" />,
}} }}
/> />
</Tabs> </Tabs>

View File

@@ -1,9 +1,80 @@
import { View, Text } from "react-native"; import React from "react";
import { View, Text, TouchableOpacity, StyleSheet, Alert } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AccountScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const handleLogout = async () => {
Alert.alert("Đăng xuất", "Bạn có chắc muốn đăng xuất?", [
{
text: "Hủy",
style: "cancel",
},
{
text: "Đăng xuất",
style: "destructive",
onPress: async () => {
await AsyncStorage.removeItem("authToken");
router.replace("/"); // quay về màn hình đăng nhập
},
},
]);
};
export default function HomeScreen() {
return ( return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}> <View style={styles.container}>
<Text>Chào mừng đến Tài khoản!</Text> {/* 🔘 Nút đăng xuất góc trên phải */}
<TouchableOpacity
style={[styles.logoutBtn, { top: insets.top + 10 }]} // đảm bảo không bị che bởi status bar
onPress={handleLogout}
activeOpacity={0.8}
>
<Text style={styles.logoutText}>Đăng xuất</Text>
</TouchableOpacity>
{/* Nội dung giữa màn hình */}
<View style={styles.centerContent}>
<Text style={styles.title}>Xin chào, User 👋</Text>
</View>
</View> </View>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
},
centerContent: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
title: {
fontSize: 20,
fontWeight: "600",
marginBottom: 20,
},
logoutBtn: {
position: "absolute",
right: 16, // sát mép phải
backgroundColor: "#f44336",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
zIndex: 999,
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.5,
},
logoutText: {
color: "#fff",
fontWeight: "700",
fontSize: 14,
},
});

View File

@@ -1,9 +0,0 @@
import { View, Text } from "react-native";
export default function HomeScreen() {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Chào mừng đến Sản phẩm!</Text>
</View>
);
}

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',
},
});

116
components/ProductCard.tsx Normal file
View File

@@ -0,0 +1,116 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Image } from 'react-native';
import { ProductResponse } from '../services/product';
import { useRouter } from 'expo-router';
interface ProductCardProps {
product: ProductResponse;
}
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
const router = useRouter();
const handlePress = () => {
router.push(`/products/${product.productId}`);
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
}).format(price);
};
return (
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
<Image
source={{ uri: product.imageUrl || 'https://via.placeholder.com/150' }}
style={styles.image}
resizeMode="cover"
/>
<View style={styles.content}>
<Text style={styles.productName} numberOfLines={2}>
{product.productName}
</Text>
<Text style={styles.categoryName} numberOfLines={1}>
{product.categoryName}
</Text>
<View style={styles.priceContainer}>
<Text style={styles.price}>{formatPrice(product.price)}</Text>
{product.stockQuantity > 0 ? (
<View style={styles.stockBadge}>
<Text style={styles.stockText}>Còn {product.stockQuantity}</Text>
</View>
) : (
<View style={[styles.stockBadge, styles.outOfStock]}>
<Text style={[styles.stockText, styles.outOfStockText]}>Hết hàng</Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
marginHorizontal: 16,
marginVertical: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
overflow: 'hidden',
},
image: {
width: '100%',
height: 200,
backgroundColor: '#f0f0f0',
},
content: {
padding: 12,
},
productName: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 4,
},
categoryName: {
fontSize: 13,
color: '#666',
marginBottom: 8,
},
priceContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
price: {
fontSize: 18,
fontWeight: '700',
color: '#e91e63',
},
stockBadge: {
backgroundColor: '#4caf50',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
stockText: {
fontSize: 12,
color: '#fff',
fontWeight: '500',
},
outOfStock: {
backgroundColor: '#f44336',
},
outOfStockText: {
color: '#fff',
},
});
export default ProductCard;

31
constants/Config.ts Normal file
View File

@@ -0,0 +1,31 @@
export const API_CONFIG = {
// Thay đổi URL này khi deploy
BASE_URL: __DEV__
? 'http://192.168.2.141:8080/api'
: 'http://192.168.2.141:8080/api',
TIMEOUT: 10000,
};
// Pagination
export const PAGINATION = {
DEFAULT_PAGE: 0,
DEFAULT_SIZE: 10,
MAX_SIZE: 50,
};
// Sort options
export const SORT_OPTIONS = {
PRODUCT_ID_ASC: { sortBy: 'productId', sortDir: 'asc' as const },
PRODUCT_ID_DESC: { sortBy: 'productId', sortDir: 'desc' as const },
PRICE_ASC: { sortBy: 'price', sortDir: 'asc' as const },
PRICE_DESC: { sortBy: 'price', sortDir: 'desc' as const },
NAME_ASC: { sortBy: 'productName', sortDir: 'asc' as const },
NAME_DESC: { sortBy: 'productName', sortDir: 'desc' as const },
};
export default {
API_CONFIG,
PAGINATION,
SORT_OPTIONS,
};

View File

@@ -0,0 +1,113 @@
import { useState, useEffect, useCallback } from 'react';
import productService, { ProductResponse, ProductListResponse } from '../services/product';
interface UseProductsOptions {
page?: number;
size?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
categoryId?: number;
keyword?: string;
availableOnly?: boolean;
}
export const useProducts = (options: UseProductsOptions = {}) => {
const [data, setData] = useState<ProductListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const {
page = 0,
size = 10,
sortBy = 'productId',
sortDir = 'asc',
categoryId,
keyword,
availableOnly = false,
} = options;
const fetchProducts = useCallback(async () => {
try {
setLoading(true);
setError(null);
let result: ProductListResponse;
if (keyword) {
result = await productService.searchProducts(keyword, page, size, sortBy, sortDir);
} else if (categoryId) {
result = await productService.getProductsByCategory(categoryId, page, size, sortBy, sortDir);
} else if (availableOnly) {
result = await productService.getAvailableProducts(page, size, sortBy, sortDir);
} else {
result = await productService.getAllProducts(page, size, sortBy, sortDir);
}
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}, [page, size, sortBy, sortDir, categoryId, keyword, availableOnly]);
const refresh = useCallback(async () => {
setRefreshing(true);
await fetchProducts();
setRefreshing(false);
}, [fetchProducts]);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
return {
products: data?.products || [],
currentPage: data?.currentPage || 0,
totalItems: data?.totalItems || 0,
totalPages: data?.totalPages || 0,
hasNext: data?.hasNext || false,
hasPrevious: data?.hasPrevious || false,
loading,
error,
refreshing,
refresh,
refetch: fetchProducts,
};
};
export const useProduct = (productId: number | null) => {
const [data, setData] = useState<ProductResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchProduct = useCallback(async () => {
if (!productId) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const result = await productService.getProductById(productId);
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}, [productId]);
useEffect(() => {
fetchProduct();
}, [fetchProduct]);
return {
product: data,
loading,
error,
refetch: fetchProduct,
};
};

View File

@@ -0,0 +1,198 @@
import { API_CONFIG } from '../constants/Config';
const API_URL = API_CONFIG.BASE_URL;
export interface ProductResponse {
productId: number;
productName: string;
description: string;
price: number;
stockQuantity: number;
imageUrl: string;
categoryId: number;
categoryName: string;
createdAt: string;
updatedAt: string;
}
export interface ProductListResponse {
products: ProductResponse[];
currentPage: number;
totalItems: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
}
class ProductService {
/**
* Lấy danh sách tất cả sản phẩm
*/
async getAllProducts(
page: number = 0,
size: number = 10,
sortBy: string = 'productId',
sortDir: 'asc' | 'desc' = 'asc'
): Promise<ProductListResponse> {
try {
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;
}
}
/**
* Lấy chi tiết sản phẩm theo ID
*/
async getProductById(id: number): Promise<ProductResponse> {
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;
}
}
/**
* Lấy sản phẩm theo category
*/
async getProductsByCategory(
categoryId: number,
page: number = 0,
size: number = 10,
sortBy: string = 'productId',
sortDir: 'asc' | 'desc' = 'asc'
): Promise<ProductListResponse> {
try {
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;
}
}
/**
* Tìm kiếm sản phẩm
*/
async searchProducts(
keyword: string,
page: number = 0,
size: number = 10,
sortBy: string = 'productId',
sortDir: 'asc' | 'desc' = 'asc'
): Promise<ProductListResponse> {
try {
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;
}
}
/**
* Lấy sản phẩm còn hàng
*/
async getAvailableProducts(
page: number = 0,
size: number = 10,
sortBy: string = 'productId',
sortDir: 'asc' | 'desc' = 'asc'
): Promise<ProductListResponse> {
try {
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;
}
}
}
export default new ProductService();