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:
@@ -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 />} />
|
||||
|
||||
18
frontend/src/components/ScrollToTop.js
Normal file
18
frontend/src/components/ScrollToTop.js
Normal 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;
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
124
frontend/src/pages/VerifyEmail.js
Normal file
124
frontend/src/pages/VerifyEmail.js
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user