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

5
frontend/.env Normal file
View File

@@ -0,0 +1,5 @@
PORT=5300
REACT_APP_BACKEND_URL=http://localhost:8181
REACT_APP_API_URL=http://localhost:8181/api
WDS_SOCKET_PORT=443
ENABLE_HEALTH_CHECK=false

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
frontend/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

21
frontend/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

43
frontend/craco.config.js Normal file
View File

@@ -0,0 +1,43 @@
// craco.config.js
const path = require("path");
require("dotenv").config();
module.exports = {
webpack: {
alias: {
"@": path.resolve(__dirname, "src"),
},
configure: (webpackConfig) => {
// Prevent build from failing on warnings
webpackConfig.stats = {
warnings: false,
};
return webpackConfig;
},
},
devServer: {
port: 5300,
open: false,
historyApiFallback: true,
hot: true,
client: {
overlay: {
errors: true,
warnings: false,
},
webSocketURL: {
port: 443,
},
},
setupMiddlewares: (middlewares, devServer) => {
return middlewares;
},
onListening: function (devServer) {
if (!devServer) {
throw new Error("webpack-dev-server is not defined");
}
const port = devServer.server.address().port;
console.log("Frontend listening on port:", port);
},
},
};

24
frontend/frontend.log Normal file
View File

@@ -0,0 +1,24 @@
> frontend@0.1.0 start
> craco start
(node:3359100) [DEP0176] DeprecationWarning: fs.F_OK is deprecated, use fs.constants.F_OK instead
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:3359100) [DEP_WEBPACK_DEV_SERVER_ON_AFTER_SETUP_MIDDLEWARE] DeprecationWarning: 'onAfterSetupMiddleware' option is deprecated. Please use the 'setupMiddlewares' option.
(node:3359100) [DEP_WEBPACK_DEV_SERVER_ON_BEFORE_SETUP_MIDDLEWARE] DeprecationWarning: 'onBeforeSetupMiddleware' option is deprecated. Please use the 'setupMiddlewares' option.
Starting the development server...
Compiled successfully!
You can now view frontend in the browser.
Local: http://localhost:5300
On Your Network: http://192.168.10.130:5300
Note that the development build is not optimized.
To create a production build, use yarn build.
webpack compiled successfully
Compiling...
Compiled successfully!
webpack compiled successfully

9
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

21385
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

99
frontend/package.json Normal file
View File

