products
This commit is contained in:
38
app.json
38
app.json
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
381
app/(tabs)/products/[id].tsx
Normal file
381
app/(tabs)/products/[id].tsx
Normal 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}>Mô 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}>Mã 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
17
app/(tabs)/products/_layout.tsx
Normal file
17
app/(tabs)/products/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
app/(tabs)/products/index.tsx
Normal file
302
app/(tabs)/products/index.tsx
Normal 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 có 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
116
components/ProductCard.tsx
Normal 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
31
constants/Config.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user