Initial commit - PromptTech
This commit is contained in:
5
frontend/.env
Normal file
5
frontend/.env
Normal 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
23
frontend/.gitignore
vendored
Normal 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
70
frontend/README.md
Normal 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
21
frontend/components.json
Normal 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
43
frontend/craco.config.js
Normal 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
24
frontend/frontend.log
Normal 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
9
frontend/jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21385
frontend/package-lock.json
generated
Normal file
21385
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
frontend/package.json
Normal file
99
frontend/package.json
Normal 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"
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
40
frontend/public/index.html
Normal file
40
frontend/public/index.html
Normal 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
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
58
frontend/src/App.css
Normal file
58
frontend/src/App.css
Normal 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
63
frontend/src/App.js
Normal 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;
|
||||
174
frontend/src/components/ImageUploadManager.js
Normal file
174
frontend/src/components/ImageUploadManager.js
Normal 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;
|
||||
108
frontend/src/components/ProductImageCarousel.js
Normal file
108
frontend/src/components/ProductImageCarousel.js
Normal 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;
|
||||
131
frontend/src/components/RichTextEditor.js
Normal file
131
frontend/src/components/RichTextEditor.js
Normal 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;
|
||||
131
frontend/src/components/cards/ProductCard.js
Normal file
131
frontend/src/components/cards/ProductCard.js
Normal 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);
|
||||
85
frontend/src/components/cards/ServiceCard.js
Normal file
85
frontend/src/components/cards/ServiceCard.js
Normal 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);
|
||||
221
frontend/src/components/layout/Footer.js
Normal file
221
frontend/src/components/layout/Footer.js
Normal 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);
|
||||
229
frontend/src/components/layout/Navbar.js
Normal file
229
frontend/src/components/layout/Navbar.js
Normal 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);
|
||||
41
frontend/src/components/ui/accordion.jsx
Normal file
41
frontend/src/components/ui/accordion.jsx
Normal 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 }
|
||||
97
frontend/src/components/ui/alert-dialog.jsx
Normal file
97
frontend/src/components/ui/alert-dialog.jsx
Normal 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,
|
||||
}
|
||||
47
frontend/src/components/ui/alert.jsx
Normal file
47
frontend/src/components/ui/alert.jsx
Normal 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 }
|
||||
5
frontend/src/components/ui/aspect-ratio.jsx
Normal file
5
frontend/src/components/ui/aspect-ratio.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
33
frontend/src/components/ui/avatar.jsx
Normal file
33
frontend/src/components/ui/avatar.jsx
Normal 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 }
|
||||
34
frontend/src/components/ui/badge.jsx
Normal file
34
frontend/src/components/ui/badge.jsx
Normal 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 }
|
||||
92
frontend/src/components/ui/breadcrumb.jsx
Normal file
92
frontend/src/components/ui/breadcrumb.jsx
Normal 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,
|
||||
}
|
||||
48
frontend/src/components/ui/button.jsx
Normal file
48
frontend/src/components/ui/button.jsx
Normal 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 }
|
||||
71
frontend/src/components/ui/calendar.jsx
Normal file
71
frontend/src/components/ui/calendar.jsx
Normal 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 }
|
||||
50
frontend/src/components/ui/card.jsx
Normal file
50
frontend/src/components/ui/card.jsx
Normal 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 }
|
||||
193
frontend/src/components/ui/carousel.jsx
Normal file
193
frontend/src/components/ui/carousel.jsx
Normal 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 };
|
||||
22
frontend/src/components/ui/checkbox.jsx
Normal file
22
frontend/src/components/ui/checkbox.jsx
Normal 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 }
|
||||
9
frontend/src/components/ui/collapsible.jsx
Normal file
9
frontend/src/components/ui/collapsible.jsx
Normal 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 }
|
||||
116
frontend/src/components/ui/command.jsx
Normal file
116
frontend/src/components/ui/command.jsx
Normal 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,
|
||||
}
|
||||
156
frontend/src/components/ui/context-menu.jsx
Normal file
156
frontend/src/components/ui/context-menu.jsx
Normal 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,
|
||||
}
|
||||
94
frontend/src/components/ui/dialog.jsx
Normal file
94
frontend/src/components/ui/dialog.jsx
Normal 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,
|
||||
}
|
||||
90
frontend/src/components/ui/drawer.jsx
Normal file
90
frontend/src/components/ui/drawer.jsx
Normal 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,
|
||||
}
|
||||
156
frontend/src/components/ui/dropdown-menu.jsx
Normal file
156
frontend/src/components/ui/dropdown-menu.jsx
Normal 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,
|
||||
}
|
||||
133
frontend/src/components/ui/form.jsx
Normal file
133
frontend/src/components/ui/form.jsx
Normal 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,
|
||||
}
|
||||
23
frontend/src/components/ui/hover-card.jsx
Normal file
23
frontend/src/components/ui/hover-card.jsx
Normal 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 }
|
||||
53
frontend/src/components/ui/input-otp.jsx
Normal file
53
frontend/src/components/ui/input-otp.jsx
Normal 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 }
|
||||
19
frontend/src/components/ui/input.jsx
Normal file
19
frontend/src/components/ui/input.jsx
Normal 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 }
|
||||
16
frontend/src/components/ui/label.jsx
Normal file
16
frontend/src/components/ui/label.jsx
Normal 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 }
|
||||
198
frontend/src/components/ui/menubar.jsx
Normal file
198
frontend/src/components/ui/menubar.jsx
Normal 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,
|
||||
}
|
||||
104
frontend/src/components/ui/navigation-menu.jsx
Normal file
104
frontend/src/components/ui/navigation-menu.jsx
Normal 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,
|
||||
}
|
||||
100
frontend/src/components/ui/pagination.jsx
Normal file
100
frontend/src/components/ui/pagination.jsx
Normal 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,
|
||||
}
|
||||
27
frontend/src/components/ui/popover.jsx
Normal file
27
frontend/src/components/ui/popover.jsx
Normal 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 }
|
||||
21
frontend/src/components/ui/progress.jsx
Normal file
21
frontend/src/components/ui/progress.jsx
Normal 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 }
|
||||
29
frontend/src/components/ui/radio-group.jsx
Normal file
29
frontend/src/components/ui/radio-group.jsx
Normal 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 }
|
||||
40
frontend/src/components/ui/resizable.jsx
Normal file
40
frontend/src/components/ui/resizable.jsx
Normal 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 }
|
||||
38
frontend/src/components/ui/scroll-area.jsx
Normal file
38
frontend/src/components/ui/scroll-area.jsx
Normal 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 }
|
||||
119
frontend/src/components/ui/select.jsx
Normal file
119
frontend/src/components/ui/select.jsx
Normal 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,
|
||||
}
|
||||
23
frontend/src/components/ui/separator.jsx
Normal file
23
frontend/src/components/ui/separator.jsx
Normal 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 }
|
||||
108
frontend/src/components/ui/sheet.jsx
Normal file
108
frontend/src/components/ui/sheet.jsx
Normal 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,
|
||||
}
|
||||
14
frontend/src/components/ui/skeleton.jsx
Normal file
14
frontend/src/components/ui/skeleton.jsx
Normal 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 }
|
||||
21
frontend/src/components/ui/slider.jsx
Normal file
21
frontend/src/components/ui/slider.jsx
Normal 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 }
|
||||
28
frontend/src/components/ui/sonner.jsx
Normal file
28
frontend/src/components/ui/sonner.jsx
Normal 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 }
|
||||
22
frontend/src/components/ui/switch.jsx
Normal file
22
frontend/src/components/ui/switch.jsx
Normal 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 }
|
||||
86
frontend/src/components/ui/table.jsx
Normal file
86
frontend/src/components/ui/table.jsx
Normal 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,
|
||||
}
|
||||
41
frontend/src/components/ui/tabs.jsx
Normal file
41
frontend/src/components/ui/tabs.jsx
Normal 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 }
|
||||
18
frontend/src/components/ui/textarea.jsx
Normal file
18
frontend/src/components/ui/textarea.jsx
Normal 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 }
|
||||
85
frontend/src/components/ui/toast.jsx
Normal file
85
frontend/src/components/ui/toast.jsx
Normal 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 };
|
||||
33
frontend/src/components/ui/toaster.jsx
Normal file
33
frontend/src/components/ui/toaster.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/ui/toggle-group.jsx
Normal file
43
frontend/src/components/ui/toggle-group.jsx
Normal 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 }
|
||||
40
frontend/src/components/ui/toggle.jsx
Normal file
40
frontend/src/components/ui/toggle.jsx
Normal 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 }
|
||||
26
frontend/src/components/ui/tooltip.jsx
Normal file
26
frontend/src/components/ui/tooltip.jsx
Normal 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 }
|
||||
91
frontend/src/context/AuthContext.js
Normal file
91
frontend/src/context/AuthContext.js
Normal 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;
|
||||
};
|
||||
154
frontend/src/context/CartContext.js
Normal file
154
frontend/src/context/CartContext.js
Normal 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;
|
||||
};
|
||||
48
frontend/src/context/ThemeContext.js
Normal file
48
frontend/src/context/ThemeContext.js
Normal 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;
|
||||
};
|
||||
155
frontend/src/hooks/use-toast.js
Normal file
155
frontend/src/hooks/use-toast.js
Normal 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 }
|
||||
133
frontend/src/hooks/useAdminAPI.js
Normal file
133
frontend/src/hooks/useAdminAPI.js
Normal 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,
|
||||
};
|
||||
};
|
||||
55
frontend/src/hooks/useDialogState.js
Normal file
55
frontend/src/hooks/useDialogState.js
Normal 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
326
frontend/src/index.css
Normal 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
87
frontend/src/index.js
Normal 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>
|
||||
);
|
||||
6
frontend/src/lib/utils.js
Normal file
6
frontend/src/lib/utils.js
Normal 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
278
frontend/src/pages/About.js
Normal 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;
|
||||
3369
frontend/src/pages/AdminDashboard.js
Normal file
3369
frontend/src/pages/AdminDashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
409
frontend/src/pages/Cart.js
Normal file
409
frontend/src/pages/Cart.js
Normal 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;
|
||||
261
frontend/src/pages/Contact.js
Normal file
261
frontend/src/pages/Contact.js
Normal 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;
|
||||
107
frontend/src/pages/DebugServices.js
Normal file
107
frontend/src/pages/DebugServices.js
Normal 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
330
frontend/src/pages/Home.js
Normal 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
194
frontend/src/pages/Login.js
Normal 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;
|
||||
238
frontend/src/pages/OrderHistory.js
Normal file
238
frontend/src/pages/OrderHistory.js
Normal 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;
|
||||
436
frontend/src/pages/ProductDetail.js
Normal file
436
frontend/src/pages/ProductDetail.js
Normal 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;
|
||||
391
frontend/src/pages/Products.js
Normal file
391
frontend/src/pages/Products.js
Normal 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;
|
||||
174
frontend/src/pages/Profile.js
Normal file
174
frontend/src/pages/Profile.js
Normal 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;
|
||||
307
frontend/src/pages/ServiceDetail.js
Normal file
307
frontend/src/pages/ServiceDetail.js
Normal 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;
|
||||
255
frontend/src/pages/Services.js
Normal file
255
frontend/src/pages/Services.js
Normal 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;
|
||||
44
frontend/src/utils/apiCache.js
Normal file
44
frontend/src/utils/apiCache.js
Normal 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);
|
||||
};
|
||||
};
|
||||
82
frontend/tailwind.config.js
Normal file
82
frontend/tailwind.config.js
Normal 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
11744
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user