Initial commit - PromptTech

This commit is contained in:
2026-01-27 18:07:00 -06:00
commit 3959a223bf
262 changed files with 128736 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
REACT_APP_BACKEND_URL=https://dev-foundry.preview.emergentagent.com
WDS_SOCKET_PORT=443
ENABLE_HEALTH_CHECK=false

View File

@@ -0,0 +1,94 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-alert-dialog": "^1.1.11",
"@radix-ui/react-aspect-ratio": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.7",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-collapsible": "^1.1.8",
"@radix-ui/react-context-menu": "^2.2.12",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.11",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-navigation-menu": "^1.2.10",
"@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.4",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-toggle": "^1.1.6",
"@radix-ui/react-toggle-group": "^1.1.7",
"@radix-ui/react-tooltip": "^1.2.4",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cra-template": "1.2.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"input-otp": "^1.4.2",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.507.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.2",
"react-resizable-panels": "^3.0.1",
"react-router-dom": "^7.5.1",
"react-scripts": "5.0.1",
"react-to-print": "^3.2.0",
"recharts": "^3.6.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.24.4"
},
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@craco/craco": "^7.1.0",
"@eslint/js": "9.23.0",
"autoprefixer": "^10.4.20",
"eslint": "9.23.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.4",
"eslint-plugin-react-hooks": "5.2.0",
"globals": "15.15.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,178 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="A product of emergent.sh" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Emergent | Fullstack App</title>
<script src="https://assets.emergent.sh/scripts/emergent-main.js"></script>
<!--
These two scripts have been added for the Visual Edits, please do not edit or remove them
-->
<script>
// Only load visual edit scripts when inside an iframe
if (window.self !== window.top) {
// Load debug monitor script
var debugMonitorScript = document.createElement('script');
debugMonitorScript.src = 'https://assets.emergent.sh/scripts/debug-monitor.js';
document.head.appendChild(debugMonitorScript);
// Configure Tailwind
window.tailwind = window.tailwind || {};
tailwind.config = {
corePlugins: { preflight: false },
};
// Load Tailwind CDN
var tailwindScript = document.createElement('script');
tailwindScript.src = 'https://cdn.tailwindcss.com';
document.head.appendChild(tailwindScript);
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<a
id="emergent-badge"
target="_blank"
href="https://app.emergent.sh/?utm_source=emergent-badge"
style="
display: flex !important;
align-items: center !important;
position: fixed !important;
bottom: 20px;
right: 20px;
text-decoration: none;
padding: 6px 10px;
font-family: -apple-system, BlinkMacSystemFont,
&quot;Segoe UI&quot;, Roboto, Oxygen, Ubuntu, Cantarell,
&quot;Open Sans&quot;, &quot;Helvetica Neue&quot;,
sans-serif !important;
font-size: 12px !important;
z-index: 9999 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
border-radius: 8px !important;
background-color: #ffffff !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
"
>
<div
style="display: flex; flex-direction: row; align-items: center"
>
<img
style="width: 20px; height: 20px; margin-right: 8px"
src="https://avatars.githubusercontent.com/in/1201222?s=120&u=2686cf91179bbafbc7a71bfbc43004cf9ae1acea&v=4"
/>
<p
style="
color: #000000;
font-family: -apple-system, BlinkMacSystemFont,
&quot;Segoe UI&quot;, Roboto, Oxygen, Ubuntu,
Cantarell, &quot;Open Sans&quot;,
&quot;Helvetica Neue&quot;, sans-serif !important;
font-size: 12px !important;
align-items: center;
margin-bottom: 0;
"
>
Made with Emergent
</p>
</div>
</a>
<script>
!(function (t, e) {
var o, n, p, r;
e.__SV ||
((window.posthog = e),
(e._i = []),
(e.init = function (i, s, a) {
function g(t, e) {
var o = e.split(".");
2 == o.length && ((t = t[o[0]]), (e = o[1])),
(t[e] = function () {
t.push(
[e].concat(
Array.prototype.slice.call(
arguments,
0,
),
),
);
});
}
((p = t.createElement("script")).type =
"text/javascript"),
(p.crossOrigin = "anonymous"),
(p.async = !0),
(p.src =
s.api_host.replace(
".i.posthog.com",
"-assets.i.posthog.com",
) + "/static/array.js"),
(r =
t.getElementsByTagName(
"script",
)[0]).parentNode.insertBefore(p, r);
var u = e;
for (
void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
u.people = u.people || [],
u.toString = function (t) {
var e = "posthog";
return (
"posthog" !== a && (e += "." + a),
t || (e += " (stub)"),
e
);
},
u.people.toString = function () {
return u.toString(1) + ".people (stub)";
},
o =
"init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(
" ",
),
n = 0;
n < o.length;
n++
)
g(u, o[n]);
e._i.push([i, s, a]);
}),
(e.__SV = 1));
})(document, window.posthog || []);
posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs", {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well,
session_recording: {
recordCrossOriginIframes: true,
},
});
</script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #0f0f10;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,61 @@
import React from "react";
import "@/App.css";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Toaster } from "./components/ui/sonner";
import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext";
import { CartProvider } from "./context/CartContext";
// Layout
import Navbar from "./components/layout/Navbar";
import Footer from "./components/layout/Footer";
// Pages
import Home from "./pages/Home";
import Products from "./pages/Products";
import ProductDetail from "./pages/ProductDetail";
import Services from "./pages/Services";
import ServiceDetail from "./pages/ServiceDetail";
import About from "./pages/About";
import Contact from "./pages/Contact";
import Login from "./pages/Login";
import Cart from "./pages/Cart";
import Profile from "./pages/Profile";
import OrderHistory from "./pages/OrderHistory";
import AdminDashboard from "./pages/AdminDashboard";
function App() {
return (
<ThemeProvider>
<AuthProvider>
<CartProvider>
<BrowserRouter>
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/services" element={<Services />} />
<Route path="/services/:id" element={<ServiceDetail />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/login" element={<Login />} />
<Route path="/cart" element={<Cart />} />
<Route path="/profile" element={<Profile />} />
<Route path="/orders" element={<OrderHistory />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
</main>
<Footer />
</div>
<Toaster position="top-right" richColors />
</BrowserRouter>
</CartProvider>
</AuthProvider>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ShoppingCart, Eye } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useCart } from '../../context/CartContext';
import { useAuth } from '../../context/AuthContext';
import { toast } from 'sonner';
const ProductCard = ({ product }) => {
const { addToCart } = useCart();
const { isAuthenticated } = useAuth();
const handleAddToCart = async (e) => {
e.preventDefault();
e.stopPropagation();
if (!isAuthenticated) {
toast.error('Please login to add items to cart');
return;
}
try {
await addToCart(product.id);
toast.success(`${product.name} added to cart`);
} catch (error) {
toast.error('Failed to add item to cart');
}
};
return (
<Link
to={`/products/${product.id}`}
className="group relative overflow-hidden rounded-xl border border-border/50 bg-card hover:border-primary/50 transition-all duration-300 hover-lift card-hover-border"
data-testid={`product-card-${product.id}`}
>
{/* Image Container */}
<div className="relative aspect-square overflow-hidden bg-muted">
<img
src={product.image_url}
alt={product.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
/>
{/* Overlay Actions */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<Button
size="icon"
variant="secondary"
className="rounded-full w-10 h-10 bg-background/90 backdrop-blur-sm hover:bg-background"
onClick={handleAddToCart}
data-testid={`add-to-cart-${product.id}`}
>
<ShoppingCart className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="secondary"
className="rounded-full w-10 h-10 bg-background/90 backdrop-blur-sm hover:bg-background"
data-testid={`view-product-${product.id}`}
>
<Eye className="h-4 w-4" />
</Button>
</div>
{/* Stock Badge */}
{product.stock <= 5 && product.stock > 0 && (
<Badge className="absolute top-3 left-3 bg-orange-500 hover:bg-orange-600">
Only {product.stock} left
</Badge>
)}
{product.stock === 0 && (
<Badge variant="destructive" className="absolute top-3 left-3">
Out of Stock
</Badge>
)}
</div>
{/* Content */}
<div className="p-4 space-y-2">
{/* Category & Brand */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{product.category}
</Badge>
{product.brand && (
<span className="text-xs text-muted-foreground">{product.brand}</span>
)}
</div>
{/* Title */}
<h3 className="font-semibold text-base leading-tight line-clamp-2 group-hover:text-primary transition-colors font-['Outfit']">
{product.name}
</h3>
{/* Price */}
<div className="flex items-center justify-between pt-2">
<span className="text-lg font-bold font-['Outfit']">
${product.price.toFixed(2)}
</span>
<Button
size="sm"
className="rounded-full px-4 btn-press"
onClick={handleAddToCart}
disabled={product.stock === 0}
>
Add to Cart
</Button>
</div>
</div>
</Link>
);
};
export default ProductCard;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Clock, ArrowRight } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
const ServiceCard = ({ service }) => {
const navigate = useNavigate();
const handleCardClick = () => {
navigate(`/services/${service.id}`);
};
return (
<div
className="group rounded-xl border border-border bg-card p-6 hover:shadow-lg transition-all duration-300 hover-lift cursor-pointer"
data-testid={`service-card-${service.id}`}
onClick={handleCardClick}
>
{/* Image */}
<div className="relative aspect-video rounded-lg overflow-hidden mb-4 bg-muted">
<img
src={service.image_url}
alt={service.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<Badge className="absolute top-3 left-3 capitalize">
{service.category}
</Badge>
</div>
{/* Content */}
<div className="space-y-3">
<h3 className="font-semibold text-lg font-['Outfit'] group-hover:text-primary transition-colors">
{service.name}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{service.description}
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>{service.duration}</span>
</div>
<div className="flex items-center justify-between pt-3 border-t border-border">
<div>
<span className="text-xs text-muted-foreground">Starting from</span>
<p className="text-xl font-bold font-['Outfit']">${service.price.toFixed(2)}</p>
</div>
<Button
className="rounded-full gap-2 btn-press"
data-testid={`book-service-${service.id}`}
onClick={(e) => {
e.stopPropagation();
navigate(`/services/${service.id}`);
}}
>
Book Now
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
};
export default ServiceCard;

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Mail, Phone, MapPin, Facebook, Twitter, Instagram, Linkedin } from 'lucide-react';
const Footer = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="border-t border-border bg-card mt-auto">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-12 md:py-16">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
{/* Brand */}
<div className="space-y-4">
<Link to="/" className="flex items-center gap-2" data-testid="footer-logo">
<div className="w-9 h-9 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg font-['Outfit']">T</span>
</div>
<span className="font-bold text-xl tracking-tight font-['Outfit']">TechZone</span>
</Link>
<p className="text-sm text-muted-foreground leading-relaxed">
Your trusted destination for premium electronics and professional repair services.
Quality products, expert solutions.
</p>
<div className="flex gap-3">
<a
href="#"
className="w-9 h-9 rounded-full border border-border flex items-center justify-center hover:bg-accent hover:border-primary transition-colors"
data-testid="social-facebook"
>
<Facebook className="h-4 w-4" />
</a>
<a
href="#"
className="w-9 h-9 rounded-full border border-border flex items-center justify-center hover:bg-accent hover:border-primary transition-colors"
data-testid="social-twitter"
>
<Twitter className="h-4 w-4" />
</a>
<a
href="#"
className="w-9 h-9 rounded-full border border-border flex items-center justify-center hover:bg-accent hover:border-primary transition-colors"
data-testid="social-instagram"
>
<Instagram className="h-4 w-4" />
</a>
<a
href="#"
className="w-9 h-9 rounded-full border border-border flex items-center justify-center hover:bg-accent hover:border-primary transition-colors"
data-testid="social-linkedin"
>
<Linkedin className="h-4 w-4" />
</a>
</div>
</div>
{/* Quick Links */}
<div>
<h4 className="font-semibold text-base mb-4 font-['Outfit']">Quick Links</h4>
<ul className="space-y-3">
<li>
<Link to="/products" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-products">
Products
</Link>
</li>
<li>
<Link to="/services" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-services">
Services
</Link>
</li>
<li>
<Link to="/about" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-about">
About Us
</Link>
</li>
<li>
<Link to="/contact" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-contact">
Contact
</Link>
</li>
</ul>
</div>
{/* Categories */}
<div>
<h4 className="font-semibold text-base mb-4 font-['Outfit']">Categories</h4>
<ul className="space-y-3">
<li>
<Link to="/products?category=phones" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-phones">
Phones
</Link>
</li>
<li>
<Link to="/products?category=laptops" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-laptops">
Laptops
</Link>
</li>
<li>
<Link to="/products?category=tablets" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-tablets">
Tablets
</Link>
</li>
<li>
<Link to="/products?category=accessories" className="text-sm text-muted-foreground hover:text-foreground transition-colors" data-testid="footer-accessories">
Accessories
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h4 className="font-semibold text-base mb-4 font-['Outfit']">Contact Us</h4>
<ul className="space-y-3">
<li className="flex items-start gap-3">
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">
123 Tech Street, Silicon Valley, CA 94000
</span>
</li>
<li className="flex items-center gap-3">
<Phone className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<a href="tel:+1234567890" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
+1 (234) 567-890
</a>
</li>
<li className="flex items-center gap-3">
<Mail className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<a href="mailto:info@techzone.com" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
info@techzone.com
</a>
</li>
</ul>
</div>
</div>
{/* Bottom Bar */}
<div className="border-t border-border mt-10 pt-6 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-muted-foreground">
© {currentYear} TechZone. All rights reserved.
</p>
<div className="flex gap-6">
<Link to="/privacy" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Privacy Policy
</Link>
<Link to="/terms" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Terms of Service
</Link>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTheme } from '../../context/ThemeContext';
import { useAuth } from '../../context/AuthContext';
import { useCart } from '../../context/CartContext';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../ui/sheet';
import {
Sun,
Moon,
ShoppingCart,
Menu,
User,
LogOut,
Package,
Wrench,
Home,
Info,
Phone
} from 'lucide-react';
const Navbar = () => {
const { theme, toggleTheme } = useTheme();
const { user, logout, isAuthenticated } = useAuth();
const { cartCount } = useCart();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const navLinks = [
{ path: '/', label: 'Home', icon: Home },
{ path: '/products', label: 'Products', icon: Package },
{ path: '/services', label: 'Services', icon: Wrench },
{ path: '/about', label: 'About', icon: Info },
{ path: '/contact', label: 'Contact', icon: Phone },
];
const isActive = (path) => location.pathname === path;
return (
<header className="sticky top-0 z-50 glass glass-border">
<nav className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link
to="/"
className="flex items-center gap-2 group"
data-testid="navbar-logo"
>
<div className="w-9 h-9 rounded-lg bg-primary flex items-center justify-center transition-transform group-hover:scale-105">
<span className="text-primary-foreground font-bold text-lg font-['Outfit']">T</span>
</div>
<span className="font-bold text-xl tracking-tight font-['Outfit']">TechZone</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.path}
to={link.path}
data-testid={`nav-${link.label.toLowerCase()}`}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
isActive(link.path)
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
{link.label}
</Link>
))}
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
data-testid="theme-toggle"
className="rounded-full"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
{/* Cart */}
<Link to="/cart" data-testid="cart-button">
<Button variant="ghost" size="icon" className="rounded-full relative">
<ShoppingCart className="h-5 w-5" />
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-primary text-primary-foreground text-xs rounded-full flex items-center justify-center font-medium">
{cartCount}
</span>
)}
</Button>
</Link>
{/* Auth */}
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="rounded-full"
data-testid="user-menu-trigger"
>
<User className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-2 py-1.5">
<p className="text-sm font-medium">{user?.name}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/profile" data-testid="profile-link" className="cursor-pointer">
<User className="h-4 w-4 mr-2" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/orders" data-testid="orders-link" className="cursor-pointer">
<Package className="h-4 w-4 mr-2" />
Orders
</Link>
</DropdownMenuItem>
{user?.role === 'admin' && (
<DropdownMenuItem asChild>
<Link to="/admin" data-testid="admin-link" className="cursor-pointer">
<Package className="h-4 w-4 mr-2" />
Admin Dashboard
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
data-testid="logout-button"
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link to="/login" data-testid="login-link">
<Button className="rounded-full px-6">
Login
</Button>
</Link>
)}
{/* Mobile Menu */}
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon" data-testid="mobile-menu-trigger">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-72">
<div className="flex flex-col gap-4 mt-8">
{navLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.path}
to={link.path}
onClick={() => setMobileMenuOpen(false)}
data-testid={`mobile-nav-${link.label.toLowerCase()}`}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-base font-medium transition-colors ${
isActive(link.path)
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent'
}`}
>
<Icon className="h-5 w-5" />
{link.label}
</Link>
);
})}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</nav>
</header>
);
};
export default Navbar;

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}>
{children}
<ChevronDown
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,97 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props} />
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props} />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} />
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props} />
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props} />
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef(
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props} />
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props} />
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props} />
);
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props} />
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />
);
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,71 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props} />
);
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,193 @@
import * as React from "react"
import useEmblaCarousel from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const CarouselContext = React.createContext(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef((
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel({
...opts,
axis: orientation === "horizontal" ? "x" : "y",
}, plugins)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback((event) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
}, [scrollPrev, scrollNext])
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
};
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}>
{children}
</div>
</CarouselContext.Provider>
);
})
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props} />
</div>
);
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props} />
);
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
})
CarouselNext.displayName = "CarouselNext"
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props} />
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({
children,
...props
}) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props} />
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props} />
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props} />
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props} />
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />
);
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props} />
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props} />
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />
);
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,94 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,90 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props} />
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props} />
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,133 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { Controller, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
const FormFieldContext = React.createContext({})
const FormField = (
{
...props
}
) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const FormItemContext = React.createContext({})
const FormItem = React.forwardRef(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props} />
);
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props} />
);
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props} />
);
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}>
{body}
</p>
);
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props} />
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props} />
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}>
{char}
{hasFakeCaret && (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />
);
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function MenubarMenu({
...props
}) {
return <MenubarPrimitive.Menu {...props} />;
}
function MenubarGroup({
...props
}) {
return <MenubarPrimitive.Group {...props} />;
}
function MenubarPortal({
...props
}) {
return <MenubarPrimitive.Portal {...props} />;
}
function MenubarRadioGroup({
...props
}) {
return <MenubarPrimitive.RadioGroup {...props} />;
}
function MenubarSub({
...props
}) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
const Menubar = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className
)}
{...props} />
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props} />
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props} />
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef((
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props} />
</MenubarPrimitive.Portal>
))
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />
);
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,104 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props} />
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props} />
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props} />
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}>
<div
className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,100 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button";
const Pagination = ({
className,
...props
}) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props} />
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props} />
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className)}
{...props} />
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,40 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props} />
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}>
{withHandle && (
<div
className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,119 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,108 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />
);
}
export { Skeleton }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,28 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, toast } from "sonner"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props} />
);
}
export { Toaster, toast }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} />
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />
);
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,85 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props} />
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props} />
);
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props} />
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction };

View File

@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}), className)}
{...props}>
{children}
</ToggleGroupPrimitive.Item>
);
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props} />
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
const AuthContext = createContext(null);
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(localStorage.getItem('token'));
const [loading, setLoading] = useState(true);
useEffect(() => {
const initAuth = async () => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
try {
const response = await axios.get(`${API}/auth/me`, {
headers: { Authorization: `Bearer ${storedToken}` }
});
setUser(response.data);
setToken(storedToken);
} catch (error) {
localStorage.removeItem('token');
setToken(null);
setUser(null);
}
}
setLoading(false);
};
initAuth();
}, []);
const login = async (email, password) => {
const response = await axios.post(`${API}/auth/login`, { email, password });
const { access_token, user: userData } = response.data;
localStorage.setItem('token', access_token);
setToken(access_token);
setUser(userData);
return userData;
};
const register = async (name, email, password) => {
const response = await axios.post(`${API}/auth/register`, { name, email, password });
const { access_token, user: userData } = response.data;
localStorage.setItem('token', access_token);
setToken(access_token);
setUser(userData);
return userData;
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
const value = {
user,
token,
loading,
login,
register,
logout,
isAuthenticated: !!token
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,122 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
import { useAuth } from './AuthContext';
const CartContext = createContext(null);
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
export const CartProvider = ({ children }) => {
const [cartItems, setCartItems] = useState([]);
const [loading, setLoading] = useState(false);
const { token, isAuthenticated } = useAuth();
const fetchCart = async () => {
if (!isAuthenticated) {
setCartItems([]);
return;
}
setLoading(true);
try {
const response = await axios.get(`${API}/cart`, {
headers: { Authorization: `Bearer ${token}` }
});
setCartItems(response.data);
} catch (error) {
console.error('Failed to fetch cart:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isAuthenticated && token) {
fetchCart();
} else {
setCartItems([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, token]);
const addToCart = async (productId, quantity = 1) => {
if (!isAuthenticated) {
throw new Error('Please login to add items to cart');
}
try {
await axios.post(`${API}/cart/add`,
{ product_id: productId, quantity },
{ headers: { Authorization: `Bearer ${token}` } }
);
await fetchCart();
} catch (error) {
throw error;
}
};
const updateQuantity = async (itemId, quantity) => {
try {
await axios.put(`${API}/cart/${itemId}?quantity=${quantity}`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
await fetchCart();
} catch (error) {
throw error;
}
};
const removeFromCart = async (itemId) => {
try {
await axios.delete(`${API}/cart/${itemId}`, {
headers: { Authorization: `Bearer ${token}` }
});
await fetchCart();
} catch (error) {
throw error;
}
};
const clearCart = async () => {
try {
await axios.delete(`${API}/cart`, {
headers: { Authorization: `Bearer ${token}` }
});
setCartItems([]);
} catch (error) {
throw error;
}
};
const cartTotal = cartItems.reduce((total, item) => {
return total + (item.product?.price || 0) * item.quantity;
}, 0);
const cartCount = cartItems.reduce((count, item) => count + item.quantity, 0);
const value = {
cartItems,
loading,
addToCart,
updateQuantity,
removeFromCart,
clearCart,
cartTotal,
cartCount,
fetchCart
};
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
};
export const useCart = () => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
};

View File

@@ -0,0 +1,39 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext(null);
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
const stored = localStorage.getItem('theme');
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@@ -0,0 +1,155 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST"
}
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString();
}
const toastTimeouts = new Map()
const addToRemoveQueue = (toastId) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state, action) => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t),
};
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
const listeners = []
let memoryState = { toasts: [] }
function dispatch(action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
function toast({
...props
}) {
const id = genId()
const update = (props) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
};
}, [state])
return {
...state,
toast,
dismiss: (toastId) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast }

View File

@@ -0,0 +1,234 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--brand: 217 91% 60%;
}
.dark {
--background: 0 0% 4%;
--foreground: 0 0% 98%;
--card: 0 0% 4%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 4%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: 'Inter', sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Outfit', sans-serif;
}
}
/* Smooth transitions for theme toggle */
html {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--foreground));
}
/* Glass morphism utilities */
.glass {
background: hsl(var(--background) / 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.glass-border {
border: 1px solid hsl(var(--border) / 0.4);
}
/* Hover lift effect */
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, hsl(var(--foreground)) 0%, hsl(var(--muted-foreground)) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Brand accent glow */
.brand-glow {
box-shadow: 0 0 20px hsl(var(--brand) / 0.3);
}
/* Animation classes */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease forwards;
}
.animate-fade-in {
animation: fadeIn 0.4s ease forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.4s ease forwards;
}
/* Stagger animation delays */
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
/* Button press effect */
.btn-press {
transition: transform 0.1s ease;
}
.btn-press:active {
transform: scale(0.97);
}
/* Card hover border effect */
.card-hover-border {
position: relative;
transition: border-color 0.3s ease;
}
.card-hover-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, transparent 0%, hsl(var(--brand)) 50%, transparent 100%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.3s ease;
}
.card-hover-border:hover::before {
opacity: 1;
}
/* Focus visible styles */
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
@layer base {
[data-debug-wrapper="true"] {
display: contents !important;
}
}

View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@/index.css";
import App from "@/App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,252 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Users, Target, Award, Heart, ArrowRight } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
const About = () => {
const team = [
{
name: 'Alex Johnson',
role: 'Founder & CEO',
image: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400',
},
{
name: 'Sarah Williams',
role: 'Head of Operations',
image: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400',
},
{
name: 'Mike Chen',
role: 'Lead Technician',
image: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400',
},
{
name: 'Emily Davis',
role: 'Customer Success',
image: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=400',
},
];
const values = [
{
icon: Target,
title: 'Quality First',
desc: 'We never compromise on the quality of our products and services.',
},
{
icon: Users,
title: 'Customer Focus',
desc: 'Your satisfaction is our top priority. We listen and deliver.',
},
{
icon: Award,
title: 'Excellence',
desc: 'We strive for excellence in everything we do.',
},
{
icon: Heart,
title: 'Integrity',
desc: 'Honest, transparent, and ethical business practices.',
},
];
return (
<div className="min-h-screen">
{/* Hero */}
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div className="space-y-6">
<Badge variant="secondary" className="rounded-full px-4 py-1">
About TechZone
</Badge>
<h1 className="text-4xl sm:text-5xl font-bold font-['Outfit'] leading-tight">
Your Trusted
<br />
<span className="text-muted-foreground">Tech Partner</span>
</h1>
<p className="text-lg text-muted-foreground leading-relaxed">
Founded in 2020, TechZone has grown from a small repair shop to a
comprehensive tech solutions provider. We combine quality products
with expert services to deliver the best tech experience.
</p>
<div className="flex flex-wrap gap-4">
<Link to="/products" data-testid="about-shop-now">
<Button size="lg" className="rounded-full gap-2 btn-press">
Shop Now
<ArrowRight className="h-5 w-5" />
</Button>
</Link>
<Link to="/contact" data-testid="about-contact-us">
<Button size="lg" variant="outline" className="rounded-full">
Contact Us
</Button>
</Link>
</div>
</div>
<div className="relative">
<img
src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800"
alt="Our Team"
className="rounded-2xl shadow-2xl"
data-testid="about-hero-image"
/>
{/* Stats Card */}
<div className="absolute -bottom-6 -left-6 bg-card border border-border rounded-xl p-6 shadow-lg">
<p className="text-3xl font-bold font-['Outfit']">5+</p>
<p className="text-sm text-muted-foreground">Years of Excellence</p>
</div>
</div>
</div>
</div>
</section>
{/* Stats */}
<section className="py-12 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{[
{ value: '50K+', label: 'Happy Customers' },
{ value: '10K+', label: 'Products Sold' },
{ value: '15K+', label: 'Repairs Done' },
{ value: '98%', 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>
))}
</div>
</div>
</section>
{/* Our Story */}
<section className="py-16 md:py-24">
<div className="max-w-4xl mx-auto px-4 md:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Our Story
</h2>
</div>
<div className="prose prose-lg dark:prose-invert max-w-none">
<p className="text-muted-foreground leading-relaxed mb-6">
TechZone started with a simple vision: to make quality tech accessible
and provide expert support that customers can trust. What began as a
small phone repair shop has evolved into a full-service tech destination.
</p>
<p className="text-muted-foreground leading-relaxed mb-6">
Our team of certified technicians brings decades of combined experience
in electronics repair, from smartphones to laptops and everything in between.
We've helped thousands of customers bring their devices back to life.
</p>
<p className="text-muted-foreground leading-relaxed">
Today, we're proud to offer a curated selection of premium electronics
alongside our repair services. Every product we sell meets our high standards
for quality, and every repair we do is backed by our satisfaction guarantee.
</p>
</div>
</div>
</section>
{/* 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">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Our Values
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
The principles that guide everything we do
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{values.map((value, idx) => {
const Icon = value.icon;
return (
<div
key={idx}
className="p-6 rounded-2xl bg-card border border-border hover-lift text-center"
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>
<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>
</section>
{/* Team */}
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Meet Our Team
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
The people behind TechZone's success
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{team.map((member, idx) => (
<div
key={idx}
className="group text-center"
data-testid={`team-member-${idx}`}
>
<div className="relative mb-4 overflow-hidden rounded-2xl aspect-square">
<img
src={member.image}
alt={member.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
</div>
<h3 className="font-semibold font-['Outfit']">{member.name}</h3>
<p className="text-sm text-muted-foreground">{member.role}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16 md:py-24 bg-primary text-primary-foreground">
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Ready to Get Started?
</h2>
<p className="text-primary-foreground/80 mb-8 max-w-2xl mx-auto">
Whether you need a new device or want to repair your current one,
we're here to help.
</p>
<div className="flex flex-wrap justify-center gap-4">
<Link to="/products" data-testid="cta-browse-products">
<Button size="lg" variant="secondary" className="rounded-full px-8 btn-press">
Browse Products
</Button>
</Link>
<Link to="/services" data-testid="cta-our-services">
<Button
size="lg"
variant="outline"
className="rounded-full px-8 border-primary-foreground/30 text-primary-foreground hover:bg-primary-foreground/10"
>
Our Services
</Button>
</Link>
</div>
</div>
</section>
</div>
);
};
export default About;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { Trash2, Plus, Minus, ShoppingBag, ArrowRight, ArrowLeft, CreditCard } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Separator } from '../components/ui/separator';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Textarea } from '../components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '../components/ui/dialog';
import { useCart } from '../context/CartContext';
import { useAuth } from '../context/AuthContext';
import { toast } from 'sonner';
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const Cart = () => {
const { cartItems, cartTotal, updateQuantity, removeFromCart, clearCart, loading, fetchCart } = useCart();
const { isAuthenticated, token } = useAuth();
const navigate = useNavigate();
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [processing, setProcessing] = useState(false);
const [shippingAddress, setShippingAddress] = useState({
street: '',
city: '',
state: '',
zip: '',
country: ''
});
const [orderNotes, setOrderNotes] = useState('');
const handleUpdateQuantity = async (itemId, newQuantity) => {
try {
await updateQuantity(itemId, newQuantity);
} catch (error) {
toast.error('Failed to update cart');
}
};
const handleRemove = async (itemId) => {
try {
await removeFromCart(itemId);
toast.success('Item removed from cart');
} catch (error) {
toast.error('Failed to remove item');
}
};
const handleClearCart = async () => {
try {
await clearCart();
toast.success('Cart cleared');
} catch (error) {
toast.error('Failed to clear cart');
}
};
const handleCheckout = async () => {
setProcessing(true);
try {
const response = await axios.post(`${API}/orders`, {
shipping_address: shippingAddress,
notes: orderNotes
}, {
headers: { Authorization: `Bearer ${token}` }
});
toast.success('Order placed successfully!');
setCheckoutOpen(false);
await fetchCart();
navigate('/orders');
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to place order');
} finally {
setProcessing(false);
}
};
if (!isAuthenticated) {
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted flex items-center justify-center">
<ShoppingBag className="h-10 w-10 text-muted-foreground" />
</div>
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">Please Sign In</h2>
<p className="text-muted-foreground mb-8">
You need to be logged in to view your cart
</p>
<Link to="/login" data-testid="cart-login-link">
<Button className="rounded-full gap-2">
Sign In
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
);
}
if (loading) {
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8">
<div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-4 p-4 border border-border rounded-xl">
<div className="w-24 h-24 bg-muted rounded-lg" />
<div className="flex-1 space-y-2">
<div className="h-5 bg-muted rounded w-1/2" />
<div className="h-4 bg-muted rounded w-1/4" />
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (cartItems.length === 0) {
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted flex items-center justify-center">
<ShoppingBag className="h-10 w-10 text-muted-foreground" />
</div>
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">Your Cart is Empty</h2>
<p className="text-muted-foreground mb-8">
Looks like you haven't added anything to your cart yet
</p>
<Link to="/products" data-testid="continue-shopping-empty">
<Button className="rounded-full gap-2">
Continue Shopping
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
);
}
const tax = cartTotal * 0.08;
const shipping = cartTotal > 100 ? 0 : 9.99;
const total = cartTotal + tax + shipping;
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-6xl mx-auto px-4 md:px-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold font-['Outfit'] mb-1">Shopping Cart</h1>
<p className="text-muted-foreground">{cartItems.length} items in your cart</p>
</div>
<Button
variant="outline"
className="rounded-full gap-2"
onClick={handleClearCart}
data-testid="clear-cart"
>
<Trash2 className="h-4 w-4" />
Clear Cart
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Cart Items */}
<div className="lg:col-span-2 space-y-4">
{cartItems.map((item) => (
<div
key={item.id}
className="flex gap-4 p-4 border border-border rounded-xl bg-card"
data-testid={`cart-item-${item.id}`}
>
{/* Image */}
<Link to={`/products/${item.product?.id}`} className="flex-shrink-0">
<div className="w-24 h-24 rounded-lg overflow-hidden bg-muted">
<img
src={item.product?.image_url}
alt={item.product?.name}
className="w-full h-full object-cover"
/>
</div>
</Link>
{/* Details */}
<div className="flex-1 min-w-0">
<Link to={`/products/${item.product?.id}`}>
<h3 className="font-semibold text-base font-['Outfit'] hover:text-primary transition-colors truncate">
{item.product?.name}
</h3>
</Link>
<p className="text-sm text-muted-foreground capitalize mt-1">
{item.product?.category}
</p>
<p className="font-bold mt-2 font-['Outfit']">
${item.product?.price?.toFixed(2)}
</p>
</div>
{/* Quantity & Actions */}
<div className="flex flex-col items-end justify-between">
<button
onClick={() => handleRemove(item.id)}
className="text-muted-foreground hover:text-destructive transition-colors p-1"
data-testid={`remove-item-${item.id}`}
>
<Trash2 className="h-4 w-4" />
</button>
<div className="flex items-center border border-input rounded-full">
<Button
variant="ghost"
size="icon"
className="rounded-full h-8 w-8"
onClick={() => handleUpdateQuantity(item.id, item.quantity - 1)}
data-testid={`decrease-quantity-${item.id}`}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-8 text-center text-sm font-medium">
{item.quantity}
</span>
<Button
variant="ghost"
size="icon"
className="rounded-full h-8 w-8"
onClick={() => handleUpdateQuantity(item.id, item.quantity + 1)}
data-testid={`increase-quantity-${item.id}`}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
))}
{/* Continue Shopping */}
<Link
to="/products"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
data-testid="continue-shopping"
>
<ArrowLeft className="h-4 w-4" />
Continue Shopping
</Link>
</div>
{/* Order Summary */}
<div className="lg:col-span-1">
<div className="sticky top-24 border border-border rounded-2xl bg-card p-6 space-y-6">
<h2 className="text-xl font-semibold font-['Outfit']">Order Summary</h2>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span>${cartTotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Shipping</span>
<span>{shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax (8%)</span>
<span>${tax.toFixed(2)}</span>
</div>
</div>
<Separator />
<div className="flex justify-between font-semibold">
<span>Total</span>
<span className="font-['Outfit'] text-xl">
${total.toFixed(2)}
</span>
</div>
<Button
className="w-full rounded-full btn-press gap-2"
size="lg"
onClick={() => setCheckoutOpen(true)}
data-testid="checkout-button"
>
<CreditCard className="h-4 w-4" />
Proceed to Checkout
</Button>
<p className="text-xs text-center text-muted-foreground">
Free shipping on orders over $100
</p>
</div>
</div>
</div>
</div>
{/* Checkout Dialog */}
<Dialog open={checkoutOpen} onOpenChange={setCheckoutOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-['Outfit']">Checkout</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Street Address</Label>
<Input
value={shippingAddress.street}
onChange={(e) => setShippingAddress({...shippingAddress, street: e.target.value})}
placeholder="123 Main St"
data-testid="shipping-street"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>City</Label>
<Input
value={shippingAddress.city}
onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})}
placeholder="New York"
data-testid="shipping-city"
/>
</div>
<div className="space-y-2">
<Label>State</Label>
<Input
value={shippingAddress.state}
onChange={(e) => setShippingAddress({...shippingAddress, state: e.target.value})}
placeholder="NY"
data-testid="shipping-state"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>ZIP Code</Label>
<Input
value={shippingAddress.zip}
onChange={(e) => setShippingAddress({...shippingAddress, zip: e.target.value})}
placeholder="10001"
data-testid="shipping-zip"
/>
</div>
<div className="space-y-2">
<Label>Country</Label>
<Input
value={shippingAddress.country}
onChange={(e) => setShippingAddress({...shippingAddress, country: e.target.value})}
placeholder="USA"
data-testid="shipping-country"
/>
</div>
</div>
<div className="space-y-2">
<Label>Order Notes (Optional)</Label>
<Textarea
value={orderNotes}
onChange={(e) => setOrderNotes(e.target.value)}
placeholder="Any special instructions..."
data-testid="order-notes"
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Subtotal</span>
<span>${cartTotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span>Shipping</span>
<span>{shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`}</span>
</div>
<div className="flex justify-between text-sm">
<span>Tax</span>
<span>${tax.toFixed(2)}</span>
</div>
<div className="flex justify-between font-semibold text-lg pt-2">
<span>Total</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
<Button
className="w-full rounded-full"
size="lg"
onClick={handleCheckout}
disabled={processing}
data-testid="place-order-button"
>
{processing ? 'Processing...' : 'Place Order'}
</Button>
<p className="text-xs text-center text-muted-foreground">
By placing your order, you agree to our terms and conditions.
</p>
</div>
</DialogContent>
</Dialog>
</div>
);
};
export default Cart;

