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

@@ -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>

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

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