@@ -0,0 +1,99 @@
{
"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",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"ajv": "^8.17.1",
"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": "^6.28.0",
"react-scripts": "5.0.1",
"react-to-print": "^3.2.0",
"readdirp": "^5.0.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,40 @@
<!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="PromptTech Solutions - Your trusted destination for premium electronics and professional repair services"
/>
<!--
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>PromptTech Solutions</title>
</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`.
-->
</body>
</html>

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

58
frontend/src/App.css Normal file
View File

@@ -0,0 +1,58 @@
.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);
}
}
/* Thin scrollbar styles */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* Firefox thin scrollbar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}

63
frontend/src/App.js Normal file
View File

@@ -0,0 +1,63 @@
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 DebugServices from "./pages/DebugServices";
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="/debug-services" element={<DebugServices />} />
<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,174 @@
import React, { useState, useRef } from "react";
import { X, Upload, GripVertical, Star } from "lucide-react";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import axios from "axios";
import { toast } from "sonner";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const ImageUploadManager = ({ images = [], onChange, token }) => {
const [uploading, setUploading] = useState(false);
const [draggedIndex, setDraggedIndex] = useState(null);
const fileInputRef = useRef(null);
const handleFileUpload = async (files) => {
setUploading(true);
try {
const uploadedUrls = [];
for (const file of files) {
console.log("Uploading file:", {
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
});
const formData = new FormData();
formData.append("file", file);
const response = await axios.post(`${API}/upload/image`, formData, {
headers: {
Authorization: `Bearer ${token}`,
},
});
uploadedUrls.push(response.data.url);
console.log("Upload successful:", response.data);
}
onChange([...images, ...uploadedUrls]);
toast.success(`Uploaded ${uploadedUrls.length} image(s)`);
} catch (error) {
toast.error("Failed to upload images");
console.error("Upload error:", error);
if (error.response) {
console.error("Error response:", error.response.data);
}
} finally {
setUploading(false);
}
};
const handleFileSelect = (e) => {
const files = Array.from(e.target.files);
if (files.length > 0) {
handleFileUpload(files);
}
e.target.value = ""; // Reset input
};
const handleRemoveImage = (index) => {
const newImages = images.filter((_, i) => i !== index);
onChange(newImages);
};
const handleDragStart = (e, index) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e, index) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const newImages = [...images];
const draggedImage = newImages[draggedIndex];
newImages.splice(draggedIndex, 1);
newImages.splice(index, 0, draggedImage);
setDraggedIndex(index);
onChange(newImages);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Product Images</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<Upload className="h-4 w-4 mr-2" />
{uploading ? "Uploading..." : "Add Images"}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
</div>
{images.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{images.map((url, index) => (
<div
key={index}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className="relative group border border-border rounded-md overflow-hidden cursor-move hover:border-primary transition-colors"
>
<img
src={
url.startsWith("/uploads")
? `${process.env.REACT_APP_BACKEND_URL}${url}`
: url
}
alt={`Product ${index + 1}`}
className="w-full h-32 object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<GripVertical className="h-5 w-5 text-white" />
{index === 0 && (
<Star className="h-5 w-5 text-yellow-400 fill-yellow-400" />
)}
</div>
<button
type="button"
onClick={() => handleRemoveImage(index)}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
{index === 0 && (
<div className="absolute bottom-2 left-2 bg-yellow-500 text-white text-xs px-2 py-1 rounded">
Primary
</div>
)}
</div>
))}
</div>
) : (
<div className="border-2 border-dashed border-border rounded-md p-8 text-center text-muted-foreground">
<Upload className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
No images yet. Click "Add Images" to upload.
</p>
</div>
)}
{images.length > 0 && (
<p className="text-xs text-muted-foreground">
Drag images to reorder. First image is the primary image.
</p>
)}
</div>
);
};
export default ImageUploadManager;

View File

@@ -0,0 +1,108 @@
import React, { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "./ui/button";
const ProductImageCarousel = ({ images = [], alt = "Product" }) => {
const [currentIndex, setCurrentIndex] = useState(0);
// Handle empty or single image
if (!images || images.length === 0) {
return (
<div className="w-full aspect-square bg-muted rounded-lg flex items-center justify-center">
<p className="text-muted-foreground">No image available</p>
</div>
);
}
const imageUrls = images.map((img) =>
typeof img === "string" ? img : img.url || img.image_url
);
const goToPrevious = () => {
setCurrentIndex((prevIndex) =>
prevIndex === 0 ? imageUrls.length - 1 : prevIndex - 1
);
};
const goToNext = () => {
setCurrentIndex((prevIndex) =>
prevIndex === imageUrls.length - 1 ? 0 : prevIndex + 1
);
};
const goToIndex = (index) => {
setCurrentIndex(index);
};
const getImageUrl = (url) => {
if (url.startsWith("/uploads")) {
return `${process.env.REACT_APP_BACKEND_URL}${url}`;
}
return url;
};
return (
<div className="space-y-4">
{/* Main Image */}
<div className="relative aspect-square bg-muted rounded-lg overflow-hidden group">
<img
src={getImageUrl(imageUrls[currentIndex])}
alt={`${alt} - Image ${currentIndex + 1}`}
className="w-full h-full object-cover"
/>
{/* Navigation Arrows - Only show if more than 1 image */}
{imageUrls.length > 1 && (
<>
<Button
variant="outline"
size="icon"
className="absolute left-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
onClick={goToPrevious}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
onClick={goToNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
{/* Image Counter */}
<div className="absolute bottom-4 right-4 bg-background/80 px-3 py-1 rounded-full text-sm">
{currentIndex + 1} / {imageUrls.length}
</div>
</>
)}
</div>
{/* Thumbnail Strip - Only show if more than 1 image */}
{imageUrls.length > 1 && (
<div className="grid grid-cols-5 gap-2">
{imageUrls.map((url, index) => (
<button
key={index}
onClick={() => goToIndex(index)}
className={`aspect-square rounded-md overflow-hidden border-2 transition-all ${
index === currentIndex
? "border-primary ring-2 ring-primary/20"
: "border-transparent hover:border-border"
}`}
>
<img
src={getImageUrl(url)}
alt={`Thumbnail ${index + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
);
};
export default ProductImageCarousel;