View File

@@ -0,0 +1,249 @@
import React, { useState } from 'react';
import axios from 'axios';
import { Mail, Phone, MapPin, Clock, Send } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Textarea } from '../components/ui/textarea';
import { Label } from '../components/ui/label';
import { toast } from 'sonner';
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const Contact = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: ''
});
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
try {
await axios.post(`${API}/contact`, formData);
toast.success('Message sent successfully! We will get back to you soon.');
setFormData({ name: '', email: '', subject: '', message: '' });
} catch (error) {
toast.error('Failed to send message. Please try again.');
} finally {
setSubmitting(false);
}
};
const contactInfo = [
{
icon: MapPin,
title: 'Address',
content: '123 Tech Street, Silicon Valley, CA 94000',
},
{
icon: Phone,
title: 'Phone',
content: '+1 (234) 567-890',
link: 'tel:+1234567890',
},
{
icon: Mail,
title: 'Email',
content: 'info@techzone.com',
link: 'mailto:info@techzone.com',
},
{
icon: Clock,
title: 'Business Hours',
content: 'Mon - Sat: 9AM - 7PM',
},
];
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-7xl mx-auto px-4 md:px-8">
{/* Header */}
<div className="text-center mb-12 md:mb-16">
<h1 className="text-4xl md:text-5xl font-bold font-['Outfit'] mb-4">
Get in Touch
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Have questions? We'd love to hear from you. Send us a message
and we'll respond as soon as possible.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Contact Info */}
<div className="lg:col-span-1 space-y-6">
<h2 className="text-xl font-semibold font-['Outfit'] mb-6">
Contact Information
</h2>
{contactInfo.map((info, idx) => {
const Icon = info.icon;
const Content = info.link ? (
<a
href={info.link}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{info.content}
</a>
) : (
<span className="text-muted-foreground">{info.content}</span>
);
return (
<div
key={idx}
className="flex items-start gap-4 p-4 rounded-xl border border-border bg-card"
data-testid={`contact-info-${idx}`}
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-medium text-sm mb-1">{info.title}</h3>
{Content}
</div>
</div>
);
})}
{/* Map placeholder */}
<div className="aspect-video rounded-xl overflow-hidden border border-border bg-muted">
<img
src="https://images.unsplash.com/photo-1526778548025-fa2f459cd5c1?w=800"
alt="Location Map"
className="w-full h-full object-cover"
data-testid="contact-map"
/>
</div>
</div>
{/* Contact Form */}
<div className="lg:col-span-2">
<div className="border border-border rounded-2xl bg-card p-6 md:p-8">
<h2 className="text-xl font-semibold font-['Outfit'] mb-6">
Send us a Message
</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="John Doe"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
data-testid="contact-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="john@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
data-testid="contact-email"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subject">Subject</Label>
<Input
id="subject"
placeholder="How can we help you?"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
required
data-testid="contact-subject"
/>
</div>
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
placeholder="Your message..."
rows={6}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
required
data-testid="contact-message"
/>
</div>
<Button
type="submit"
size="lg"
className="rounded-full gap-2 btn-press"
disabled={submitting}
data-testid="contact-submit"
>
{submitting ? (
'Sending...'
) : (
<>
Send Message
<Send className="h-4 w-4" />
</>
)}
</Button>
</form>
</div>
</div>
</div>
{/* FAQ Section */}
<section className="mt-16 md:mt-24">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold font-['Outfit'] mb-4">
Frequently Asked Questions
</h2>
<p className="text-muted-foreground">
Quick answers to common questions
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{[
{
q: 'What are your business hours?',
a: 'We are open Monday through Saturday, 9AM to 7PM. We are closed on Sundays.'
},
{
q: 'Do you offer warranty on repairs?',
a: 'Yes, all our repairs come with a 30-day warranty covering the parts and labor.'
},
{
q: 'How long does a typical repair take?',
a: 'Most repairs are completed within 1-2 hours. Complex repairs may take 1-2 days.'
},
{
q: 'Do you offer pickup and delivery?',
a: 'Yes, we offer free pickup and delivery for repairs within a 10-mile radius.'
}
].map((faq, idx) => (
<div
key={idx}
className="p-6 rounded-xl border border-border bg-card"
data-testid={`faq-${idx}`}
>
<h3 className="font-semibold mb-2 font-['Outfit']">{faq.q}</h3>
<p className="text-sm text-muted-foreground">{faq.a}</p>
</div>
))}
</div>
</section>
</div>
</div>
);
};
export default Contact;

