- Add email verification with token-based validation - Integrate Google, Facebook, and Yahoo OAuth providers - Add OAuth configuration and email service modules - Update User model with email_verified, oauth_provider, oauth_id fields - Implement async password hashing/verification to prevent blocking - Add database migration script for new user fields - Create email verification page with professional UI - Update login page with social login buttons (Google, Facebook, Yahoo) - Add OAuth callback token handling - Implement scroll-to-top navigation component - Add 5-second real-time polling for Products and Services pages - Enhance About page with Apple-style scroll animations - Update Home and Contact pages with branding and business info - Optimize API cache with prefix-based clearing - Create comprehensive setup documentation and quick start guide - Fix login performance with ThreadPoolExecutor for bcrypt operations Performance improvements: - Login time optimized to ~220ms with async password verification - Real-time data updates every 5 seconds - Non-blocking password operations Security enhancements: - Email verification required for new accounts - OAuth integration for secure social login - Verification tokens expire after 24 hours - Password field nullable for OAuth users
404 lines
13 KiB
JavaScript
404 lines
13 KiB
JavaScript
import React, { useState, useEffect, useMemo } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import axios from "axios";
|
|
import {
|
|
Search,
|
|
SlidersHorizontal,
|
|
X,
|
|
Grid3X3,
|
|
LayoutList,
|
|
} from "lucide-react";
|
|
import { Button } from "../components/ui/button";
|
|
import { Input } from "../components/ui/input";
|
|
import { Badge } from "../components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "../components/ui/select";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
} from "../components/ui/sheet";
|
|
import { Slider } from "../components/ui/slider";
|
|
import ProductCard from "../components/cards/ProductCard";
|
|
import { getCached, setCache } from "../utils/apiCache";
|
|
|
|
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
|
|
|
|
const Products = () => {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [products, setProducts] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState(searchParams.get("search") || "");
|
|
const [category, setCategory] = useState(
|
|
searchParams.get("category") || "all",
|
|
);
|
|
const [priceRange, setPriceRange] = useState([0, 3000]);
|
|
const [sortBy, setSortBy] = useState("name");
|
|
const [viewMode, setViewMode] = useState("grid");
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
|
|
const categories = [
|
|
{ value: "all", label: "All Products" },
|
|
{ value: "phones", label: "Phones" },
|
|
{ value: "laptops", label: "Laptops" },
|
|
{ value: "tablets", label: "Tablets" },
|
|
{ value: "wearables", label: "Wearables" },
|
|
{ value: "accessories", label: "Accessories" },
|
|
];
|
|
|
|
useEffect(() => {
|
|
// Scroll to top when component mounts
|
|
window.scrollTo(0, 0);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchProducts();
|
|
}, [category, search]);
|
|
|
|
// Auto-refresh every 5 seconds for real-time updates
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
fetchProducts(true); // Silent refresh without loading spinner
|
|
}, 5000); // 5 seconds
|
|
|
|
return () => clearInterval(interval);
|
|
}, [category, search]);
|
|
|
|
// Refresh products when user returns to the tab
|
|
useEffect(() => {
|
|
const handleVisibilityChange = () => {
|
|
if (!document.hidden) {
|
|
fetchProducts();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
return () =>
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
}, [category, search]);
|
|
|
|
const fetchProducts = async (silent = false) => {
|
|
if (!silent) setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (category && category !== "all") params.append("category", category);
|
|
if (search) params.append("search", search);
|
|
|
|
const response = await axios.get(`${API}/products?${params.toString()}`);
|
|
setProducts(response.data);
|
|
} catch (error) {
|
|
console.error("Failed to fetch products:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSearch = (e) => {
|
|
e.preventDefault();
|
|
setSearchParams({ search, category });
|
|
fetchProducts();
|
|
};
|
|
|
|
const handleCategoryChange = (value) => {
|
|
setCategory(value);
|
|
setSearchParams({ search, category: value });
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSearch("");
|
|
setCategory("all");
|
|
setPriceRange([0, 3000]);
|
|
setSearchParams({});
|
|
};
|
|
|
|
const filteredProducts = products
|
|
.filter((p) => p.price >= priceRange[0] && p.price <= priceRange[1])
|
|
.sort((a, b) => {
|
|
if (sortBy === "price-low") return a.price - b.price;
|
|
if (sortBy === "price-high") return b.price - a.price;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
const activeFiltersCount = [
|
|
category !== "all",
|
|
search !== "",
|
|
priceRange[0] > 0 || priceRange[1] < 3000,
|
|
].filter(Boolean).length;
|
|
|
|
const FilterContent = () => (
|
|
<div className="space-y-6">
|
|
{/* Categories */}
|
|
<div>
|
|
<h4 className="font-semibold mb-3 font-['Outfit']">Categories</h4>
|
|
<div className="space-y-2">
|
|
{categories.map((cat) => (
|
|
<button
|
|
key={cat.value}
|
|
onClick={() => handleCategoryChange(cat.value)}
|
|
data-testid={`filter-category-${cat.value}`}
|
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
|
category === cat.value
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-accent"
|
|
}`}
|
|
>
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Price Range */}
|
|
<div>
|
|
<h4 className="font-semibold mb-3 font-['Outfit']">Price Range</h4>
|
|
<div className="px-2">
|
|
<Slider
|
|
value={priceRange}
|
|
onValueChange={setPriceRange}
|
|
max={3000}
|
|
step={50}
|
|
className="mb-4"
|
|
data-testid="price-slider"
|
|
/>
|
|
<div className="flex justify-between text-sm text-muted-foreground">
|
|
<span>${priceRange[0]}</span>
|
|
<span>${priceRange[1]}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Clear Filters */}
|
|
{activeFiltersCount > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full rounded-full"
|
|
onClick={clearFilters}
|
|
data-testid="clear-filters"
|
|
>
|
|
Clear All Filters
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen py-8 md:py-12">
|
|
<div className="max-w-7xl mx-auto px-4 md:px-8">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-2">
|
|
Products
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
Discover our wide range of premium electronics
|
|
</p>
|
|
</div>
|
|
|
|
{/* Search & Filters Bar */}
|
|
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
|
{/* Search */}
|
|
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search products..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-10 rounded-full"
|
|
data-testid="product-search-input"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
className="rounded-full"
|
|
data-testid="search-button"
|
|
>
|
|
Search
|
|
</Button>
|
|
</form>
|
|
|
|
{/* Controls */}
|
|
<div className="flex gap-2">
|
|
{/* Sort */}
|
|
<Select value={sortBy} onValueChange={setSortBy}>
|
|
<SelectTrigger
|
|
className="w-[160px] rounded-full"
|
|
data-testid="sort-select"
|
|
>
|
|
<SelectValue placeholder="Sort by" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="name">Name</SelectItem>
|
|
<SelectItem value="price-low">Price: Low to High</SelectItem>
|
|
<SelectItem value="price-high">Price: High to Low</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* View Mode */}
|
|
<div className="hidden md:flex border border-input rounded-full p-1">
|
|
<Button
|
|
size="icon"
|
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
|
className="rounded-full h-8 w-8"
|
|
onClick={() => setViewMode("grid")}
|
|
data-testid="view-grid"
|
|
>
|
|
<Grid3X3 className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant={viewMode === "list" ? "default" : "ghost"}
|
|
className="rounded-full h-8 w-8"
|
|
onClick={() => setViewMode("list")}
|
|
data-testid="view-list"
|
|
>
|
|
<LayoutList className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Mobile Filters */}
|
|
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
|
<SheetTrigger asChild className="md:hidden">
|
|
<Button
|
|
variant="outline"
|
|
className="rounded-full gap-2"
|
|
data-testid="mobile-filters"
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
Filters
|
|
{activeFiltersCount > 0 && (
|
|
<Badge className="ml-1 h-5 w-5 p-0 flex items-center justify-center">
|
|
{activeFiltersCount}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left">
|
|
<SheetHeader>
|
|
<SheetTitle>Filters</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="mt-6">
|
|
<FilterContent />
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active Filters */}
|
|
{(category !== "all" || search) && (
|
|
<div className="flex flex-wrap gap-2 mb-6">
|
|
{category !== "all" && (
|
|
<Badge variant="secondary" className="gap-1 pr-1">
|
|
{categories.find((c) => c.value === category)?.label}
|
|
<button
|
|
onClick={() => handleCategoryChange("all")}
|
|
className="ml-1 h-4 w-4 rounded-full hover:bg-muted flex items-center justify-center"
|
|
data-testid="remove-category-filter"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
{search && (
|
|
<Badge variant="secondary" className="gap-1 pr-1">
|
|
Search: {search}
|
|
<button
|
|
onClick={() => {
|
|
setSearch("");
|
|
setSearchParams({ category });
|
|
}}
|
|
className="ml-1 h-4 w-4 rounded-full hover:bg-muted flex items-center justify-center"
|
|
data-testid="remove-search-filter"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content */}
|
|
<div className="flex gap-8">
|
|
{/* Desktop Sidebar */}
|
|
<aside className="hidden md:block w-64 flex-shrink-0">
|
|
<div className="sticky top-24 border border-border rounded-xl p-6 bg-card">
|
|
<FilterContent />
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Products Grid */}
|
|
<div className="flex-1">
|
|
{loading ? (
|
|
<div
|
|
className={`grid gap-6 ${
|
|
viewMode === "grid"
|
|
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
|
: "grid-cols-1"
|
|
}`}
|
|
>
|
|
{[...Array(6)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-xl border border-border bg-card p-4 animate-pulse"
|
|
>
|
|
<div className="aspect-square bg-muted rounded-lg mb-4" />
|
|
<div className="h-4 bg-muted rounded w-2/3 mb-2" />
|
|
<div className="h-6 bg-muted rounded w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filteredProducts.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted flex items-center justify-center">
|
|
<Search className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
|
|
No products found
|
|
</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
Try adjusting your search or filters
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
onClick={clearFilters}
|
|
className="rounded-full"
|
|
>
|
|
Clear Filters
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Showing {filteredProducts.length} products
|
|
</p>
|
|
<div
|
|
className={`grid gap-6 ${
|
|
viewMode === "grid"
|
|
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
|
: "grid-cols-1"
|
|
}`}
|
|
>
|
|
{filteredProducts.map((product) => (
|
|
<ProductCard key={product.id} product={product} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Products;
|