Files
PromptTech/frontend/src/pages/Products.js
Kristen Hercules 9a7b00649b feat: Implement comprehensive OAuth and email verification authentication system
- 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
2026-02-04 00:41:16 -06:00

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;