products
This commit is contained in:
38
app.json
38
app.json
@@ -5,44 +5,36 @@
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "projectit207",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.yourcompany.projectit207"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
"package": "com.yourcompany.projectit207",
|
||||
"permissions": ["INTERNET"]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
"expo-font"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,10 @@
|
||||
import { Tabs } from "expo-router";
|
||||
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() {
|
||||
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 (
|
||||
<Tabs screenOptions={{ headerShown: true, tabBarActiveTintColor: "#007AFF" }}>
|
||||
<Tabs screenOptions={{ headerShown: false, tabBarActiveTintColor: "#007AFF" }}>
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
@@ -45,7 +24,6 @@ export default function TabsLayout() {
|
||||
options={{
|
||||
title: "Tài khoản",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />,
|
||||
headerRight: () => <Button title="Logout" onPress={handleLogout} color="#FF3B30" />,
|
||||
}}
|
||||
/>
|
||||
</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 (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Chào mừng đến Tài khoản!</Text>
|
||||
<View style={styles.container}>
|
||||
{/* 🔘 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>
|
||||
);
|
||||
}
|
||||
|
||||
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