View File

@@ -0,0 +1,282 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import { ArrowRight, Truck, Shield, Headphones, Wrench, Laptop, Smartphone, Watch } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import ProductCard from '../components/cards/ProductCard';
import ServiceCard from '../components/cards/ServiceCard';
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const Home = () => {
const [featuredProducts, setFeaturedProducts] = useState([]);
const [featuredServices, setFeaturedServices] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
// Seed data first
await axios.post(`${API}/seed`);
const [productsRes, servicesRes] = await Promise.all([
axios.get(`${API}/products`),
axios.get(`${API}/services`)
]);
setFeaturedProducts(productsRes.data.slice(0, 4));
setFeaturedServices(servicesRes.data.slice(0, 3));
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const features = [
{ icon: Truck, title: 'Free Shipping', desc: 'On orders over $100' },
{ icon: Shield, title: 'Warranty', desc: '1 Year manufacturer warranty' },
{ icon: Headphones, title: '24/7 Support', desc: 'Expert assistance anytime' },
{ icon: Wrench, title: 'Expert Repair', desc: 'Certified technicians' },
];
const categories = [
{ icon: Smartphone, name: 'Phones', link: '/products?category=phones' },
{ icon: Laptop, name: 'Laptops', link: '/products?category=laptops' },
{ icon: Watch, name: 'Wearables', link: '/products?category=wearables' },
];
return (
<div className="min-h-screen">
{/* Hero Section */}
<section className="relative overflow-hidden py-16 md:py-24 lg:py-32">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
{/* Content */}
<div className="space-y-6 animate-fade-in-up">
<Badge variant="secondary" className="rounded-full px-4 py-1">
New Arrivals Available
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight tracking-tight font-['Outfit']">
Premium Tech,
<br />
<span className="text-muted-foreground">Expert Service</span>
</h1>
<p className="text-lg text-muted-foreground max-w-lg">
Discover the latest electronics and get professional repair services.
Quality products, trusted solutions for all your tech needs.
</p>
<div className="flex flex-wrap gap-4 pt-2">
<Link to="/products" data-testid="hero-shop-now">
<Button size="lg" className="rounded-full px-8 gap-2 btn-press">
Shop Now
<ArrowRight className="h-5 w-5" />
</Button>
</Link>
<Link to="/services" data-testid="hero-our-services">
<Button size="lg" variant="outline" className="rounded-full px-8">
Our Services
</Button>
</Link>
</div>
</div>
{/* Hero Image */}
<div className="relative animate-fade-in-up stagger-2">
<div className="relative aspect-square max-w-lg mx-auto">
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent rounded-3xl" />
<img
src="https://images.unsplash.com/photo-1759588071908-fc10a79714fe?w=800"
alt="Premium Electronics"
className="w-full h-full object-cover rounded-3xl shadow-2xl"
data-testid="hero-image"
/>
{/* Floating Card */}
<div className="absolute -bottom-4 -left-4 md:-left-8 bg-card border border-border rounded-xl p-4 shadow-lg animate-fade-in-up stagger-3">
<p className="text-sm text-muted-foreground">Starting from</p>
<p className="text-2xl font-bold font-['Outfit']">$99.99</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* 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">
{features.map((feature, idx) => {
const Icon = feature.icon;
return (
<div key={idx} className="flex items-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">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium text-sm font-['Outfit']">{feature.title}</p>
<p className="text-xs text-muted-foreground">{feature.desc}</p>
</div>
</div>
);
})}
</div>
</div>
</section>
{/* Categories */}
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Shop by Category
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Browse our wide selection of premium electronics
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{categories.map((cat, idx) => {
const Icon = cat.icon;
return (
<Link
key={idx}
to={cat.link}
className="group relative overflow-hidden rounded-2xl border border-border bg-card p-8 text-center hover:border-primary/50 transition-all hover-lift"
data-testid={`category-${cat.name.toLowerCase()}`}
>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center transition-transform group-hover:scale-110">
<Icon className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-semibold font-['Outfit'] group-hover:text-primary transition-colors">
{cat.name}
</h3>
</Link>
);
})}
</div>
</div>
</section>
{/* Featured Products */}
<section className="py-16 md:py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-12">
<div>
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-2">
Featured Products
</h2>
<p className="text-muted-foreground">
Handpicked selection of our best-selling items
</p>
</div>
<Link to="/products" data-testid="view-all-products">
<Button variant="outline" className="rounded-full gap-2">
View All
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].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>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
</section>
{/* Services Section */}
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-12">
<div>
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-2">
Our Services
</h2>
<p className="text-muted-foreground">
Professional tech support and repair services
</p>
</div>
<Link to="/services" data-testid="view-all-services">
<Button variant="outline" className="rounded-full gap-2">
All Services
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-6 animate-pulse">
<div className="aspect-video bg-muted rounded-lg mb-4" />
<div className="h-5 bg-muted rounded w-2/3 mb-2" />
<div className="h-4 bg-muted rounded w-full mb-4" />
<div className="h-8 bg-muted rounded w-1/3" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{featuredServices.map((service) => (
<ServiceCard key={service.id} service={service} />
))}
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="py-16 md:py-24 bg-primary text-primary-foreground">
<div className="max-w-7xl mx-auto px-4 md:px-8 text-center">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Need Expert Help?
</h2>
<p className="text-primary-foreground/80 max-w-2xl mx-auto mb-8">
Our certified technicians are ready to help you with any tech problem.
From repairs to upgrades, we've got you covered.
</p>
<div className="flex flex-wrap justify-center gap-4">
<Link to="/contact" data-testid="cta-contact">
<Button
size="lg"
variant="secondary"
className="rounded-full px-8 gap-2 btn-press"
>
Contact Us
<ArrowRight className="h-5 w-5" />
</Button>
</Link>
<Link to="/services" data-testid="cta-services">
<Button
size="lg"
variant="outline"
className="rounded-full px-8 border-primary-foreground/30 text-primary-foreground hover:bg-primary-foreground/10"
>
Browse Services
</Button>
</Link>
</div>
</div>
</section>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { Link, useNavigate, useLocation } 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';
import { Label } from '../components/ui/label';
import { Separator } from '../components/ui/separator';
import { useAuth } from '../context/AuthContext';
import { toast } from 'sonner';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
const { login, register } = useAuth();
const [isRegister, setIsRegister] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
password: ''
});
const from = location.state?.from?.pathname || '/';
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (isRegister) {
await register(formData.name, formData.email, formData.password);
toast.success('Account created successfully!');
} else {
await login(formData.email, formData.password);
toast.success('Welcome back!');
}
navigate(from, { replace: true });
} catch (error) {
toast.error(error.response?.data?.detail || 'Authentication failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center gap-2 mb-8" data-testid="login-logo">
<div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-xl font-['Outfit']">T</span>
</div>
<span className="font-bold text-2xl tracking-tight font-['Outfit']">TechZone</span>
</Link>
<h1 className="text-2xl font-bold font-['Outfit'] mb-2">
{isRegister ? 'Create an account' : 'Welcome back'}
</h1>
<p className="text-muted-foreground">
{isRegister
? 'Enter your details to get started'
: 'Sign in to your account to continue'
}
</p>
</div>
<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>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="john@example.com"
className="pl-10"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
data-testid="login-email"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
className="pl-10 pr-10"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
minLength={6}
data-testid="login-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
data-testid="toggle-password"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{!isRegister && (
<div className="flex justify-end">
<button
type="button"
className="text-sm text-primary hover:underline"
data-testid="forgot-password"
>
Forgot password?
</button>
</div>
)}
<Button
type="submit"
className="w-full rounded-full gap-2 btn-press"
size="lg"
disabled={loading}
data-testid="login-submit"
>
{loading ? 'Please wait...' : (isRegister ? 'Create Account' : 'Sign In')}
<ArrowRight className="h-4 w-4" />
</Button>
</form>
<Separator className="my-6" />
<p className="text-center text-sm text-muted-foreground">
{isRegister ? 'Already have an account?' : "Don't have an account?"}{' '}
<button
type="button"
onClick={() => setIsRegister(!isRegister)}
className="text-primary hover:underline font-medium"
data-testid="toggle-auth-mode"
>
{isRegister ? 'Sign in' : 'Sign up'}
</button>
</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import { Package, ShoppingBag, Clock, Eye, ChevronRight } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { Separator } from '../components/ui/separator';
import { useAuth } from '../context/AuthContext';
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const statusColors = {
pending: 'bg-yellow-500',
processing: 'bg-blue-500',
layaway: 'bg-purple-500',
shipped: 'bg-cyan-500',
delivered: 'bg-green-500',
cancelled: 'bg-red-500',
refunded: 'bg-orange-500',
on_hold: 'bg-gray-500'
};
const statusLabels = {
pending: 'Pending',
processing: 'Processing',
layaway: 'Layaway',
shipped: 'Shipped',
delivered: 'Delivered',
cancelled: 'Cancelled',
refunded: 'Refunded',
on_hold: 'On Hold'
};
const OrderHistory = () => {
const { token, isAuthenticated } = useAuth();
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedOrder, setSelectedOrder] = useState(null);
useEffect(() => {
if (isAuthenticated) {
fetchOrders();
}
}, [isAuthenticated, token]);
const fetchOrders = async () => {
try {
const response = await axios.get(`${API}/orders`, {
headers: { Authorization: `Bearer ${token}` }
});
setOrders(response.data);
} catch (error) {
console.error('Failed to fetch orders:', error);
} finally {
setLoading(false);
}
};
if (!isAuthenticated) {
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted flex items-center justify-center">
<ShoppingBag className="h-10 w-10 text-muted-foreground" />
</div>
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">Please Sign In</h2>
<p className="text-muted-foreground mb-8">You need to be logged in to view your orders</p>
<Link to="/login">
<Button className="rounded-full">Sign In</Button>
</Link>
</div>
</div>
);
}
if (loading) {
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8">
<div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-muted rounded-xl" />
))}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8">
<h1 className="text-3xl font-bold font-['Outfit'] mb-8">Order History</h1>
{orders.length === 0 ? (
<div className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted flex items-center justify-center">
<Package className="h-10 w-10 text-muted-foreground" />
</div>
<h2 className="text-xl font-bold mb-4 font-['Outfit']">No Orders Yet</h2>
<p className="text-muted-foreground mb-8">Start shopping to see your orders here</p>
<Link to="/products">
<Button className="rounded-full">Browse Products</Button>
</Link>
</div>
) : (
<div className="space-y-4">
{orders.map((order) => (
<div
key={order.id}
className="border border-border rounded-xl bg-card overflow-hidden"
data-testid={`order-${order.id}`}
>
{/* Order Header */}
<div className="p-4 md:p-6 flex flex-col md:flex-row md:items-center justify-between gap-4 bg-muted/30">
<div className="flex flex-wrap gap-4 md:gap-8">
<div>
<p className="text-xs text-muted-foreground mb-1">Order ID</p>
<p className="font-mono text-sm">{order.id.slice(0, 8)}...</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Date</p>
<p className="text-sm">{new Date(order.created_at).toLocaleDateString()}</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Total</p>
<p className="font-semibold font-['Outfit']">${order.total.toFixed(2)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge className={`${statusColors[order.status]} text-white`}>
{statusLabels[order.status]}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedOrder(selectedOrder === order.id ? null : order.id)}
data-testid={`toggle-order-${order.id}`}
>
{selectedOrder === order.id ? 'Hide' : 'Details'}
<ChevronRight className={`h-4 w-4 ml-1 transition-transform ${selectedOrder === order.id ? 'rotate-90' : ''}`} />
</Button>
</div>
</div>
{/* Order Details */}
{selectedOrder === order.id && (
<div className="p-4 md:p-6 border-t border-border">
{/* Items */}
<h4 className="font-semibold mb-4 font-['Outfit']">Items</h4>
<div className="space-y-3 mb-6">
{order.items.map((item) => (
<div key={item.id} className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg overflow-hidden bg-muted flex-shrink-0">
<img
src={item.product_image}
alt={item.product_name}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.product_name}</p>
<p className="text-sm text-muted-foreground">Qty: {item.quantity}</p>
</div>
<p className="font-semibold">${(item.price * item.quantity).toFixed(2)}</p>
</div>
))}
</div>
<Separator className="my-4" />
{/* Order Summary */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Subtotal</p>
<p className="font-medium">${order.subtotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Tax</p>
<p className="font-medium">${order.tax.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Shipping</p>
<p className="font-medium">${order.shipping.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Total</p>
<p className="font-bold text-lg font-['Outfit']">${order.total.toFixed(2)}</p>
</div>
</div>
{/* Tracking */}
{order.tracking_number && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">Tracking Number</p>
<p className="font-mono">{order.tracking_number}</p>
</div>
)}
{/* Status History */}
{order.status_history && order.status_history.length > 0 && (
<div className="mt-6">
<h4 className="font-semibold mb-3 font-['Outfit']">Order Timeline</h4>
<div className="space-y-3">
{order.status_history.map((history, idx) => (
<div key={history.id} className="flex items-start gap-3">
<div className="relative">
<div className={`w-3 h-3 rounded-full ${statusColors[history.status]}`} />
{idx < order.status_history.length - 1 && (
<div className="absolute top-3 left-1.5 w-0.5 h-6 bg-border -translate-x-1/2" />
)}
</div>
<div>
<p className="font-medium text-sm">{statusLabels[history.status]}</p>
<p className="text-xs text-muted-foreground">
{new Date(history.created_at).toLocaleString()}
</p>
{history.notes && (
<p className="text-xs text-muted-foreground mt-1">{history.notes}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};
export default OrderHistory;

View File

@@ -0,0 +1,313 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import axios from 'axios';
import { ArrowLeft, ShoppingCart, Minus, Plus, Package, Shield, Truck, Star, Send } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { Separator } from '../components/ui/separator';
import { Input } from '../components/ui/input';
import { Textarea } from '../components/ui/textarea';
import { Label } from '../components/ui/label';
import { useCart } from '../context/CartContext';
import { useAuth } from '../context/AuthContext';
import { toast } from 'sonner';
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const ProductDetail = () => {
const { id } = useParams();
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [quantity, setQuantity] = useState(1);
const [reviewForm, setReviewForm] = useState({ rating: 5, title: '', comment: '' });
const [submittingReview, setSubmittingReview] = useState(false);
const { addToCart } = useCart();
const { isAuthenticated, token } = useAuth();
useEffect(() => {
fetchProduct();
}, [id]);
const fetchProduct = async () => {
try {
const response = await axios.get(`${API}/products/${id}`);
setProduct(response.data);
} catch (error) {
console.error('Failed to fetch product:', error);
} finally {
setLoading(false);
}
};
const handleAddToCart = async () => {
if (!isAuthenticated) {
toast.error('Please login to add items to cart');
return;
}
try {
await addToCart(product.id, quantity);
toast.success(`${product.name} added to cart`);
} catch (error) {
toast.error('Failed to add item to cart');
}
};
const handleSubmitReview = async (e) => {
e.preventDefault();
if (!isAuthenticated) {
toast.error('Please login to submit a review');
return;
}
setSubmittingReview(true);
try {
await axios.post(`${API}/reviews`, {
product_id: product.id,
...reviewForm
}, {
headers: { Authorization: `Bearer ${token}` }
});
toast.success('Review submitted successfully!');
setReviewForm({ rating: 5, title: '', comment: '' });
fetchProduct();
} catch (error) {
toast.error('Failed to submit review');
} finally {
setSubmittingReview(false);
}
};
if (loading) {
return (
<div className="min-h-screen py-8 md:py-12">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div className="aspect-square bg-muted rounded-2xl animate-pulse" />
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-12 bg-muted rounded w-2/3" />
<div className="h-24 bg-muted rounded" />
</div>
</div>
</div>
</div>
);
}
if (!product) {
return (
<div className="min-h-screen py-8 md:py-12">
<div className="max-w-7xl mx-auto px-4 md:px-8 text-center py-16">
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">Product not found</h2>
<Link to="/products">
<Button className="rounded-full">Back to Products</Button>
</Link>
</div>
</div>
);
}
const renderStars = (rating, interactive = false, size = 'h-4 w-4') => {
return (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
disabled={!interactive}
onClick={() => interactive && setReviewForm({ ...reviewForm, rating: star })}
className={interactive ? 'cursor-pointer' : 'cursor-default'}
>
<Star
className={`${size} ${star <= rating ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
/>
</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">
<Link
to="/products"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors"
data-testid="back-to-products"
>
<ArrowLeft className="h-4 w-4" />
Back to Products
</Link>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image */}
<div className="relative">
<div className="aspect-square rounded-2xl overflow-hidden bg-muted border border-border">
<img src={product.image_url} alt={product.name} className="w-full h-full object-cover" data-testid="product-image" />
</div>
{product.stock <= 5 && product.stock > 0 && (
<Badge className="absolute top-4 left-4 bg-orange-500">Only {product.stock} left</Badge>
)}
</div>
{/* Details */}
<div className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="secondary" className="capitalize">{product.category}</Badge>
{product.brand && <span className="text-sm text-muted-foreground">{product.brand}</span>}
</div>
<h1 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4" data-testid="product-title">{product.name}</h1>
{/* Rating */}
{product.review_count > 0 && (
<div className="flex items-center gap-2 mb-4">
{renderStars(Math.round(product.average_rating))}
<span className="text-sm text-muted-foreground">
({product.average_rating?.toFixed(1)}) · {product.review_count} reviews
</span>
</div>
)}
<p className="text-muted-foreground leading-relaxed" data-testid="product-description">{product.description}</p>
</div>
<Separator />
<div>
<span className="text-sm text-muted-foreground">Price</span>
<p className="text-4xl font-bold font-['Outfit']" data-testid="product-price">${product.price.toFixed(2)}</p>
</div>
{product.specs && Object.keys(product.specs).length > 0 && (
<div>
<h3 className="font-semibold mb-3 font-['Outfit']">Specifications</h3>
<div className="grid grid-cols-2 gap-3">
{Object.entries(product.specs).map(([key, value]) => (
<div key={key} className="flex justify-between p-3 bg-muted rounded-lg">
<span className="text-sm text-muted-foreground capitalize">{key.replace('_', ' ')}</span>
<span className="text-sm font-medium">{value}</span>
</div>
))}
</div>
</div>
)}
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Quantity</span>
<div className="flex items-center border border-input rounded-full">
<Button variant="ghost" size="icon" className="rounded-full h-10 w-10" onClick={() => setQuantity(Math.max(1, quantity - 1))} data-testid="quantity-decrease">
<Minus className="h-4 w-4" />
</Button>
<span className="w-12 text-center font-medium" data-testid="quantity-value">{quantity}</span>
<Button variant="ghost" size="icon" className="rounded-full h-10 w-10" onClick={() => setQuantity(Math.min(product.stock, quantity + 1))} data-testid="quantity-increase">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<Button size="lg" className="w-full rounded-full gap-2 btn-press" onClick={handleAddToCart} disabled={product.stock === 0} data-testid="add-to-cart">
<ShoppingCart className="h-5 w-5" />
Add to Cart
</Button>
{product.stock === 0 && <p className="text-destructive text-sm">This product is currently out of stock</p>}
</div>
<div className="grid grid-cols-3 gap-4 pt-4">
<div className="text-center p-4 rounded-xl bg-muted/50">
<Truck className="h-6 w-6 mx-auto mb-2 text-primary" />
<p className="text-xs text-muted-foreground">Free Shipping</p>
</div>
<div className="text-center p-4 rounded-xl bg-muted/50">
<Shield className="h-6 w-6 mx-auto mb-2 text-primary" />
<p className="text-xs text-muted-foreground">1 Year Warranty</p>
</div>
<div className="text-center p-4 rounded-xl bg-muted/50">
<Package className="h-6 w-6 mx-auto mb-2 text-primary" />
<p className="text-xs text-muted-foreground">Easy Returns</p>
</div>
</div>
</div>
</div>
{/* Reviews Section */}
<div className="mt-16">
<Separator className="mb-8" />
<h2 className="text-2xl font-bold font-['Outfit'] mb-6">Customer Reviews</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Write Review */}
<div className="lg:col-span-1">
<div className="border border-border rounded-xl p-6 bg-card sticky top-24">
<h3 className="font-semibold mb-4 font-['Outfit']">Write a Review</h3>
<form onSubmit={handleSubmitReview} className="space-y-4">
<div className="space-y-2">
<Label>Rating</Label>
{renderStars(reviewForm.rating, true, 'h-6 w-6')}
</div>
<div className="space-y-2">
<Label>Title</Label>
<Input
value={reviewForm.title}
onChange={(e) => setReviewForm({ ...reviewForm, title: e.target.value })}
placeholder="Summary of your review"
data-testid="review-title"
/>
</div>
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={reviewForm.comment}
onChange={(e) => setReviewForm({ ...reviewForm, comment: e.target.value })}
placeholder="Share your experience..."
rows={4}
data-testid="review-comment"
/>
</div>
<Button type="submit" className="w-full rounded-full gap-2" disabled={submittingReview} data-testid="submit-review">
<Send className="h-4 w-4" />
{submittingReview ? 'Submitting...' : 'Submit Review'}
</Button>
</form>
</div>
</div>
{/* Reviews List */}
<div className="lg:col-span-2 space-y-4">
{product.reviews && product.reviews.length > 0 ? (
product.reviews.map((review) => (
<div key={review.id} className="border border-border rounded-xl p-6 bg-card" data-testid={`review-${review.id}`}>
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">{review.user_name}</span>
{review.is_verified_purchase && (
<Badge variant="secondary" className="text-xs">Verified Purchase</Badge>
)}
</div>
{renderStars(review.rating)}
</div>
<span className="text-xs text-muted-foreground">
{new Date(review.created_at).toLocaleDateString()}
</span>
</div>
{review.title && <h4 className="font-medium mb-2">{review.title}</h4>}
{review.comment && <p className="text-sm text-muted-foreground">{review.comment}</p>}
</div>
))
) : (
<div className="text-center py-12 border border-border rounded-xl bg-card">
<Star className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">No reviews yet. Be the first to review this product!</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default ProductDetail;

View File

@@ -0,0 +1,334 @@
import React, { useState, useEffect } 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';
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(() => {
fetchProducts();
}, [category, search]);
const fetchProducts = async () => {
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;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { User, Mail, Calendar, ShoppingBag, Wrench, LogOut, Package, Settings } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Separator } from '../components/ui/separator';
import { Badge } from '../components/ui/badge';
import { useAuth } from '../context/AuthContext';
const Profile = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/');
};
if (!user) {
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center py-16">
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">Please Sign In</h2>
<p className="text-muted-foreground mb-8">
You need to be logged in to view your profile
</p>
<Button onClick={() => navigate('/login')} className="rounded-full">
Sign In
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen py-12 md:py-16">
<div className="max-w-4xl mx-auto px-4 md:px-8">
<h1 className="text-3xl font-bold font-['Outfit'] mb-8">My Profile</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Profile Card */}
<div className="md:col-span-1">
<div className="border border-border rounded-2xl bg-card p-6 text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-10 w-10 text-primary" />
</div>
<h2 className="text-xl font-semibold font-['Outfit'] mb-1" data-testid="profile-name">
{user.name}
</h2>
<p className="text-sm text-muted-foreground mb-2" data-testid="profile-email">
{user.email}
</p>
{user.role === 'admin' && (
<Badge className="mb-4">Admin</Badge>
)}
<Button
variant="outline"
className="w-full rounded-full gap-2 mt-4"
onClick={handleLogout}
data-testid="profile-logout"
>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</div>
</div>
{/* Details */}
<div className="md:col-span-2 space-y-6">
{/* Account Info */}
<div className="border border-border rounded-2xl bg-card p-6">
<h3 className="text-lg font-semibold font-['Outfit'] mb-4">Account Information</h3>
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<User className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Full Name</p>
<p className="font-medium">{user.name}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<Mail className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Email Address</p>
<p className="font-medium">{user.email}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<Calendar className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Account Type</p>
<p className="font-medium capitalize">{user.role || 'Customer'}</p>
</div>
</div>
</div>
</div>
{/* Quick Links */}
<div className="border border-border rounded-2xl bg-card p-6">
<h3 className="text-lg font-semibold font-['Outfit'] mb-4">Quick Links</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
variant="outline"
className="justify-start gap-3 h-auto py-4 rounded-xl"
onClick={() => navigate('/orders')}
data-testid="profile-view-orders"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Package className="h-5 w-5 text-primary" />
</div>
<div className="text-left">
<p className="font-medium">Order History</p>
<p className="text-xs text-muted-foreground">View your past orders</p>
</div>
</Button>
<Button
variant="outline"
className="justify-start gap-3 h-auto py-4 rounded-xl"
onClick={() => navigate('/cart')}
data-testid="profile-view-cart"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<ShoppingBag className="h-5 w-5 text-primary" />
</div>
<div className="text-left">
<p className="font-medium">Shopping Cart</p>
<p className="text-xs text-muted-foreground">View your cart items</p>
</div>
</Button>
<Button
variant="outline"
className="justify-start gap-3 h-auto py-4 rounded-xl"
onClick={() => navigate('/services')}
data-testid="profile-book-service"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Wrench className="h-5 w-5 text-primary" />
</div>
<div className="text-left">
<p className="font-medium">Book a Service</p>
<p className="text-xs text-muted-foreground">Schedule a repair</p>
</div>
</Button>
{user.role === 'admin' && (
<Button
variant="outline"
className="justify-start gap-3 h-auto py-4 rounded-xl border-primary/50"
onClick={() => navigate('/admin')}
data-testid="profile-admin-dashboard"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Settings className="h-5 w-5 text-primary" />
</div>
<div className="text-left">
<p className="font-medium">Admin Dashboard</p>
<p className="text-xs text-muted-foreground">Manage store</p>
</div>
</Button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Profile;

View File

@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { ArrowLeft, Clock, Calendar, Check, Phone, Mail, User } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { Input } from '../components/ui/input';
import { Textarea } from '../components/ui/textarea';
import { Label } from '../components/ui/label';
import { Separator } from '../components/ui/separator';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../components/ui/dialog';
import { toast } from 'sonner';
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const ServiceDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [service, setService] = useState(null);
const [loading, setLoading] = useState(true);
const [bookingOpen, setBookingOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
preferred_date: '',
notes: ''
});
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]);
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
try {
await axios.post(`${API}/services/book`, {
service_id: service.id,
...formData
});
toast.success('Booking submitted successfully! We will contact you soon.');
setBookingOpen(false);
setFormData({ name: '', email: '', phone: '', preferred_date: '', notes: '' });
} catch (error) {
toast.error('Failed to submit booking. Please try again.');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="min-h-screen py-8 md:py-12">
<div className="max-w-4xl mx-auto px-4 md:px-8">
<div className="animate-pulse space-y-8">
<div className="aspect-video bg-muted rounded-2xl" />
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-24 bg-muted rounded" />
</div>
</div>
</div>
);
}
if (!service) {
return (
<div className="min-h-screen py-8 md:py-12">
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center py-16">
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">Service not found</h2>
<Link to="/services">
<Button className="rounded-full">Back to Services</Button>
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen py-8 md:py-12">
<div className="max-w-4xl mx-auto px-4 md:px-8">
{/* Breadcrumb */}
<Link
to="/services"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors"
data-testid="back-to-services"
>
<ArrowLeft className="h-4 w-4" />
Back to Services
</Link>
{/* Hero Image */}
<div className="relative aspect-video rounded-2xl overflow-hidden mb-8">
<img
src={service.image_url}
alt={service.name}
className="w-full h-full object-cover"
data-testid="service-image"
/>
<Badge className="absolute top-4 left-4 capitalize">
{service.category}
</Badge>
</div>
{/* Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div>
<h1 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4" data-testid="service-title">
{service.name}
</h1>
<p className="text-lg text-muted-foreground leading-relaxed" data-testid="service-description">
{service.description}
</p>
</div>
<Separator />
{/* What's Included */}
<div>
<h3 className="text-xl font-semibold mb-4 font-['Outfit']">What's Included</h3>
<ul className="space-y-3">
{[
'Free diagnostic assessment',
'Quality replacement parts (if needed)',
'Professional service by certified technicians',
'30-day warranty on all repairs',
'Post-service support'
].map((item, idx) => (
<li key={idx} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Check className="h-3 w-3 text-primary" />
</div>
<span className="text-muted-foreground">{item}</span>
</li>
))}
</ul>
</div>
<Separator />
{/* Process */}
<div>
<h3 className="text-xl font-semibold mb-4 font-['Outfit']">How It Works</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ step: '1', title: 'Book', desc: 'Schedule your appointment online' },
{ step: '2', title: 'Drop Off', desc: 'Bring your device to our store' },
{ step: '3', title: 'Pick Up', desc: 'Get your device back fixed' }
].map((item, idx) => (
<div key={idx} className="text-center p-4 rounded-xl bg-muted/50">
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-bold">
{item.step}
</div>
<h4 className="font-semibold mb-1 font-['Outfit']">{item.title}</h4>
<p className="text-sm text-muted-foreground">{item.desc}</p>
</div>
))}
</div>
</div>
</div>
{/* Sidebar */}
<div className="lg:col-span-1">
<div className="sticky top-24 border border-border rounded-2xl p-6 bg-card space-y-6">
<div>
<span className="text-sm text-muted-foreground">Starting from</span>
<p className="text-3xl font-bold font-['Outfit']" data-testid="service-price">
${service.price.toFixed(2)}
</p>
</div>
<div className="flex items-center gap-3 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>Duration: {service.duration}</span>
</div>
<Dialog open={bookingOpen} onOpenChange={setBookingOpen}>
<DialogTrigger asChild>
<Button className="w-full rounded-full btn-press" size="lg" data-testid="book-now-button">
Book Now
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-['Outfit']">Book {service.name}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<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
data-testid="booking-name"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="john@example.com"
className="pl-10"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
data-testid="booking-email"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="phone"
type="tel"
placeholder="+1 234 567 890"
className="pl-10"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
required
data-testid="booking-phone"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="date">Preferred Date</Label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="date"
type="date"
className="pl-10"
value={formData.preferred_date}
onChange={(e) => setFormData({ ...formData, preferred_date: e.target.value })}
required
data-testid="booking-date"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Additional Notes (Optional)</Label>
<Textarea
id="notes"
placeholder="Describe your issue..."
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
data-testid="booking-notes"
/>
</div>
<Button
type="submit"
className="w-full rounded-full"
disabled={submitting}
data-testid="submit-booking"
>
{submitting ? 'Submitting...' : 'Submit Booking'}
</Button>
</form>
</DialogContent>
</Dialog>
<p className="text-xs text-center text-muted-foreground">
No payment required for booking
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default ServiceDetail;

View File

@@ -0,0 +1,216 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Wrench, HardDrive, Shield, Cpu, Settings, Smartphone } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import ServiceCard from '../components/cards/ServiceCard';
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const Services = () => {
const [services, setServices] = useState([]);
const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState('all');
const categories = [
{ value: 'all', label: 'All Services', icon: Settings },
{ value: 'repair', label: 'Repairs', icon: Wrench },
{ value: 'data', label: 'Data Recovery', icon: HardDrive },
{ value: 'software', label: 'Software', icon: Shield },
{ value: 'upgrade', label: 'Upgrades', icon: Cpu },
{ value: 'setup', label: 'Setup', icon: Smartphone },
];
useEffect(() => {
fetchServices();
}, [activeCategory]);
const fetchServices = async () => {
setLoading(true);
try {
const params = activeCategory !== 'all' ? `?category=${activeCategory}` : '';
const response = await axios.get(`${API}/services${params}`);
setServices(response.data);
} catch (error) {
console.error('Failed to fetch services:', error);
} finally {
setLoading(false);
}
};
const stats = [
{ value: '10K+', label: 'Devices Repaired' },
{ value: '98%', label: 'Success Rate' },
{ value: '24h', label: 'Avg Turnaround' },
{ value: '5 Star', label: 'Customer Rating' },
];
return (
<div className="min-h-screen">
{/* Hero Section */}
<section className="relative py-16 md:py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div className="space-y-6">
<Badge variant="secondary" className="rounded-full px-4 py-1">
Professional Tech Services
</Badge>
<h1 className="text-4xl sm:text-5xl font-bold font-['Outfit'] leading-tight">
Expert Repair &
<br />
<span className="text-muted-foreground">Tech Solutions</span>
</h1>
<p className="text-lg text-muted-foreground max-w-lg">
From screen repairs to data recovery, our certified technicians
provide professional solutions for all your tech needs.
</p>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4">
{stats.map((stat, idx) => (
<div key={idx} className="text-center p-4 rounded-xl bg-card border border-border">
<p className="text-2xl font-bold font-['Outfit']">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
</div>
<div className="relative">
<img
src="https://images.unsplash.com/photo-1676630444903-163fe485c5d1?w=800"
alt="Tech Repair Service"
className="rounded-2xl shadow-2xl"
data-testid="services-hero-image"
/>
</div>
</div>
</div>
</section>
{/* Categories */}
<section className="py-8 border-b border-border sticky top-16 bg-background/95 backdrop-blur-sm z-40">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
{categories.map((cat) => {
const Icon = cat.icon;
return (
<Button
key={cat.value}
variant={activeCategory === cat.value ? 'default' : 'outline'}
className="rounded-full gap-2 flex-shrink-0"
onClick={() => setActiveCategory(cat.value)}
data-testid={`service-category-${cat.value}`}
>
<Icon className="h-4 w-4" />
{cat.label}
</Button>
);
})}
</div>
</div>
</section>
{/* Services Grid */}
<section className="py-12 md:py-16">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="mb-8">
<h2 className="text-2xl md:text-3xl font-bold font-['Outfit'] mb-2">
{categories.find(c => c.value === activeCategory)?.label || 'All Services'}
</h2>
<p className="text-muted-foreground">
Professional solutions for all your tech needs
</p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-6 animate-pulse">
<div className="aspect-video bg-muted rounded-lg mb-4" />
<div className="h-5 bg-muted rounded w-2/3 mb-2" />
<div className="h-4 bg-muted rounded w-full mb-4" />
<div className="h-8 bg-muted rounded w-1/3" />
</div>
))}
</div>
) : services.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">
<Wrench className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">No services found</h3>
<p className="text-muted-foreground mb-4">
Try selecting a different category
</p>
<Button
variant="outline"
onClick={() => setActiveCategory('all')}
className="rounded-full"
>
View All Services
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{services.map((service) => (
<ServiceCard key={service.id} service={service} />
))}
</div>
)}
</div>
</section>
{/* Why Choose Us */}
<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">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Why Choose Us?
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
We're committed to providing the best service experience
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
icon: Shield,
title: 'Certified Technicians',
desc: 'All our technicians are professionally certified and trained to handle any device.'
},
{
icon: Wrench,
title: 'Genuine Parts',
desc: 'We use only genuine manufacturer parts to ensure quality and longevity.'
},
{
icon: HardDrive,
title: 'Data Safety',
desc: 'Your data privacy is our priority. We follow strict security protocols.'
}
].map((item, idx) => {
const Icon = item.icon;
return (
<div
key={idx}
className="text-center p-8 rounded-2xl bg-card border border-border hover-lift"
data-testid={`why-choose-${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>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.desc}</p>
</div>
);
})}
</div>
</div>
</section>
</div>
);
};
export default Services;

View File

@@ -0,0 +1,82 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./src/**/*.{js,jsx,ts,tsx}",
"./public/index.html"
],
theme: {
extend: {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require("tailwindcss-animate")],
};