View File

@@ -0,0 +1,131 @@
import React from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import {
Bold,
Italic,
List,
ListOrdered,
Heading2,
Quote,
Undo,
Redo,
} from "lucide-react";
import { Button } from "./ui/button";
const MenuBar = ({ editor }) => {
if (!editor) {
return null;
}
return (
<div className="border-b border-border p-2 flex flex-wrap gap-1">
<Button
type="button"
variant={editor.isActive("bold") ? "default" : "outline"}
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant={editor.isActive("italic") ? "default" : "outline"}
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant={
editor.isActive("heading", { level: 2 }) ? "default" : "outline"
}
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant={editor.isActive("bulletList") ? "default" : "outline"}
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant={editor.isActive("orderedList") ? "default" : "outline"}
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
type="button"
variant={editor.isActive("blockquote") ? "default" : "outline"}
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Quote className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="outline"
size="sm"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
>
<Redo className="h-4 w-4" />
</Button>
</div>
);
};
const RichTextEditor = ({
content,
onChange,
placeholder = "Enter description...",
}) => {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder,
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
return (
<div
className="border border-border rounded-md overflow-hidden resize-y min-h-[240px] max-h-[600px]"
style={{ overflow: "auto" }}
>
<MenuBar editor={editor} />
<div className="overflow-y-auto custom-scrollbar h-full">
<EditorContent
editor={editor}
className="prose prose-sm max-w-none p-4 min-h-[180px] focus:outline-none"
/>
</div>
</div>
);
};
export default RichTextEditor;

View File

@@ -0,0 +1,131 @@
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");
}
};
// Get the primary image URL
const getImageUrl = () => {
if (product.images && product.images.length > 0) {
const primaryImage =
product.images.find((img) => img.is_primary) || product.images[0];
const url = primaryImage.url || primaryImage.image_url || primaryImage;
return url.startsWith("/uploads")
? `${process.env.REACT_APP_BACKEND_URL}${url}`
: url;
}
return product.image_url;
};
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={getImageUrl()}
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 React.memo(ProductCard);

View File

