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
This commit is contained in:
2026-02-04 00:41:16 -06:00
parent 72f17c8be9
commit 9a7b00649b
22 changed files with 2273 additions and 128 deletions

View File

@@ -9,6 +9,7 @@ import { CartProvider } from "./context/CartContext";
// Layout
import Navbar from "./components/layout/Navbar";
import Footer from "./components/layout/Footer";
import ScrollToTop from "./components/ScrollToTop";
// Pages
import Home from "./pages/Home";
@@ -24,6 +25,7 @@ import Cart from "./pages/Cart";
import Profile from "./pages/Profile";
import OrderHistory from "./pages/OrderHistory";
import AdminDashboard from "./pages/AdminDashboard";
import VerifyEmail from "./pages/VerifyEmail";
function App() {
return (
@@ -31,6 +33,7 @@ function App() {
<AuthProvider>
<CartProvider>
<BrowserRouter>
<ScrollToTop />
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">
@@ -44,6 +47,7 @@ function App() {
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/login" element={<Login />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/cart" element={<Cart />} />
<Route path="/profile" element={<Profile />} />
<Route path="/orders" element={<OrderHistory />} />

View File

@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
/**
* ScrollToTop component that scrolls to the top of the page
* whenever the route changes
*/
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
export default ScrollToTop;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import { Link } from "react-router-dom";
import {
Users,
@@ -10,9 +10,19 @@ import {
Shield,
Star,
Lightbulb,
TrendingUp,
UserCheck,
Sparkles,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Badge } from "../components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../components/ui/dialog";
import axios from "axios";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
@@ -27,6 +37,22 @@ const iconMap = {
Shield: Shield,
Star: Star,
Lightbulb: Lightbulb,
TrendingUp: TrendingUp,
UserCheck: UserCheck,
Sparkles: Sparkles,
};
// Default icons for common values
const getDefaultIcon = (title) => {
const lowerTitle = title.toLowerCase();
if (lowerTitle.includes("quality")) return Sparkles;
if (lowerTitle.includes("customer") || lowerTitle.includes("focus"))
return UserCheck;
if (lowerTitle.includes("excellence") || lowerTitle.includes("excellent"))
return TrendingUp;
if (lowerTitle.includes("integrity") || lowerTitle.includes("honest"))
return Shield;
return Target;
};
const About = () => {
@@ -34,12 +60,62 @@ const About = () => {
const [values, setValues] = useState([]);
const [content, setContent] = useState({});
const [loading, setLoading] = useState(true);
const [valuesHeadingVisible, setValuesHeadingVisible] = useState(false);
const [visibleCards, setVisibleCards] = useState([]);
const [selectedValue, setSelectedValue] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
const valuesHeadingRef = useRef(null);
const valueCardsRefs = useRef([]);
useEffect(() => {
window.scrollTo(0, 0);
fetchAboutData();
}, []);
useEffect(() => {
// Observer for heading
const headingObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setValuesHeadingVisible(true);
}
});
},
{ threshold: 0.3 },
);
if (valuesHeadingRef.current) {
headingObserver.observe(valuesHeadingRef.current);
}
// Observer for individual cards
const cardsObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const index = parseInt(entry.target.dataset.index);
setVisibleCards((prev) => [...new Set([...prev, index])]);
}
});
},
{ threshold: 0.3 },
);
valueCardsRefs.current.forEach((ref) => {
if (ref) cardsObserver.observe(ref);
});
return () => {
if (valuesHeadingRef.current) {
headingObserver.unobserve(valuesHeadingRef.current);
}
valueCardsRefs.current.forEach((ref) => {
if (ref) cardsObserver.unobserve(ref);
});
};
}, [values]);
const fetchAboutData = async () => {
try {
const [teamRes, valuesRes, contentRes] = await Promise.all([
@@ -91,8 +167,11 @@ const About = () => {
};
// Get icon component from string name
const getIcon = (iconName) => {
return iconMap[iconName] || Target;
const getIcon = (iconName, title) => {
if (iconName && iconMap[iconName]) {
return iconMap[iconName];
}
return getDefaultIcon(title);
};
if (loading) {
@@ -162,28 +241,108 @@ const About = () => {
</section>
{/* Stats */}
<section className="py-12 bg-muted/30">
<section className="py-12 bg-muted/30 overflow-hidden">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{(
content.stats?.data?.stats || [
{ value: "1K+", label: "Happy Customers" },
{ value: "500+", label: "Products Sold" },
{ value: "1,500+", label: "Repairs Done" },
{ value: "90%", label: "Satisfaction Rate" },
]
).map((stat, idx) => (
<div
key={idx}
className="text-center"
data-testid={`stat-${idx}`}
>
<p className="text-4xl md:text-5xl font-bold font-['Outfit'] mb-2">
{stat.value}
</p>
<p className="text-muted-foreground">{stat.label}</p>
<div className="relative">
<style>{`
@keyframes scroll-left {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
.animate-scroll {
animation: scroll-left 15s linear infinite;
}
`}</style>
<div className="flex">
<div className="flex animate-scroll">
{/* First set */}
{(
content.stats?.data?.stats || [
{ value: "1K+", label: "Happy Customers" },
{ value: "500+", label: "Products Sold" },
{ value: "1,500+", label: "Repairs Done" },
{ value: "90%", label: "Satisfaction Rate" },
]
).map((stat, idx) => (
<div
key={idx}
className="text-center flex-shrink-0 px-8 md:px-16 min-w-[200px]"
data-testid={`stat-${idx}`}
>
<p className="text-4xl md:text-5xl font-bold font-['Outfit'] mb-2">
{stat.value}
</p>
<p className="text-muted-foreground whitespace-nowrap">
{stat.label}
</p>
</div>
))}
{/* Duplicate set for seamless loop */}
{(
content.stats?.data?.stats || [
{ value: "1K+", label: "Happy Customers" },
{ value: "500+", label: "Products Sold" },
{ value: "1,500+", label: "Repairs Done" },
{ value: "90%", label: "Satisfaction Rate" },
]
).map((stat, idx) => (
<div
key={`dup-${idx}`}
className="text-center flex-shrink-0 px-8 md:px-16 min-w-[200px]"
>
<p className="text-4xl md:text-5xl font-bold font-['Outfit'] mb-2">
{stat.value}
</p>
<p className="text-muted-foreground whitespace-nowrap">
{stat.label}
</p>
</div>
))}
</div>
))}
{/* Duplicate entire animation block for truly seamless loop */}
<div className="flex animate-scroll" aria-hidden="true">
{(
content.stats?.data?.stats || [
{ value: "1K+", label: "Happy Customers" },
{ value: "500+", label: "Products Sold" },
{ value: "1,500+", label: "Repairs Done" },
{ value: "90%", label: "Satisfaction Rate" },
]
).map((stat, idx) => (
<div
key={`set2-${idx}`}
className="text-center flex-shrink-0 px-8 md:px-16 min-w-[200px]"
>
<p className="text-4xl md:text-5xl font-bold font-['Outfit'] mb-2">
{stat.value}
</p>
<p className="text-muted-foreground whitespace-nowrap">
{stat.label}
</p>
</div>
))}
{(
content.stats?.data?.stats || [
{ value: "1K+", label: "Happy Customers" },
{ value: "500+", label: "Products Sold" },
{ value: "1,500+", label: "Repairs Done" },
{ value: "90%", label: "Satisfaction Rate" },
]
).map((stat, idx) => (
<div
key={`set2-dup-${idx}`}
className="text-center flex-shrink-0 px-8 md:px-16 min-w-[200px]"
>
<p className="text-4xl md:text-5xl font-bold font-['Outfit'] mb-2">
{stat.value}
</p>
<p className="text-muted-foreground whitespace-nowrap">
{stat.label}
</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
@@ -231,7 +390,10 @@ const About = () => {
{/* Values */}
<section className="py-16 md:py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="text-center mb-12">
<div
ref={valuesHeadingRef}
className={`text-center mb-12 transition-all duration-700 ${valuesHeadingVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"}`}
>
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Our Values
</h2>
@@ -241,71 +403,179 @@ const About = () => {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<style>{`
@keyframes slide-up-fade {
from {
opacity: 0;
transform: translateY(60px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.value-card {
opacity: 0;
transform: translateY(60px);
cursor: pointer;
transition: all 0.3s ease;
}
.value-card.visible {
animation: slide-up-fade 0.8s ease-out forwards;
}
.value-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
.value-card:hover .card-bg {
opacity: 1;
}
.card-bg {
position: absolute;
inset: 0;
border-radius: 1rem;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.quality-bg { background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(168, 85, 247, 0.1)); }
.customer-bg { background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(14, 165, 233, 0.1)); }
.excellence-bg { background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(249, 115, 22, 0.1)); }
.integrity-bg { background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(16, 185, 129, 0.1)); }
`}</style>
{values.length > 0
? values.map((value, idx) => {
const Icon = getIcon(value.icon);
const Icon = getIcon(value.icon, value.title);
const bgClass = [
"quality-bg",
"customer-bg",
"excellence-bg",
"integrity-bg",
][idx % 4];
return (
<div
key={value.id || idx}
className="p-6 rounded-2xl bg-card border border-border hover-lift text-center"
ref={(el) => (valueCardsRefs.current[idx] = el)}
data-index={idx}
className={`value-card relative p-6 rounded-2xl bg-card border border-border text-center overflow-hidden ${visibleCards.includes(idx) ? "visible" : ""}`}
onClick={() => {
setSelectedValue(value);
setDialogOpen(true);
}}
data-testid={`value-${idx}`}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-7 w-7 text-primary" />
<div className={`card-bg ${bgClass}`} />
<div className="relative z-10">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-7 w-7 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
{value.title}
</h3>
<p className="text-sm text-muted-foreground">
{value.description}
</p>
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
{value.title}
</h3>
<p className="text-sm text-muted-foreground">
{value.description}
</p>
</div>
);
})
: // Fallback values
[
{
icon: Target,
icon: Sparkles,
title: "Quality First",
desc: "We never compromise on the quality of our products and services.",
},
{
icon: Users,
icon: UserCheck,
title: "Customer Focus",
desc: "Your satisfaction is our top priority. We listen and deliver.",
},
{
icon: Award,
icon: TrendingUp,
title: "Excellence",
desc: "We strive for excellence in everything we do.",
},
{
icon: Heart,
icon: Shield,
title: "Integrity",
desc: "Honest, transparent, and ethical business practices.",
},
].map((value, idx) => {
const Icon = value.icon;
const bgClass = [
"quality-bg",
"customer-bg",
"excellence-bg",
"integrity-bg",
][idx];
return (
<div
key={idx}
className="p-6 rounded-2xl bg-card border border-border hover-lift text-center"
ref={(el) => (valueCardsRefs.current[idx] = el)}
data-index={idx}
className={`value-card relative p-6 rounded-2xl bg-card border border-border text-center overflow-hidden ${visibleCards.includes(idx) ? "visible" : ""}`}
onClick={() => {
setSelectedValue({
title: value.title,
description: value.desc,
icon: value.icon,
});
setDialogOpen(true);
}}
data-testid={`value-${idx}`}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-7 w-7 text-primary" />
<div className={`card-bg ${bgClass}`} />
<div className="relative z-10">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-7 w-7 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
{value.title}
</h3>
<p className="text-sm text-muted-foreground">
{value.desc}
</p>
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
{value.title}
</h3>
<p className="text-sm text-muted-foreground">
{value.desc}
</p>
</div>
);
})}
</div>
</div>
{/* Value Detail Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
{selectedValue && (
<>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
{React.createElement(
getIcon(selectedValue.icon, selectedValue.title),
{ className: "h-8 w-8 text-primary" },
)}
</div>
<DialogTitle className="text-2xl font-bold text-center font-['Outfit']">
{selectedValue.title}
</DialogTitle>
<DialogDescription className="text-center text-base mt-4">
{selectedValue.description}
</DialogDescription>
<div className="mt-6 p-6 rounded-lg bg-muted/50">
<p className="text-sm text-muted-foreground leading-relaxed">
At PromptTech Solutions,{" "}
<strong>{selectedValue.title.toLowerCase()}</strong> is at
the core of everything we do. We believe that{" "}
{selectedValue.description.toLowerCase()} This commitment
drives us to deliver exceptional service and build lasting
relationships with our customers.
</p>
</div>
</>
)}
</DialogHeader>
</DialogContent>
</Dialog>
</section>
{/* Team */}

View File

@@ -58,7 +58,7 @@ const Contact = () => {
{
icon: Clock,
title: "Business Hours",
content: "Mon - Sat: 9AM - 7PM",
content: "Mon-Fri: 8AM-5PM | Sat: 9AM-5PM",
},
];
@@ -245,7 +245,7 @@ const Contact = () => {
{[
{
q: "What are your business hours?",
a: "We are open Monday through Saturday, 9AM to 7PM. We are closed on Sundays.",
a: "We are open Monday to Friday, 8AM to 5PM, and Saturday, 9AM to 5PM. We are closed on Sundays.",
},
{
q: "Do you offer warranty on repairs?",
@@ -257,7 +257,7 @@ const Contact = () => {
},
{
q: "Do you offer pickup and delivery?",
a: "Yes, we offer free pickup and delivery for repairs within a 10-mile radius.",
a: "Yes, we do offer pickups within Belmopan and free delivery within Belmopan. Anything outside Belmopan region must be either with a carrier or with BPMS or Interdistrict Belize covered by the customer.",
},
].map((faq, idx) => (
<div

View File

@@ -58,14 +58,21 @@ const Home = () => {
}, []);
const features = [
{ icon: Truck, title: "Free Shipping", desc: "On orders over $100" },
{ icon: Shield, title: "Warranty", desc: "1 Year manufacturer warranty" },
{
icon: Shield,
title: "6 Months Warranty",
desc: "Comprehensive coverage",
},
{
icon: Headphones,
title: "24/7 Support",
desc: "Expert assistance anytime",
title: "Monday-Friday Support",
desc: "Expert assistance 8 AM - 5 PM",
},
{
icon: Wrench,
title: "Certified Technician",
desc: "Professional repair service",
},
{ icon: Wrench, title: "Expert Repair", desc: "Certified technicians" },
];
const categories = [
@@ -86,7 +93,7 @@ const Home = () => {
New Arrivals Available
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight tracking-tight font-['Outfit']">
Premium Tech,
PromptTech Solution,
<br />
<span className="text-muted-foreground">Expert Service</span>
</h1>
@@ -141,13 +148,13 @@ const Home = () => {
{/* Features Bar */}
<section className="border-y border-border bg-muted/30 py-8">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{features.map((feature, idx) => {
const Icon = feature.icon;
return (
<div
key={idx}
className="flex items-center gap-3"
className="flex flex-col items-center text-center gap-3"
data-testid={`feature-${idx}`}
>
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">

View File

@@ -1,5 +1,10 @@
import React, { useState } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import React, { useState, useEffect } from "react";
import {
Link,
useNavigate,
useLocation,
useSearchParams,
} from "react-router-dom";
import { Mail, Lock, User, ArrowRight, Eye, EyeOff } from "lucide-react";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -8,29 +13,51 @@ import { Separator } from "../components/ui/separator";
import { useAuth } from "../context/AuthContext";
import { toast } from "sonner";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { login, register } = useAuth();
const [isRegister, setIsRegister] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: "",
firstName: "",
lastName: "",
email: "",
password: "",
});
const from = location.state?.from?.pathname || "/";
// Handle OAuth callback token
useEffect(() => {
const token = searchParams.get("token");
const error = searchParams.get("error");
if (token) {
// Store token and redirect
localStorage.setItem("token", token);
toast.success("Successfully logged in!");
navigate(from, { replace: true });
} else if (error) {
toast.error("Authentication failed. Please try again.");
}
}, [searchParams, navigate, from]);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (isRegister) {
await register(formData.name, formData.email, formData.password);
toast.success("Account created successfully!");
const fullName = `${formData.firstName} ${formData.lastName}`.trim();
await register(fullName, formData.email, formData.password);
toast.success(
"Account created! Please check your email to verify your account.",
);
} else {
await login(formData.email, formData.password);
toast.success("Welcome back!");
@@ -43,6 +70,11 @@ const Login = () => {
}
};
const handleSocialLogin = (provider) => {
// Redirect to backend OAuth endpoint
window.location.href = `${API}/auth/${provider}`;
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4">
<div className="w-full max-w-md">
@@ -74,23 +106,36 @@ const Login = () => {
<div className="border border-border rounded-2xl bg-card p-6 md:p-8">
<form onSubmit={handleSubmit} className="space-y-4">
{isRegister && (
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="name"
placeholder="John Doe"
className="pl-10"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required={isRegister}
data-testid="register-name"
/>
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
placeholder="John"
value={formData.firstName}
onChange={(e) =>
setFormData({ ...formData, firstName: e.target.value })
}
required={isRegister}
data-testid="register-firstname"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
placeholder="Doe"
value={formData.lastName}
onChange={(e) =>
setFormData({ ...formData, lastName: e.target.value })
}
required={isRegister}
data-testid="register-lastname"
/>
</div>
</div>
</div>
</>
)}
<div className="space-y-2">
@@ -166,14 +211,80 @@ const Login = () => {
{loading
? "Please wait..."
: isRegister
? "Create Account"
: "Sign In"}
? "Create Account"
: "Sign In"}
<ArrowRight className="h-4 w-4" />
</Button>
</form>
<Separator className="my-6" />
{/* Social Login Buttons */}
<div className="space-y-3">
<p className="text-center text-sm text-muted-foreground mb-4">
Or continue with
</p>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => handleSocialLogin("google")}
data-testid="google-login"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => handleSocialLogin("facebook")}
data-testid="facebook-login"
>
<svg className="w-5 h-5 mr-2" fill="#1877F2" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
Sign in with Facebook
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => handleSocialLogin("yahoo")}
data-testid="yahoo-login"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="#5F01D1"
d="M13.131 21.415v-6.844l5.943-11.571h-4.161l-3.281 6.865-3.281-6.865H4.19l5.876 11.497v6.918z"
/>
</svg>
Sign in with Yahoo
</Button>
</div>
<Separator className="my-6" />
<p className="text-center text-sm text-muted-foreground">
{isRegister ? "Already have an account?" : "Don't have an account?"}{" "}
<button

View File

@@ -43,14 +43,36 @@ const ProductDetail = () => {
fetchProduct();
}, [id]);
const fetchProduct = async () => {
// Auto-refresh every 5 seconds for real-time updates
useEffect(() => {
const interval = setInterval(() => {
fetchProduct(true);
}, 5000);
return () => clearInterval(interval);
}, [id]);
// Refresh when user returns to the tab
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
fetchProduct();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [id]);
const fetchProduct = async (silent = false) => {
try {
const response = await axios.get(`${API}/products/${id}`);
setProduct(response.data);
} catch (error) {
console.error("Failed to fetch product:", error);
} finally {
setLoading(false);
if (!silent) setLoading(false);
}
};
@@ -83,7 +105,7 @@ const ProductDetail = () => {
},
{
headers: { Authorization: `Bearer ${token}` },
}
},
);
toast.success("Review submitted successfully!");
setReviewForm({ rating: 5, title: "", comment: "" });

View File

@@ -37,7 +37,7 @@ const Products = () => {
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(searchParams.get("search") || "");
const [category, setCategory] = useState(
searchParams.get("category") || "all"
searchParams.get("category") || "all",
);
const [priceRange, setPriceRange] = useState([0, 3000]);
const [sortBy, setSortBy] = useState("name");
@@ -62,25 +62,37 @@ const Products = () => {
fetchProducts();
}, [category, search]);
const fetchProducts = async () => {
setLoading(true);
// 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 cacheKey = `products-${params.toString()}`;
const cached = getCached(cacheKey);
if (cached) {
setProducts(cached);
setLoading(false);
return;
}
const response = await axios.get(`${API}/products?${params.toString()}`);
setProducts(response.data);
setCache(cacheKey, response.data);
} catch (error) {
console.error("Failed to fetch products:", error);
} finally {

View File

@@ -42,18 +42,41 @@ const ServiceDetail = () => {
notes: "",
});
const fetchService = async (silent = false) => {
try {
const response = await axios.get(`${API}/services/${id}`);
setService(response.data);
} catch (error) {
console.error("Failed to fetch service:", error);
} finally {
if (!silent) setLoading(false);
}
};
useEffect(() => {
const fetchService = async () => {
try {
const response = await axios.get(`${API}/services/${id}`);
setService(response.data);
} catch (error) {
console.error("Failed to fetch service:", error);
} finally {
setLoading(false);
fetchService();
}, [id]);
// Auto-refresh every 5 seconds for real-time updates
useEffect(() => {
const interval = setInterval(() => {
fetchService(true);
}, 5000);
return () => clearInterval(interval);
}, [id]);
// Refresh when user returns to the tab
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
fetchService();
}
};
fetchService();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [id]);
const handleSubmit = async (e) => {

View File

@@ -38,23 +38,36 @@ const Services = () => {
fetchServices();
}, [activeCategory]);
const fetchServices = async () => {
setLoading(true);
// Auto-refresh every 5 seconds for real-time updates
useEffect(() => {
const interval = setInterval(() => {
fetchServices(true);
}, 5000);
return () => clearInterval(interval);
}, [activeCategory]);
// Refresh when user returns to the tab
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
fetchServices();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [activeCategory]);
const fetchServices = async (silent = false) => {
if (!silent) setLoading(true);
try {
const params =
activeCategory !== "all" ? `?category=${activeCategory}` : "";
const cacheKey = `services-${activeCategory}`;
const cached = getCached(cacheKey);
if (cached) {
setServices(cached);
setLoading(false);
return;
}
const response = await axios.get(`${API}/services${params}`);
setServices(response.data);
setCache(cacheKey, response.data);
} catch (error) {
console.error("Failed to fetch services:", error);
} finally {

View File

@@ -0,0 +1,124 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
const API = process.env.REACT_APP_API_URL || "http://localhost:8181/api";
const VerifyEmail = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState("verifying"); // verifying, success, error
const [message, setMessage] = useState("Verifying your email...");
useEffect(() => {
const verifyEmail = async () => {
const token = searchParams.get("token");
if (!token) {
setStatus("error");
setMessage(
"Invalid verification link. Please check your email and try again.",
);
return;
}
try {
const response = await fetch(
`${API}/auth/verify-email?token=${encodeURIComponent(token)}`,
);
const data = await response.json();
if (response.ok) {
setStatus("success");
setMessage("Email verified successfully! Redirecting to login...");
// Redirect to login after 3 seconds
setTimeout(() => {
navigate("/login");
}, 3000);
} else {
setStatus("error");
setMessage(
data.detail ||
"Verification failed. The link may be expired or invalid.",
);
}
} catch (error) {
console.error("Verification error:", error);
setStatus("error");
setMessage(
"An error occurred during verification. Please try again later.",
);
}
};
verifyEmail();
}, [searchParams, navigate]);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
<div className="text-center">
{/* Status Icon */}
<div className="mb-6 flex justify-center">
{status === "verifying" && (
<Loader2 className="w-16 h-16 text-blue-600 animate-spin" />
)}
{status === "success" && (
<CheckCircle className="w-16 h-16 text-green-600" />
)}
{status === "error" && (
<XCircle className="w-16 h-16 text-red-600" />
)}
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-gray-900 mb-3">
{status === "verifying" && "Verifying Email"}
{status === "success" && "Email Verified!"}
{status === "error" && "Verification Failed"}
</h1>
{/* Message */}
<p className="text-gray-600 mb-6">{message}</p>
{/* Actions */}
{status === "error" && (
<div className="space-y-3">
<button
onClick={() => navigate("/login")}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Back to Login
</button>
<button
onClick={() =>
(window.location.href = "mailto:prompttechbz@gmail.com")
}
className="w-full bg-gray-100 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-200 transition-colors"
>
Contact Support
</button>
</div>
)}
{status === "success" && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-800 text-sm">
Your account is now active
<br /> You can now log in and start shopping
</p>
</div>
)}
</div>
{/* Footer */}
<div className="mt-8 pt-6 border-t border-gray-200 text-center">
<p className="text-sm text-gray-500">PromptTech Solution</p>
</div>
</div>
</div>
);
};
export default VerifyEmail;

View File

@@ -1,6 +1,6 @@
// Simple in-memory cache for API responses
const cache = new Map();
const CACHE_DURATION = 60000; // 60 seconds
const CACHE_DURATION = 30000; // 30 seconds (reduced from 60)
export const getCached = (key) => {
const cached = cache.get(key);
@@ -24,7 +24,19 @@ export const setCache = (key, data) => {
export const clearCache = (key) => {
if (key) {
cache.delete(key);
// If key ends with a dash, clear all keys starting with that prefix
if (key.endsWith("-")) {
const prefix = key;
const keysToDelete = [];
for (const [cacheKey] of cache) {
if (cacheKey.startsWith(prefix)) {
keysToDelete.push(cacheKey);
}
}
keysToDelete.forEach((k) => cache.delete(k));
} else {
cache.delete(key);
}
} else {
cache.clear();
}