@@ -0,0 +1,85 @@
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}`);
};
// Get the primary image URL
const getImageUrl = () => {
if (service.images && service.images.length > 0) {
const primaryImage =
service.images.find((img) => img.is_primary) || service.images[0];
const url = primaryImage.url || primaryImage.image_url || primaryImage;
return url.startsWith("/uploads")
? `${process.env.REACT_APP_BACKEND_URL}${url}`
: url;
}
return service.image_url;
};
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={getImageUrl()}
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 React.memo(ServiceCard);

View File

@@ -0,0 +1,221 @@
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"
>
<img
src="/logo.png"
alt="PromptTech Solutions"
className="w-12 h-12 rounded-lg object-contain"
/>
<span className="font-bold text-xl tracking-tight font-['Outfit']">
PromptTech Solutions
</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@prompttechsolutions.com"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
info@prompttechsolutions.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} PromptTech Solutions. 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 React.memo(Footer);

View File

@@ -0,0 +1,229 @@
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"
>
<img
src="/logo.png"
alt="PromptTech Solutions"
className="w-12 h-12 rounded-lg object-contain transition-transform group-hover:scale-105"
/>
<span className="font-bold text-xl tracking-tight font-['Outfit']">
PromptTech Solutions
</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 React.memo(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,91 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useMemo,
useCallback,
} 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 = useCallback(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 = useCallback(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 = useCallback(() => {
localStorage.removeItem("token");
setToken(null);
setUser(null);
}, []);
const value = useMemo(
() => ({
user,
token,
loading,
login,
register,
logout,
isAuthenticated: !!token,
}),
[user, token, loading, login, register, logout]
);
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,154 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
} 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 = useCallback(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);
}
}, [isAuthenticated, token]);
useEffect(() => {
fetchCart();
}, [fetchCart]);
const addToCart = useCallback(
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;
}
},
[isAuthenticated, token, fetchCart]
);
const updateQuantity = useCallback(
async (itemId, quantity) => {
try {
await axios.put(
`${API}/cart/${itemId}?quantity=${quantity}`,
{},
{
headers: { Authorization: `Bearer ${token}` },
}
);
await fetchCart();
} catch (error) {
throw error;
}
},
[token, fetchCart]
);
const removeFromCart = useCallback(
async (itemId) => {
try {
await axios.delete(`${API}/cart/${itemId}`, {
headers: { Authorization: `Bearer ${token}` },
});
await fetchCart();
} catch (error) {
throw error;
}
},
[token, fetchCart]
);
const clearCart = useCallback(async () => {
try {
await axios.delete(`${API}/cart`, {
headers: { Authorization: `Bearer ${token}` },
});
setCartItems([]);
} catch (error) {
throw error;
}
}, [token]);
const cartTotal = useMemo(
() =>
cartItems.reduce((total, item) => {
return total + (item.product?.price || 0) * item.quantity;
}, 0),
[cartItems]
);
const cartCount = useMemo(
() => cartItems.reduce((count, item) => count + item.quantity, 0),
[cartItems]
);
const value = useMemo(
() => ({
cartItems,
loading,
addToCart,
updateQuantity,
removeFromCart,
clearCart,
cartTotal,
cartCount,
fetchCart,
}),
[
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,48 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useMemo,
useCallback,
} 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 = useCallback(() => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
}, []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={value}>{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,133 @@
import { useState, useCallback } from "react";
import axios from "axios";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
/**
* Custom hook for admin API calls with consistent error handling
* @param {string} token - Auth token
* @returns {Object} API methods and loading state
*/
export const useAdminAPI = (token) => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleError = useCallback(
(error, defaultMessage = "Operation failed") => {
console.error(defaultMessage, error);
if (error.response) {
const { status } = error.response;
if (status === 401) {
toast.error("Session expired. Please login again.");
navigate("/login");
} else if (status === 403) {
toast.error("Admin access required");
navigate("/");
} else if (status === 500) {
toast.error("Server error. Please try again later.");
} else {
toast.error(`${defaultMessage} (Error ${status})`);
}
} else if (error.request) {
toast.error("Cannot connect to server. Please check your connection.");
} else {
toast.error(defaultMessage);
}
},
[navigate]
);
const getHeaders = useCallback(
() => ({
Authorization: `Bearer ${token}`,
}),
[token]
);
const apiGet = useCallback(
async (endpoint, errorMessage) => {
try {
setLoading(true);
const response = await axios.get(`${API}${endpoint}`, {
headers: getHeaders(),
timeout: 10000,
});
return response.data;
} catch (error) {
handleError(error, errorMessage);
throw error;
} finally {
setLoading(false);
}
},
[token, getHeaders, handleError]
);
const apiPost = useCallback(
async (endpoint, data, errorMessage) => {
try {
setLoading(true);
const response = await axios.post(`${API}${endpoint}`, data, {
headers: getHeaders(),
timeout: 10000,
});
return response.data;
} catch (error) {
handleError(error, errorMessage);
throw error;
} finally {
setLoading(false);
}
},
[token, getHeaders, handleError]
);
const apiPut = useCallback(
async (endpoint, data, errorMessage) => {
try {
setLoading(true);
const response = await axios.put(`${API}${endpoint}`, data, {
headers: getHeaders(),
timeout: 10000,
});
return response.data;
} catch (error) {
handleError(error, errorMessage);
throw error;
} finally {
setLoading(false);
}
},
[token, getHeaders, handleError]
);
const apiDelete = useCallback(
async (endpoint, errorMessage) => {
try {
setLoading(true);
const response = await axios.delete(`${API}${endpoint}`, {
headers: getHeaders(),
timeout: 10000,
});
return response.data;
} catch (error) {
handleError(error, errorMessage);
throw error;
} finally {
setLoading(false);
}
},
[token, getHeaders, handleError]
);
return {
loading,
apiGet,
apiPost,
apiPut,
apiDelete,
};
};

View File

@@ -0,0 +1,55 @@
import { useState } from "react";
/**
* Custom hook for managing dialog/modal state
* @returns {Object} Dialog state and controls
*/
export const useDialog = () => {
const [isOpen, setIsOpen] = useState(false);
const [item, setItem] = useState(null);
const open = (itemData = null) => {
setItem(itemData);
setIsOpen(true);
};
const close = () => {
setIsOpen(false);
setItem(null);
};
return {
isOpen,
item,
open,
close,
};
};
/**
* Custom hook for managing form state
* @param {Object} initialState - Initial form values
* @returns {Object} Form state and controls
*/
export const useFormState = (initialState) => {
const [form, setForm] = useState(initialState);
const updateField = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const updateForm = (newForm) => {
setForm(newForm);
};
const resetForm = () => {
setForm(initialState);
};
return {
form,
updateField,
updateForm,
resetForm,
};
};

326
frontend/src/index.css Normal file
View File

@@ -0,0 +1,326 @@
@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;
}
/* Rich Text Editor Styles */
.ProseMirror {
outline: none;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: hsl(var(--muted-foreground));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.ProseMirror h2 {
font-size: 1.5em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5em;
margin: 0.75em 0;
}
.ProseMirror blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1em;
margin: 1em 0;
color: hsl(var(--muted-foreground));
}
/* Product Description Prose Styles */
.prose h2 {
font-size: 1.5em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
.prose ul,
.prose ol {
padding-left: 1.5em;
margin: 0.75em 0;
}
.prose blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1em;
margin: 1em 0;
font-style: italic;
}
.prose strong {
font-weight: 600;
}
.prose em {
font-style: italic;
}
/* Custom Scrollbar Styles */
.custom-scrollbar::-webkit-scrollbar,
.ProseMirror::-webkit-scrollbar {
width: 3px;
height: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track,
.ProseMirror::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb,
.ProseMirror::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.15);
border-radius: 1.5px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
.ProseMirror::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.35);
}
/* Firefox scrollbar */
.custom-scrollbar,
.ProseMirror {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.15) transparent;
}
@layer base {
[data-debug-wrapper="true"] {
display: contents !important;
}
}

87
frontend/src/index.js Normal file
View File

@@ -0,0 +1,87 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
// Error boundary component
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error("React Error Boundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: "20px",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
<h1 style={{ color: "#ef4444", marginBottom: "16px" }}>
Something went wrong
</h1>
<p style={{ color: "#6b7280", marginBottom: "24px" }}>
The application encountered an error. Please refresh the page.
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: "12px 24px",
backgroundColor: "#3b82f6",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "16px",
}}
>
Reload Page
</button>
{process.env.NODE_ENV === "development" && this.state.error && (
<details style={{ marginTop: "24px", maxWidth: "600px" }}>
<summary style={{ cursor: "pointer", color: "#6b7280" }}>
Error Details
</summary>
<pre
style={{
marginTop: "12px",
padding: "16px",
backgroundColor: "#f3f4f6",
borderRadius: "6px",
overflow: "auto",
fontSize: "12px",
}}
>
{this.state.error.toString()}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>
);

View File

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

278
frontend/src/pages/About.js Normal file
View File

@@ -0,0 +1,278 @@
import React, { useEffect } 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 = () => {
useEffect(() => {
window.scrollTo(0, 0);
}, []);
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 PromptTech Solutions
</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, PromptTech Solutions 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">
PromptTech Solutions 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 PromptTech Solutions' 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

409
frontend/src/pages/Cart.js Normal file
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,261 @@
import React, { useState, useEffect } 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 = () => {
useEffect(() => {
window.scrollTo(0, 0);
}, []);
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@prompttechsolutions.com",
link: "mailto:info@prompttechsolutions.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,107 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
function DebugServices() {
const [status, setStatus] = useState("Loading...");
const [services, setServices] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
testAPI();
}, []);
const testAPI = async () => {
try {
console.log("Testing API:", `${API}/services`);
setStatus("Fetching from API...");
const response = await axios.get(`${API}/services`);
console.log("API Response:", response.data);
setServices(response.data);
setStatus(`Success! Loaded ${response.data.length} services`);
setError(null);
} catch (err) {
console.error("API Error:", err);
setStatus("Error loading services");
setError(err.message);
}
};
return (
<div style={{ padding: "20px", fontFamily: "Arial" }}>
<h1>Services Debug Page</h1>
<div
style={{ background: "#f0f0f0", padding: "15px", marginBottom: "20px" }}
>
<strong>Status:</strong> {status}
{error && (
<div style={{ color: "red" }}>
<strong>Error:</strong> {error}
</div>
)}
</div>
<div
style={{ background: "#e8f5e9", padding: "15px", marginBottom: "20px" }}
>
<strong>API Endpoint:</strong> {`${API}/services`}
</div>
<button
onClick={testAPI}
style={{ padding: "10px 20px", marginBottom: "20px" }}
>
Reload Services
</button>
<h2>Services ({services.length}):</h2>
{services.length === 0 ? (
<p>No services loaded yet. Click "Reload Services" button.</p>
) : (
<div>
{services.map((service, index) => (
<div
key={service.id}
style={{
border: "1px solid #ddd",
padding: "15px",
marginBottom: "10px",
background: "white",
}}
>
<h3>
{index + 1}. {service.name}
</h3>
<p>
<strong>Category:</strong> {service.category}
</p>
<p>
<strong>Price:</strong> ${service.price}
</p>
<p>
<strong>Duration:</strong> {service.duration}
</p>
<p>
<strong>ID:</strong> {service.id}
</p>
</div>
))}
</div>
)}
<div
style={{ marginTop: "30px", background: "#fff3cd", padding: "15px" }}
>
<h3>Debug Info:</h3>
<pre>{JSON.stringify(services, null, 2)}</pre>
</div>
</div>
);
}
export default DebugServices;

330
frontend/src/pages/Home.js Normal file
View File

@@ -0,0 +1,330 @@
import React, { useState, useEffect, useMemo } 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";
import { getCached, setCache } from "../utils/apiCache";
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 {
const cacheKey = "home-featured";
const cached = getCached(cacheKey);
if (cached) {
setFeaturedProducts(cached.products);
setFeaturedServices(cached.services);
setLoading(false);
return;
}
const [productsRes, servicesRes] = await Promise.all([
axios.get(`${API}/products`),
axios.get(`${API}/services`),
]);
const products = productsRes.data.slice(0, 4);
const services = servicesRes.data.slice(0, 3);
setFeaturedProducts(products);
setFeaturedServices(services);
setCache(cacheKey, { products, services });
} 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-8 md:py-12 lg:py-16">
<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;

194
frontend/src/pages/Login.js Normal file
View File

@@ -0,0 +1,194 @@
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"
>
<img
src="/logo.png"
alt="PromptTech Solutions"
className="w-14 h-14 rounded-lg object-contain"
/>
<span className="font-bold text-2xl tracking-tight font-['Outfit']">
PromptTech Solutions
</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,436 @@
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";
import ProductImageCarousel from "../components/ProductImageCarousel";
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 Carousel */}
<div className="relative">
<ProductImageCarousel
images={
product.images && product.images.length > 0
? product.images
: [product.image_url]
}
alt={product.name}
/>
{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>
)}
<div
className="text-muted-foreground leading-relaxed prose prose-sm max-w-none max-h-[300px] overflow-y-auto custom-scrollbar pr-2"
data-testid="product-description"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
</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,391 @@
import React, { useState, useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import axios from "axios";
import {
Search,
SlidersHorizontal,
X,
Grid3X3,
LayoutList,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Badge } from "../components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/ui/select";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "../components/ui/sheet";
import { Slider } from "../components/ui/slider";
import ProductCard from "../components/cards/ProductCard";
import { getCached, setCache } from "../utils/apiCache";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const Products = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(searchParams.get("search") || "");
const [category, setCategory] = useState(
searchParams.get("category") || "all"
);
const [priceRange, setPriceRange] = useState([0, 3000]);
const [sortBy, setSortBy] = useState("name");
const [viewMode, setViewMode] = useState("grid");
const [filtersOpen, setFiltersOpen] = useState(false);
const categories = [
{ value: "all", label: "All Products" },
{ value: "phones", label: "Phones" },
{ value: "laptops", label: "Laptops" },
{ value: "tablets", label: "Tablets" },
{ value: "wearables", label: "Wearables" },
{ value: "accessories", label: "Accessories" },
];
useEffect(() => {
// Scroll to top when component mounts
window.scrollTo(0, 0);
}, []);
useEffect(() => {
fetchProducts();
}, [category, search]);
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 cacheKey = `products-${params.toString()}`;
const cached = getCached(cacheKey);
if (cached) {
setProducts(cached);
setLoading(false);
return;
}
const response = await axios.get(`${API}/products?${params.toString()}`);
setProducts(response.data);
setCache(cacheKey, response.data);
} catch (error) {
console.error("Failed to fetch products:", error);
} finally {
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,255 @@
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";
import { getCached, setCache } from "../utils/apiCache";
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(() => {
// Scroll to top when component mounts
window.scrollTo(0, 0);
}, []);
useEffect(() => {
fetchServices();
}, [activeCategory]);
const fetchServices = async () => {
setLoading(true);
try {
const params =
activeCategory !== "all" ? `?category=${activeCategory}` : "";
const cacheKey = `services-${activeCategory}`;
const cached = getCached(cacheKey);
if (cached) {
setServices(cached);
setLoading(false);
return;
}
const response = await axios.get(`${API}/services${params}`);
setServices(response.data);
setCache(cacheKey, response.data);
} catch (error) {
console.error("Failed to fetch services:", error);
} finally {
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,44 @@
// Simple in-memory cache for API responses
const cache = new Map();
const CACHE_DURATION = 60000; // 60 seconds
export const getCached = (key) => {
const cached = cache.get(key);
if (!cached) return null;
const isExpired = Date.now() - cached.timestamp > CACHE_DURATION;
if (isExpired) {
cache.delete(key);
return null;
}
return cached.data;
};
export const setCache = (key, data) => {
cache.set(key, {
data,
timestamp: Date.now(),
});
};
export const clearCache = (key) => {
if (key) {
cache.delete(key);
} else {
cache.clear();
}
};
// Debounce function for API calls
export const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};

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")],
};

11744
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff