Overdue initial commit
This commit is contained in:
25
dashboard/.gitignore
vendored
Normal file
25
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
8
dashboard/README.md
Normal file
8
dashboard/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
497
dashboard/build/assets/index-ClSvHF_l.js
Normal file
497
dashboard/build/assets/index-ClSvHF_l.js
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/build/assets/index-ClSvHF_l.js.map
Normal file
1
dashboard/build/assets/index-ClSvHF_l.js.map
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/build/assets/index-T9y3LwG-.css
Normal file
1
dashboard/build/assets/index-T9y3LwG-.css
Normal file
File diff suppressed because one or more lines are too long
44
dashboard/build/assets/vendor-zehgRTIA.js
Normal file
44
dashboard/build/assets/vendor-zehgRTIA.js
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/build/assets/vendor-zehgRTIA.js.map
Normal file
1
dashboard/build/assets/vendor-zehgRTIA.js.map
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/build/dashboard.svg
Normal file
1
dashboard/build/dashboard.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 363 B |
15
dashboard/build/index.html
Normal file
15
dashboard/build/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/dashboard.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard</title>
|
||||
<script type="module" crossorigin src="/assets/index-ClSvHF_l.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-zehgRTIA.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-T9y3LwG-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
21
dashboard/components.json
Normal file
21
dashboard/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": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
38
dashboard/eslint.config.js
Normal file
38
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
dashboard/index.html
Normal file
13
dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/dashboard.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
dashboard/jsconfig.json
Normal file
8
dashboard/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
3093
dashboard/logdy-messages.json
Normal file
3093
dashboard/logdy-messages.json
Normal file
File diff suppressed because it is too large
Load Diff
7449
dashboard/package-lock.json
generated
Normal file
7449
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
dashboard/package.json
Normal file
60
dashboard/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && npm run copy-build",
|
||||
"copy-build": "rm -rf ../dashboard-server/frontend/build/* && cp -r build/* ../dashboard-server/frontend/build/",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.62.2",
|
||||
"@tanstack/react-virtual": "^3.11.1",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"express-session": "^1.18.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.465.0",
|
||||
"luxon": "^3.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.0.2",
|
||||
"react-virtualized-auto-sizer": "^1.0.24",
|
||||
"react-window": "^1.8.10",
|
||||
"recharts": "^2.14.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.js
Normal file
6
dashboard/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
dashboard/public/dashboard.svg
Normal file
1
dashboard/public/dashboard.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 363 B |
47
dashboard/src/App.css
Normal file
47
dashboard/src/App.css
Normal file
@@ -0,0 +1,47 @@
|
||||
#root {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
149
dashboard/src/App.jsx
Normal file
149
dashboard/src/App.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import LoginPage from "@/components/auth/LoginPage";
|
||||
import PinProtection from "@/components/auth/PinProtection";
|
||||
import ProtectedRoute from "@/components/auth/ProtectedRoute";
|
||||
import LockButton from "@/components/auth/LockButton";
|
||||
import Header from "@/components/dashboard/Header";
|
||||
import { ThemeProvider } from "@/components/theme/ThemeProvider";
|
||||
import Navigation from "@/components/dashboard/Navigation";
|
||||
import { ScrollProvider } from "@/contexts/ScrollContext";
|
||||
import DateTimeWeatherDisplay from "@/components/dashboard/DateTime";
|
||||
import AircallDashboard from "@/components/dashboard/AircallDashboard";
|
||||
import KlaviyoApiTest from "@/components/dashboard/KlaviyoApiTest";
|
||||
import EventFeed from "./components/dashboard/EventFeed";
|
||||
import StatCards from "./components/dashboard/StatCards";
|
||||
import ProductGrid from "./components/dashboard/ProductGrid";
|
||||
import SalesChart from "./components/dashboard/SalesChart";
|
||||
|
||||
// Public layout
|
||||
const PublicLayout = () => (
|
||||
<div className="min-h-screen w-screen">
|
||||
<div className="w-full">
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 text-left mb-5">
|
||||
Black Friday 2024
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Pin Protected Layout
|
||||
const PinProtectedLayout = ({ children }) => {
|
||||
const [isPinVerified, setIsPinVerified] = React.useState(() => {
|
||||
return sessionStorage.getItem("pinVerified") === "true";
|
||||
});
|
||||
|
||||
const handlePinSuccess = () => {
|
||||
setIsPinVerified(true);
|
||||
sessionStorage.setItem("pinVerified", "true");
|
||||
};
|
||||
|
||||
if (!isPinVerified) {
|
||||
return <PinProtection onSuccess={handlePinSuccess} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
// Small Layout
|
||||
const SmallLayout = () => {
|
||||
const SCALE_FACTOR = 2;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen overflow-hidden">
|
||||
<div className="flex">
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${SCALE_FACTOR})`,
|
||||
transformOrigin: "top left",
|
||||
padding: "1.5rem",
|
||||
marginBottom: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<span className="absolute top-0 left-0">
|
||||
<LockButton />
|
||||
</span>
|
||||
<DateTimeWeatherDisplay scaleFactor={SCALE_FACTOR} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main dashboard layout
|
||||
const DashboardLayout = () => {
|
||||
return (
|
||||
<ScrollProvider>
|
||||
<div className="min-h-screen max-w-[1800px] mx-auto">
|
||||
<div className="p-4">
|
||||
<Header />
|
||||
</div>
|
||||
<Navigation />
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-8">
|
||||
<div className="space-y-4 h-full w-full">
|
||||
<StatCards />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 h-[530px]">
|
||||
<div className="h-full">
|
||||
<div className="h-full"><EventFeed /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-4 h-[800px]">
|
||||
<ProductGrid />
|
||||
</div>
|
||||
<div className="col-span-8 h-full w-full flex">
|
||||
<SalesChart className="w-full h-full"/>
|
||||
</div>
|
||||
</div>
|
||||
<AircallDashboard />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/black-friday" element={<PublicLayout />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/small"
|
||||
element={
|
||||
<PinProtectedLayout>
|
||||
<SmallLayout />
|
||||
</PinProtectedLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
28
dashboard/src/components/auth/LockButton.jsx
Normal file
28
dashboard/src/components/auth/LockButton.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
const LockButton = () => {
|
||||
const handleLock = () => {
|
||||
// Remove PIN verification from session storage
|
||||
sessionStorage.removeItem('pinVerified');
|
||||
// Reset attempt count
|
||||
localStorage.removeItem('pinAttempts');
|
||||
localStorage.removeItem('lastAttemptTime');
|
||||
// Force reload to show PIN screen
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={handleLock}
|
||||
>
|
||||
<Lock className="h-5 w-5" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LockButton;
|
||||
141
dashboard/src/components/auth/LoginPage.jsx
Normal file
141
dashboard/src/components/auth/LoginPage.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
const LoginPage = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/check', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return; // Don't show a toast, just return silently
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent failure for auth check
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const loginResponse = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${loginResponse.status}`);
|
||||
}
|
||||
|
||||
const data = await loginResponse.json();
|
||||
|
||||
if (data.success) {
|
||||
// Wait for cookie to be set
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const authCheck = await fetch('/auth/check', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const authData = await authCheck.json();
|
||||
|
||||
if (authData.authenticated) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Login successful",
|
||||
});
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
throw new Error('Authentication failed after login');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Login failed. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen flex items-center justify-center bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Lock className="h-12 w-12 text-gray-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Dashboard Login</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your password to access the dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Authenticating..." : "Login"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
203
dashboard/src/components/auth/PinProtection.jsx
Normal file
203
dashboard/src/components/auth/PinProtection.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Lock, Delete } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
const PinProtection = ({ onSuccess }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [attempts, setAttempts] = useState(() => {
|
||||
return parseInt(localStorage.getItem('pinAttempts') || '0');
|
||||
});
|
||||
const [lockoutTime, setLockoutTime] = useState(() => {
|
||||
const lastAttempt = localStorage.getItem('lastAttemptTime');
|
||||
if (!lastAttempt) return 0;
|
||||
|
||||
const timeSinceLastAttempt = Date.now() - parseInt(lastAttempt);
|
||||
if (timeSinceLastAttempt < LOCKOUT_DURATION) {
|
||||
return LOCKOUT_DURATION - timeSinceLastAttempt;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (lockoutTime > 0) {
|
||||
timer = setInterval(() => {
|
||||
setLockoutTime(prev => {
|
||||
const newTime = prev - 1000;
|
||||
if (newTime <= 0) {
|
||||
localStorage.removeItem('pinAttempts');
|
||||
localStorage.removeItem('lastAttemptTime');
|
||||
return 0;
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [lockoutTime]);
|
||||
|
||||
const handleComplete = useCallback((value) => {
|
||||
if (lockoutTime > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newAttempts = attempts + 1;
|
||||
setAttempts(newAttempts);
|
||||
localStorage.setItem('pinAttempts', newAttempts.toString());
|
||||
localStorage.setItem('lastAttemptTime', Date.now().toString());
|
||||
|
||||
if (newAttempts >= MAX_ATTEMPTS) {
|
||||
setLockoutTime(LOCKOUT_DURATION);
|
||||
toast({
|
||||
title: "Too many attempts",
|
||||
description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`,
|
||||
variant: "destructive",
|
||||
});
|
||||
setPin("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === "123456") {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "PIN accepted",
|
||||
});
|
||||
// Reset attempts on success
|
||||
setAttempts(0);
|
||||
localStorage.removeItem('pinAttempts');
|
||||
localStorage.removeItem('lastAttemptTime');
|
||||
onSuccess();
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`,
|
||||
variant: "destructive",
|
||||
});
|
||||
setPin("");
|
||||
}
|
||||
}, [attempts, lockoutTime, onSuccess, toast]);
|
||||
|
||||
const handleKeyPress = (value) => {
|
||||
if (pin.length < 6) {
|
||||
const newPin = pin + value;
|
||||
setPin(newPin);
|
||||
if (newPin.length === 6) {
|
||||
handleComplete(newPin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setPin(prev => prev.slice(0, -1));
|
||||
};
|
||||
|
||||
const renderKeypad = () => {
|
||||
const keys = [
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
['clear', 0, 'delete']
|
||||
];
|
||||
|
||||
return keys.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex justify-center gap-4">
|
||||
{row.map((key, index) => {
|
||||
if (key === 'delete') {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
className="w-16 h-16 text-lg font-medium hover:bg-muted"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Delete className="h-6 w-6" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (key === 'clear') {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
className="w-16 h-16 text-lg font-medium hover:bg-muted"
|
||||
onClick={() => setPin("")}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
className="w-16 h-16 text-2xl font-medium hover:bg-muted"
|
||||
onClick={() => handleKeyPress(key.toString())}
|
||||
>
|
||||
{key}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// Create masked version of PIN
|
||||
const maskedPin = pin.replace(/./g, '•');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen flex items-center justify-center bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Lock className="h-12 w-12 text-gray-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Enter PIN</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{lockoutTime > 0 ? (
|
||||
`Too many attempts. Try again in ${Math.ceil(lockoutTime / 60000)} minutes`
|
||||
) : (
|
||||
"Enter your PIN to access the display"
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={maskedPin}
|
||||
disabled
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0,1,2,3,4,5].map((index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className="w-14 h-14 text-2xl border-2 rounded-lg"
|
||||
readOnly
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{renderKeypad()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PinProtection;
|
||||
83
dashboard/src/components/auth/ProtectedRoute.jsx
Normal file
83
dashboard/src/components/auth/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// ProtectedRoute.jsx
|
||||
import React, { useState, useEffect } from 'react'; // Add React and useState
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const location = useLocation();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/check', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
setIsAuthenticated(false);
|
||||
navigate('/login', { state: { from: location } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (mounted) {
|
||||
setIsAuthenticated(data.authenticated);
|
||||
|
||||
if (!data.authenticated) {
|
||||
navigate('/login', { state: { from: location } });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setIsAuthenticated(false);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Authentication failed. Please try in again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate('/login', { state: { from: location } });
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [location.pathname, toast, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : null;
|
||||
};
|
||||
|
||||
// Make sure to add this default export
|
||||
export default ProtectedRoute;
|
||||
43
dashboard/src/components/dashboard/AgentStatsCard.jsx
Normal file
43
dashboard/src/components/dashboard/AgentStatsCard.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// components/dashboard/AgentStatsCard.jsx
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
export const AgentStatsCard = ({ agent, formatDuration }) => {
|
||||
const answerRate = ((agent.answered / agent.total) * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">{agent.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Answer Rate</span>
|
||||
<span className="font-medium">{answerRate}%</span>
|
||||
</div>
|
||||
<Progress value={parseFloat(answerRate)} className="h-2" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Total Calls</p>
|
||||
<p className="text-sm font-medium">{agent.total}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Avg Duration</p>
|
||||
<p className="text-sm font-medium">
|
||||
{formatDuration(agent.average_duration)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Answered</p>
|
||||
<p className="text-sm font-medium">{agent.answered}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Missed</p>
|
||||
<p className="text-sm font-medium">{agent.missed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
676
dashboard/src/components/dashboard/AircallDashboard.jsx
Normal file
676
dashboard/src/components/dashboard/AircallDashboard.jsx
Normal file
@@ -0,0 +1,676 @@
|
||||
// components/AircallDashboard.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
PhoneCall,
|
||||
PhoneMissed,
|
||||
Clock,
|
||||
UserCheck,
|
||||
PhoneIncoming,
|
||||
PhoneOutgoing,
|
||||
ArrowUpDown,
|
||||
Timer,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
} from "recharts";
|
||||
import { AgentStatsCard } from "@/components/dashboard/AgentStatsCard";
|
||||
import { TableActions } from "@/components/dashboard/TableActions";
|
||||
|
||||
const COLORS = {
|
||||
inbound: "hsl(262.1 83.3% 57.8%)", // Purple
|
||||
outbound: "hsl(142.1 76.2% 36.3%)", // Green
|
||||
missed: "hsl(47.9 95.8% 53.1%)", // Yellow
|
||||
answered: "hsl(142.1 76.2% 36.3%)", // Green
|
||||
duration: "hsl(221.2 83.2% 53.3%)", // Blue
|
||||
};
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Yesterday", value: "yesterday" },
|
||||
{ label: "Last 7 Days", value: "last7days" },
|
||||
{ label: "Last 30 Days", value: "last30days" },
|
||||
{ label: "Last 90 Days", value: "last90days" },
|
||||
];
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const MetricCard = ({ title, value, subtitle, icon: Icon, iconColor }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">{title}</CardTitle>
|
||||
<Icon className={`h-4 w-4 ${iconColor}`} />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<p className="font-medium text-sm text-gray-900 dark:text-gray-100 border-b border-gray-100 dark:border-gray-800 pb-1 mb-2">{label}</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{`${entry.name}: ${entry.value}`}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const exportToCSV = (data, filename) => {
|
||||
const headers = [
|
||||
"Name",
|
||||
"Total Calls",
|
||||
"Answered",
|
||||
"Missed",
|
||||
"Answer Rate",
|
||||
"Avg Duration",
|
||||
];
|
||||
const rows = data.map((agent) => [
|
||||
agent.name,
|
||||
agent.total,
|
||||
agent.answered,
|
||||
agent.missed,
|
||||
`${((agent.answered / agent.total) * 100).toFixed(1)}%`,
|
||||
formatDuration(agent.average_duration),
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(","),
|
||||
...rows.map((row) => row.join(",")),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `${filename}.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const AgentPerformanceTable = ({ agents, onSort }) => {
|
||||
const [sortConfig, setSortConfig] = useState({
|
||||
key: "total",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const handleSort = (key) => {
|
||||
const direction =
|
||||
sortConfig.key === key && sortConfig.direction === "desc"
|
||||
? "asc"
|
||||
: "desc";
|
||||
setSortConfig({ key, direction });
|
||||
onSort(key, direction);
|
||||
};
|
||||
|
||||
const SortButton = ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSort(column)}
|
||||
className="flex items-center gap-1 hover:bg-transparent"
|
||||
>
|
||||
{column.charAt(0).toUpperCase() + column.slice(1)}
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>
|
||||
<SortButton column="total" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortButton column="answered" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortButton column="missed" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortButton column="average_duration" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agents.map((agent) => (
|
||||
<TableRow key={agent.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">{agent.name}</TableCell>
|
||||
<TableCell>{agent.total}</TableCell>
|
||||
<TableCell className="text-emerald-600 dark:text-emerald-400">{agent.answered}</TableCell>
|
||||
<TableCell className="text-rose-600 dark:text-rose-400">{agent.missed}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const AircallDashboard = () => {
|
||||
const [timeRange, setTimeRange] = useState("last7days");
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [agentSort, setAgentSort] = useState({
|
||||
key: "total",
|
||||
direction: "desc",
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [viewType, setViewType] = useState("table"); // 'table' or 'cards'
|
||||
|
||||
const safeArray = (arr) => (Array.isArray(arr) ? arr : []);
|
||||
const safeObject = (obj) => (obj && typeof obj === "object" ? obj : {});
|
||||
|
||||
const sortedAgents = metrics?.by_users
|
||||
? Object.values(metrics.by_users).sort((a, b) => {
|
||||
const multiplier = agentSort.direction === "desc" ? -1 : 1;
|
||||
return multiplier * (a[agentSort.key] - b[agentSort.key]);
|
||||
})
|
||||
: [];
|
||||
|
||||
const filteredAgents = sortedAgents.filter((agent) =>
|
||||
agent.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
try {
|
||||
// Parse the date string (YYYY-MM-DD)
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
|
||||
// Create a date object in ET timezone
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
// Format the date in ET timezone
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "America/New_York"
|
||||
}).format(date);
|
||||
} catch (error) {
|
||||
console.error("Date formatting error:", error, { dateString });
|
||||
return "Invalid Date";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleExport = () => {
|
||||
const timestamp = new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
|
||||
exportToCSV(filteredAgents, `aircall-agent-metrics-${timestamp}`);
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
hourly: metrics?.by_hour
|
||||
? metrics.by_hour.map((count, hour) => ({
|
||||
hour: `${hour.toString().padStart(2, "0")}:00`,
|
||||
calls: count || 0,
|
||||
}))
|
||||
: [],
|
||||
|
||||
missedReasons: metrics?.by_missed_reason
|
||||
? Object.entries(metrics.by_missed_reason).map(([reason, count]) => ({
|
||||
reason: (reason || "").replace(/_/g, " "),
|
||||
count: count || 0,
|
||||
}))
|
||||
: [],
|
||||
|
||||
daily: safeArray(metrics?.daily_data).map((day) => ({
|
||||
...day,
|
||||
inbound: day.inbound || 0,
|
||||
outbound: day.outbound || 0,
|
||||
date: day.date || "",
|
||||
})),
|
||||
};
|
||||
|
||||
const peakHour = metrics?.by_hour
|
||||
? metrics.by_hour.indexOf(Math.max(...metrics.by_hour))
|
||||
: null;
|
||||
|
||||
const busyAgent = sortedAgents?.length > 0 ? sortedAgents[0] : null;
|
||||
|
||||
const bestAnswerRate = sortedAgents
|
||||
?.filter((agent) => agent.total > 0)
|
||||
?.sort((a, b) => b.answered / b.total - a.answered / a.total)[0];
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/aircall/metrics/${timeRange}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch metrics");
|
||||
const data = await response.json();
|
||||
setMetrics(data);
|
||||
setLastUpdated(data._meta?.generatedAt);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, REFRESH_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [timeRange]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="m-4">
|
||||
<AlertDescription>Error loading call data: {error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">Aircall Analytics</CardTitle>
|
||||
{lastUpdated && (
|
||||
<CardDescription className="mt-1">
|
||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<SelectValue placeholder="Select range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0 space-y-6">
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{isLoading ? (
|
||||
[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||
))
|
||||
) : metrics ? (
|
||||
<>
|
||||
<MetricCard
|
||||
title="Total Calls"
|
||||
value={metrics.total}
|
||||
icon={PhoneCall}
|
||||
iconColor="text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Inbound"
|
||||
value={metrics.by_direction.inbound}
|
||||
subtitle={`${(
|
||||
(metrics.by_direction.inbound / metrics.total) *
|
||||
100
|
||||
).toFixed(1)}%`}
|
||||
icon={PhoneIncoming}
|
||||
iconColor="text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Outbound"
|
||||
value={metrics.by_direction.outbound}
|
||||
subtitle={`${(
|
||||
(metrics.by_direction.outbound / metrics.total) *
|
||||
100
|
||||
).toFixed(1)}%`}
|
||||
icon={PhoneOutgoing}
|
||||
iconColor="text-emerald-500 dark:text-emerald-400"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Missed"
|
||||
value={metrics.by_status.missed}
|
||||
subtitle={`${(
|
||||
(metrics.by_status.missed / metrics.total) *
|
||||
100
|
||||
).toFixed(1)}%`}
|
||||
icon={PhoneMissed}
|
||||
iconColor="text-rose-500 dark:text-rose-400"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Duration"
|
||||
value={formatDuration(metrics.average_duration)}
|
||||
icon={Clock}
|
||||
iconColor="text-purple-500 dark:text-purple-400"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Answer Rate"
|
||||
value={`${(
|
||||
(metrics.by_status.answered / metrics.total) *
|
||||
100
|
||||
).toFixed(1)}%`}
|
||||
icon={UserCheck}
|
||||
iconColor="text-emerald-500 dark:text-emerald-400"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-900 dark:text-gray-100">Performance Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Peak Hour</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{metrics?.by_hour.indexOf(Math.max(...metrics.by_hour))}:00
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Busiest Agent</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{sortedAgents[0]?.name || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Best Answer Rate</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{sortedAgents
|
||||
.filter((agent) => agent.total > 0)
|
||||
.sort(
|
||||
(a, b) => b.answered / b.total - a.answered / a.total
|
||||
)[0]?.name || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-900 dark:text-gray-100">Time Period</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Date Range</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{metrics?.daily_data?.length > 0 ? (
|
||||
<>
|
||||
{formatDate(metrics.daily_data[0]?.date)} -{" "}
|
||||
{formatDate(
|
||||
metrics.daily_data[metrics.daily_data.length - 1]?.date
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"No data available"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Avg Daily Calls</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{metrics?.daily_data?.length > 0
|
||||
? Math.round(metrics.total / metrics.daily_data.length)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Avg Duration</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{metrics?.average_duration
|
||||
? formatDuration(metrics.average_duration)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Daily Call Volume */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-900 dark:text-gray-100">Daily Call Volume</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData.daily}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-gray-700" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="inbound"
|
||||
stroke={COLORS.inbound}
|
||||
name="Inbound"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="outbound"
|
||||
stroke={COLORS.outbound}
|
||||
name="Outbound"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Duration Distribution */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-900 dark:text-gray-100">Call Duration Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={metrics?.duration_distribution || []}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-gray-700" />
|
||||
<XAxis
|
||||
dataKey="range"
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<YAxis className="text-gray-600 dark:text-gray-300" />
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" fill={COLORS.duration} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Missed Call Reasons */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-900 dark:text-gray-100">Missed Call Reasons</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData.missedReasons} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-gray-700" />
|
||||
<XAxis type="number" className="text-gray-600 dark:text-gray-300" />
|
||||
<YAxis
|
||||
dataKey="reason"
|
||||
type="category"
|
||||
width={150}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" fill={COLORS.missed} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Hourly Distribution */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-900 dark:text-gray-100">Hourly Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData.hourly}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-gray-700" />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={2}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="calls" fill={COLORS.inbound} name="Calls" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Agent Performance */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-sm font-medium text-gray-900 dark:text-gray-100">Agent Performance</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={viewType === "table" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setViewType("table")}
|
||||
>
|
||||
Table
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewType === "cards" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setViewType("cards")}
|
||||
>
|
||||
Cards
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TableActions onSearch={setSearchTerm} onExport={handleExport} />
|
||||
|
||||
{viewType === "table" ? (
|
||||
<AgentPerformanceTable
|
||||
agents={filteredAgents}
|
||||
onSort={(key, direction) => setAgentSort({ key, direction })}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentStatsCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AircallDashboard;
|
||||
448
dashboard/src/components/dashboard/DateTime.jsx
Normal file
448
dashboard/src/components/dashboard/DateTime.jsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Calendar as CalendarComponent } from '@/components/ui/calendaredit';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Sun,
|
||||
Cloud,
|
||||
CloudRain,
|
||||
CloudDrizzle,
|
||||
CloudSnow,
|
||||
CloudLightning,
|
||||
CloudFog,
|
||||
CloudSun,
|
||||
CircleAlert,
|
||||
Tornado,
|
||||
Haze,
|
||||
Moon,
|
||||
Wind,
|
||||
Droplets,
|
||||
ThermometerSun,
|
||||
ThermometerSnowflake,
|
||||
Sunrise,
|
||||
Sunset,
|
||||
AlertTriangle,
|
||||
Umbrella,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
const [datetime, setDatetime] = useState(new Date());
|
||||
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
|
||||
const [isTimeChanging, setIsTimeChanging] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [weather, setWeather] = useState(null);
|
||||
const [forecast, setForecast] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setMounted(true), 150);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const newDate = new Date();
|
||||
const newTime = getTimeComponents(newDate);
|
||||
|
||||
if (newTime.minutes !== prevTime.minutes) {
|
||||
setIsTimeChanging(true);
|
||||
setTimeout(() => setIsTimeChanging(false), 200);
|
||||
}
|
||||
|
||||
setPrevTime(newTime);
|
||||
setDatetime(newDate);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [prevTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWeatherData = async () => {
|
||||
try {
|
||||
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
|
||||
const [weatherResponse, forecastResponse] = await Promise.all([
|
||||
fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||
),
|
||||
fetch(
|
||||
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||
)
|
||||
]);
|
||||
|
||||
const weatherData = await weatherResponse.json();
|
||||
const forecastData = await forecastResponse.json();
|
||||
|
||||
setWeather(weatherData);
|
||||
|
||||
// Process forecast data to get daily forecasts with precipitation
|
||||
const dailyForecasts = forecastData.list.reduce((acc, item) => {
|
||||
const date = new Date(item.dt * 1000).toLocaleDateString();
|
||||
if (!acc[date]) {
|
||||
acc[date] = {
|
||||
...item,
|
||||
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
|
||||
pop: item.pop * 100 // Probability of precipitation as percentage
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setForecast(Object.values(dailyForecasts).slice(0, 5));
|
||||
} catch (error) {
|
||||
console.error("Error fetching weather:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeatherData();
|
||||
const weatherTimer = setInterval(fetchWeatherData, 300000);
|
||||
return () => clearInterval(weatherTimer);
|
||||
}, []);
|
||||
|
||||
function getTimeComponents(date) {
|
||||
let hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
return {
|
||||
hours: hours.toString(),
|
||||
minutes: minutes.toString().padStart(2, '0'),
|
||||
ampm
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return {
|
||||
weekday: date.toLocaleDateString('en-US', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString('en-US', { month: 'long' }),
|
||||
day: date.getDate()
|
||||
};
|
||||
};
|
||||
|
||||
const getWeatherIcon = (weatherCode, currentTime, small = false) => {
|
||||
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
|
||||
const code = parseInt(weatherCode, 10);
|
||||
const iconProps = small ? "w-8 h-8" : "w-12 h-12";
|
||||
switch (true) {
|
||||
case code >= 200 && code < 300:
|
||||
return <CloudLightning className={cn(iconProps, "text-gray-700")} />;
|
||||
case code >= 300 && code < 500:
|
||||
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />;
|
||||
case code >= 500 && code < 600:
|
||||
return <CloudRain className={cn(iconProps, "text-blue-600")} />;
|
||||
case code >= 600 && code < 700:
|
||||
return <CloudSnow className={cn(iconProps, "text-blue-400")} />;
|
||||
case code >= 700 && code < 721:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
||||
case code === 721:
|
||||
return <Haze className={cn(iconProps, "text-gray-700")} />;
|
||||
case code >= 722 && code < 781:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
||||
case code === 781:
|
||||
return <Tornado className={cn(iconProps, "text-gray-700")} />;
|
||||
case code === 800:
|
||||
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
|
||||
<Sun className={cn(iconProps, "text-yellow-700")} />
|
||||
) : (
|
||||
<Moon className={cn(iconProps, "text-gray-500")} />
|
||||
);
|
||||
case code >= 800 && code < 803:
|
||||
return <CloudSun className={cn(iconProps, "text-gray-600")} />;
|
||||
case code >= 803:
|
||||
return <Cloud className={cn(iconProps, "text-gray-600")} />;
|
||||
default:
|
||||
return <CircleAlert className={cn(iconProps, "text-red-700")} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getWeatherBackground = (weatherCode, isNight) => {
|
||||
const code = parseInt(weatherCode, 10);
|
||||
|
||||
// Thunderstorm (200-299)
|
||||
if (code >= 200 && code < 300) {
|
||||
return "bg-gradient-to-br from-slate-900 via-purple-900 to-slate-800";
|
||||
}
|
||||
|
||||
// Drizzle (300-399)
|
||||
if (code >= 300 && code < 400) {
|
||||
return "bg-gradient-to-br from-slate-700 via-blue-800 to-slate-700";
|
||||
}
|
||||
|
||||
// Rain (500-599)
|
||||
if (code >= 500 && code < 600) {
|
||||
return "bg-gradient-to-br from-slate-800 via-blue-900 to-slate-700";
|
||||
}
|
||||
|
||||
// Snow (600-699)
|
||||
if (code >= 600 && code < 700) {
|
||||
return "bg-gradient-to-br from-slate-200 via-blue-100 to-slate-100";
|
||||
}
|
||||
|
||||
// Atmosphere (700-799: mist, smoke, haze, fog, etc.)
|
||||
if (code >= 700 && code < 800) {
|
||||
return "bg-gradient-to-br from-slate-600 via-slate-500 to-slate-400";
|
||||
}
|
||||
|
||||
// Clear (800)
|
||||
if (code === 800) {
|
||||
if (isNight) {
|
||||
return "bg-gradient-to-br from-slate-900 via-blue-950 to-slate-800";
|
||||
}
|
||||
return "bg-gradient-to-br from-sky-400 via-blue-400 to-sky-500";
|
||||
}
|
||||
|
||||
// Clouds (801-804)
|
||||
if (code > 800) {
|
||||
if (isNight) {
|
||||
return "bg-gradient-to-br from-slate-800 via-slate-700 to-slate-600";
|
||||
}
|
||||
return "bg-gradient-to-br from-slate-400 via-slate-500 to-slate-400";
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return "bg-gradient-to-br from-slate-700 via-slate-600 to-slate-500";
|
||||
};
|
||||
|
||||
const getTemperatureColor = (weatherCode, isNight) => {
|
||||
const code = parseInt(weatherCode, 10);
|
||||
|
||||
// Use dark text for light backgrounds
|
||||
if (code >= 600 && code < 700) { // Snow
|
||||
return "text-slate-900";
|
||||
}
|
||||
|
||||
if (code === 800 && !isNight) { // Clear day
|
||||
return "text-slate-900";
|
||||
}
|
||||
|
||||
if (code > 800 && !isNight) { // Cloudy day
|
||||
return "text-slate-900";
|
||||
}
|
||||
|
||||
// Default to white text for all other (darker) backgrounds
|
||||
return "text-white";
|
||||
};
|
||||
|
||||
const { hours, minutes, ampm } = getTimeComponents(datetime);
|
||||
const dateInfo = formatDate(datetime);
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '--:--';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
const WeatherDetails = () => (
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThermometerSun className="w-5 h-5 text-orange-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">High</span>
|
||||
<span className="text-sm font-bold">{Math.round(weather.main.temp_max)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThermometerSnowflake className="w-5 h-5 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Low</span>
|
||||
<span className="text-sm font-bold">{Math.round(weather.main.temp_min)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="w-5 h-5 text-blue-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Humidity</span>
|
||||
<span className="text-sm font-bold">{weather.main.humidity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Wind className="w-5 h-5 text-gray-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Wind</span>
|
||||
<span className="text-sm font-bold">{Math.round(weather.wind.speed)} mph</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunrise className="w-5 h-5 text-yellow-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Sunrise</span>
|
||||
<span className="text-sm font-bold">{formatTime(weather.sys?.sunrise)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunset className="w-5 h-5 text-orange-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Sunset</span>
|
||||
<span className="text-sm font-bold">{formatTime(weather.sys?.sunset)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{forecast && (
|
||||
<div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{forecast.map((day, index) => (
|
||||
<Card key={index} className="p-2">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })}
|
||||
</span>
|
||||
{getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)}
|
||||
<div className="flex justify-center gap-1 items-baseline w-full">
|
||||
<span className="text-sm font-medium">
|
||||
{Math.round(day.main.temp_max)}°
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Math.round(day.main.temp_min)}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 w-full pt-1">
|
||||
{day.rain?.['3h'] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CloudRain className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-xs">{day.rain['3h'].toFixed(2)}"</span>
|
||||
</div>
|
||||
)}
|
||||
{day.snow?.['3h'] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CloudSnow className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-xs">{day.snow['3h'].toFixed(2)}"</span>
|
||||
</div>
|
||||
)}
|
||||
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Umbrella className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs">0"</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col space-y-2 items-center w-[250px] transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Time Display */}
|
||||
<Card className="bg-gradient-to-br from-slate-900 via-sky-800 to-cyan-800 dark:bg-slate-800 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-center items-baseline">
|
||||
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
||||
<span className="text-6xl font-bold text-white">{hours}</span>
|
||||
<span className="text-6xl font-bold text-white">:</span>
|
||||
<span className="text-6xl font-bold text-white">{minutes}</span>
|
||||
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Date and Weather Display */}
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<Card className="bg-gradient-to-br from-slate-900 via-violet-800 to-purple-800 aspect-square flex items-center justify-center">
|
||||
<CardContent className="h-full p-0">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span className="text-6xl font-bold text-white">
|
||||
{dateInfo.day}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-white/70 mt-2">
|
||||
{dateInfo.weekday}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{weather?.main && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Card className={cn(
|
||||
getWeatherBackground(
|
||||
weather.weather[0]?.id,
|
||||
datetime.getHours() >= 18 || datetime.getHours() < 6
|
||||
),
|
||||
"flex items-center justify-center aspect-square cursor-pointer hover:brightness-110 transition-all relative"
|
||||
)}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{getWeatherIcon(weather.weather[0]?.id, datetime)}
|
||||
<span className={cn(
|
||||
"text-2xl font-bold ml-1 mt-2",
|
||||
getTemperatureColor(
|
||||
weather.weather[0]?.id,
|
||||
datetime.getHours() >= 18 || datetime.getHours() < 6
|
||||
)
|
||||
)}>
|
||||
{Math.round(weather.main.temp)}°
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
{weather.alerts && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[450px]"
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
style={{
|
||||
transform: `scale(${scaleFactor})`,
|
||||
transformOrigin: 'left top'
|
||||
}}
|
||||
>
|
||||
{weather.alerts && (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<AlertDescription className="text-xs">
|
||||
{weather.alerts[0].event}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<WeatherDetails />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar Display */}
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-0">
|
||||
<CalendarComponent
|
||||
selected={datetime}
|
||||
className="w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimeWeatherDisplay;
|
||||
1246
dashboard/src/components/dashboard/EventFeed.jsx
Normal file
1246
dashboard/src/components/dashboard/EventFeed.jsx
Normal file
File diff suppressed because it is too large
Load Diff
197
dashboard/src/components/dashboard/Header.jsx
Normal file
197
dashboard/src/components/dashboard/Header.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Sun,
|
||||
Cloud,
|
||||
CloudRain,
|
||||
CloudDrizzle,
|
||||
CloudSnow,
|
||||
CloudLightning,
|
||||
CloudFog,
|
||||
CloudSun,
|
||||
CircleAlert,
|
||||
Tornado,
|
||||
Haze,
|
||||
Moon,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import { useScroll } from "@/contexts/ScrollContext";
|
||||
import { useTheme } from "@/components/theme/ThemeProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const CraftsIcon = () => (
|
||||
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
|
||||
<path
|
||||
fill="white"
|
||||
d="M911.230469 1807.75C974.730469 1695.5 849.919922 1700.659912 783.610352 1791.25C645.830078 1979.439941 874.950195 2120.310059 1112.429688 2058.800049C1201.44043 2035.72998 1278.759766 2003.080078 1344.580078 1964.159912C1385.389648 1940.040039 1380.900391 1926.060059 1344.580078 1935.139893C1294.040039 1947.800049 1261.69043 1953.73999 1177.700195 1966.97998C1084.719727 1981.669922 832.790039 1984.22998 911.230469 1807.75M1046.799805 1631.389893C1135.280273 1670.419922 1139.650391 1624.129883 1056.980469 1562.070068C925.150391 1463.110107 787.360352 1446.379883 661.950195 1478.280029C265.379883 1579.179932 67.740234 2077.050049 144.099609 2448.399902C357.860352 3487.689941 1934.570313 3457.959961 2143.030273 2467.540039C2204.700195 2174.439941 2141.950195 1852.780029 1917.990234 1665.149902C1773.219727 1543.870117 1575.009766 1536.659912 1403.599609 1591.72998C1380.639648 1599.110107 1381.410156 1616.610107 1403.599609 1612.379883C1571.25 1596.040039 1750.790039 1606 1856.75 1745.280029C2038.769531 1984.459961 2052.570313 2274.080078 1974.629883 2511.209961C1739.610352 3226.25 640.719727 3226.540039 401.719727 2479.26001C308.040039 2186.350098 400.299805 1788.800049 690 1639.100098C785.830078 1589.590088 907.040039 1569.709961 1046.799805 1631.389893Z"
|
||||
/>
|
||||
<path
|
||||
fill="white"
|
||||
d="M1270.089844 1727.72998C1292.240234 1431.47998 1284.94043 952.430176 1257.849609 717.390137C1235.679688 525.310059 1166.200195 416.189941 1093.629883 349.390137C1157.620117 313.180176 1354.129883 485.680176 1447.830078 603.350098C1790.870117 1034.100098 2235.580078 915.060059 2523.480469 721.129883C2569.120117 680.51001 2592.900391 654.030029 2523.480469 651.339844C2260.400391 615.330078 2115 463.060059 1947.530273 293.890137C1672.870117 16.459961 1143.719727 162.169922 1033.969727 303.040039C999.339844 280.299805 966.849609 265 941.709961 252.419922C787.139648 175.160156 670.049805 223.580078 871.780273 341.569824C962.599609 394.689941 1089.849609 483.48999 1168.230469 799.589844C1222.370117 1018.040039 1230.009766 1423.919922 1242.360352 1728.379883C1247 1761.850098 1264.799805 1759.629883 1270.089844 1727.72998"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Header = () => {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [weather, setWeather] = useState(null);
|
||||
const { isStuck } = useScroll();
|
||||
const { theme, systemTheme, toggleTheme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
|
||||
const response = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||
);
|
||||
const data = await response.json();
|
||||
setWeather(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching weather:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeather();
|
||||
const weatherTimer = setInterval(fetchWeather, 300000);
|
||||
return () => clearInterval(weatherTimer);
|
||||
}, []);
|
||||
|
||||
const getWeatherIcon = (weatherCode, currentTime) => {
|
||||
if (!weatherCode) return <CircleAlert className="w-6 h-6 text-red-500" />;
|
||||
|
||||
const code = parseInt(weatherCode, 10);
|
||||
switch (true) {
|
||||
case code >= 200 && code < 300:
|
||||
return <CloudLightning className="w-7 h-7 text-gray-500" />;
|
||||
case code >= 300 && code < 500:
|
||||
return <CloudDrizzle className="w-7 h-7 text-blue-400" />;
|
||||
case code >= 500 && code < 600:
|
||||
return <CloudRain className="w-7 h-7 text-blue-400" />;
|
||||
case code >= 600 && code < 700:
|
||||
return <CloudSnow className="w-7 h-7 text-blue-200" />;
|
||||
case code >= 700 && code < 721:
|
||||
return <CloudFog className="w-7 h-7 text-gray-400" />;
|
||||
case code === 721:
|
||||
return <Haze className="w-7 h-7 text-gray-500" />;
|
||||
case code >= 722 && code < 781:
|
||||
return <CloudFog className="w-7 h-7 text-gray-400" />;
|
||||
case code === 781:
|
||||
return <Tornado className="w-7 h-7 text-gray-500" />;
|
||||
case code === 800:
|
||||
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
|
||||
<Sun className="w-7 h-7 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-7 h-7 text-gray-300" />
|
||||
);
|
||||
case code >= 800 && code < 803:
|
||||
return <CloudSun className="w-7 h-7 text-gray-400" />;
|
||||
case code >= 803:
|
||||
return <Cloud className="w-7 h-7 text-gray-400" />;
|
||||
default:
|
||||
return <CircleAlert className="w-6 h-6 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date) => {
|
||||
const hours = date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
const period = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes}:${seconds} ${period}`;
|
||||
};
|
||||
|
||||
const formatDate = (date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-white dark:bg-gray-900 shadow-sm",
|
||||
isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col justify-between lg:flex-row items-left sm:items-center flex-wrap">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex space-x-2">
|
||||
<div
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"bg-gradient-to-r from-blue-500 to-blue-600 p-3 rounded-lg shadow-md cursor-pointer hover:opacity-90 transition-opacity",
|
||||
theme === "light" && "ring-1 ring-yellow-300",
|
||||
theme === "dark" && "ring-1 ring-purple-300",
|
||||
"ring-offset-2 ring-offset-white dark:ring-offset-gray-900"
|
||||
)}
|
||||
>
|
||||
<CraftsIcon />
|
||||
</div>{" "}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-400 bg-clip-text text-transparent">
|
||||
Store Status
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-left sm:items-center justify-start flex-wrap mt-2 sm:mt-0">
|
||||
{weather?.main && (
|
||||
<>
|
||||
<div className="flex-col items-center text-center">
|
||||
<div className="items-center justify-center space-x-2 rounded-lg px-4 hidden sm:flex">
|
||||
{getWeatherIcon(weather.weather[0]?.id, currentTime)}
|
||||
<div>
|
||||
<p className="text-xl font-bold tracking-tight dark:text-gray-100">
|
||||
{Math.round(weather.main.temp)}° F
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
|
||||
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
|
||||
<Calendar className="w-5 h-5 text-green-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm sm:text-xl font-bold tracking-tight p-0 dark:text-gray-100">
|
||||
{formatDate(currentTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
|
||||
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
|
||||
<Clock className="w-5 h-5 text-blue-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-md sm:text-xl font-bold tracking-tight tabular-nums dark:text-gray-100 mr-2">
|
||||
{formatTime(currentTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
862
dashboard/src/components/dashboard/KlaviyoApiTest.jsx
Normal file
862
dashboard/src/components/dashboard/KlaviyoApiTest.jsx
Normal file
@@ -0,0 +1,862 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'yesterday', label: 'Yesterday' },
|
||||
{ value: 'thisWeek', label: 'This Week' },
|
||||
{ value: 'lastWeek', label: 'Last Week' },
|
||||
{ value: 'thisMonth', label: 'This Month' },
|
||||
{ value: 'lastMonth', label: 'Last Month' }
|
||||
];
|
||||
|
||||
const INTERVALS = [
|
||||
{ value: 'hour', label: 'Hourly' },
|
||||
{ value: 'day', label: 'Daily' },
|
||||
{ value: 'week', label: 'Weekly' },
|
||||
{ value: 'month', label: 'Monthly' }
|
||||
];
|
||||
|
||||
const METRIC_IDS = {
|
||||
PLACED_ORDER: 'Y8cqcF',
|
||||
SHIPPED_ORDER: 'VExpdL',
|
||||
ACCOUNT_CREATED: 'TeeypV',
|
||||
CANCELED_ORDER: 'YjVMNg',
|
||||
NEW_BLOG_POST: 'YcxeDr',
|
||||
PAYMENT_REFUNDED: 'R7XUYh'
|
||||
};
|
||||
|
||||
const KlaviyoApiTest = () => {
|
||||
// State
|
||||
const [loading, setLoading] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [activeEndpoint, setActiveEndpoint] = useState('metrics');
|
||||
|
||||
// Shared state
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState('today');
|
||||
const [selectedInterval, setSelectedInterval] = useState('hour');
|
||||
const [customDateRange, setCustomDateRange] = useState({
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
// Metrics endpoint state
|
||||
const [metrics, setMetrics] = useState([]);
|
||||
const [selectedMetric, setSelectedMetric] = useState('');
|
||||
|
||||
// Events endpoint state
|
||||
const [eventResults, setEventResults] = useState(null);
|
||||
const [eventStats, setEventStats] = useState(null);
|
||||
|
||||
// Aggregation endpoint state
|
||||
const [aggregatedData, setAggregatedData] = useState([]);
|
||||
|
||||
// Stats endpoint state
|
||||
const [periodStats, setPeriodStats] = useState(null);
|
||||
|
||||
// Products endpoint state
|
||||
const [productStats, setProductStats] = useState(null);
|
||||
|
||||
// Feed endpoint state
|
||||
const [feedEvents, setFeedEvents] = useState([]);
|
||||
const [selectedMetrics, setSelectedMetrics] = useState([METRIC_IDS.PLACED_ORDER]);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultDates();
|
||||
}, []);
|
||||
|
||||
const setDefaultDates = () => {
|
||||
const now = DateTime.now().setZone('America/New_York');
|
||||
const threeDaysAgo = now.minus({ days: 3 });
|
||||
|
||||
setCustomDateRange({
|
||||
startDate: threeDaysAgo.toFormat("yyyy-MM-dd'T'HH:mm"),
|
||||
endDate: now.toFormat("yyyy-MM-dd'T'HH:mm")
|
||||
});
|
||||
};
|
||||
|
||||
// Shared Components
|
||||
const renderTimeRangeControls = ({ showInterval = false }) => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Time Range</h3>
|
||||
<div className={`grid ${showInterval ? 'grid-cols-2' : 'grid-cols-1'} gap-4`}>
|
||||
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Custom Range</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showInterval && (
|
||||
<Select value={selectedInterval} onValueChange={setSelectedInterval}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INTERVALS.map((interval) => (
|
||||
<SelectItem key={interval.value} value={interval.value}>
|
||||
{interval.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedTimeRange === 'custom' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start Date (Eastern Time)</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="datetime-local"
|
||||
value={customDateRange.startDate}
|
||||
onChange={(e) => setCustomDateRange(prev => ({
|
||||
...prev,
|
||||
startDate: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDate">End Date (Eastern Time)</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
type="datetime-local"
|
||||
value={customDateRange.endDate}
|
||||
onChange={(e) => setCustomDateRange(prev => ({
|
||||
...prev,
|
||||
endDate: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMetricSelector = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Select Metric</h3>
|
||||
</div>
|
||||
<Select value={selectedMetric} onValueChange={setSelectedMetric}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a metric" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{metrics.map((metric) => (
|
||||
<SelectItem key={metric.id} value={metric.id}>
|
||||
{metric.attributes?.name || metric.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// API Endpoint Functions
|
||||
const fetchMetrics = async () => {
|
||||
setLoading(prev => ({ ...prev, metrics: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const response = await axios.get('/api/klaviyo/metrics');
|
||||
setMetrics(response.data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching metrics:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, metrics: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEventsByTimeRange = async () => {
|
||||
if (!selectedMetric) return;
|
||||
setLoading(prev => ({ ...prev, events: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const response = await axios.get(`/api/klaviyo/events/by-time/${selectedTimeRange}`, {
|
||||
params: {
|
||||
metricId: selectedMetric,
|
||||
includeStats: true,
|
||||
startDate: customDateRange.startDate,
|
||||
endDate: customDateRange.endDate
|
||||
}
|
||||
});
|
||||
setEventResults(response.data);
|
||||
setEventStats(response.data.stats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, events: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAggregatedEvents = async () => {
|
||||
if (!selectedMetric) return;
|
||||
setLoading(prev => ({ ...prev, aggregation: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const params = selectedTimeRange === 'custom'
|
||||
? { startDate: customDateRange.startDate, endDate: customDateRange.endDate }
|
||||
: { timeRange: selectedTimeRange };
|
||||
|
||||
const response = await axios.get('/api/klaviyo/events/aggregate', {
|
||||
params: {
|
||||
...params,
|
||||
metricId: selectedMetric,
|
||||
interval: selectedInterval
|
||||
}
|
||||
});
|
||||
setAggregatedData(response.data.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching aggregated events:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, aggregation: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPeriodStats = async () => {
|
||||
setLoading(prev => ({ ...prev, stats: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const params = selectedTimeRange === 'custom'
|
||||
? { startDate: customDateRange.startDate, endDate: customDateRange.endDate }
|
||||
: { timeRange: selectedTimeRange };
|
||||
|
||||
const response = await axios.get('/api/klaviyo/events/stats', { params });
|
||||
setPeriodStats(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching period stats:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEventFeed = async () => {
|
||||
setLoading(prev => ({ ...prev, feed: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const params = selectedTimeRange === 'custom'
|
||||
? { startDate: customDateRange.startDate, endDate: customDateRange.endDate }
|
||||
: { timeRange: selectedTimeRange };
|
||||
|
||||
const response = await axios.get('/api/klaviyo/events/feed', {
|
||||
params: {
|
||||
...params,
|
||||
metricIds: JSON.stringify(selectedMetrics)
|
||||
}
|
||||
});
|
||||
setFeedEvents(response.data.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching event feed:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, feed: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProductStats = async () => {
|
||||
setLoading(prev => ({ ...prev, products: true }));
|
||||
setError(null);
|
||||
try {
|
||||
const params = selectedTimeRange === 'custom'
|
||||
? { startDate: customDateRange.startDate, endDate: customDateRange.endDate }
|
||||
: { timeRange: selectedTimeRange };
|
||||
|
||||
const response = await axios.get('/api/klaviyo/events/products', { params });
|
||||
setProductStats(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching product stats:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Endpoint Content Components
|
||||
const renderMetricsEndpoint = () => (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={fetchMetrics}
|
||||
disabled={loading.metrics}
|
||||
className="w-full"
|
||||
>
|
||||
{loading.metrics && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Fetch Available Metrics
|
||||
</Button>
|
||||
|
||||
{metrics.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.id} className="p-2 border rounded">
|
||||
<p className="font-semibold">{metric.attributes?.name || 'Unnamed Metric'}</p>
|
||||
<p className="text-sm text-gray-500">ID: {metric.id}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEventsEndpoint = () => (
|
||||
<div className="space-y-4">
|
||||
{renderMetricSelector()}
|
||||
{renderTimeRangeControls({ showInterval: false })}
|
||||
|
||||
<Button
|
||||
onClick={fetchEventsByTimeRange}
|
||||
disabled={loading.events || !selectedMetric}
|
||||
className="w-full"
|
||||
>
|
||||
{loading.events && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Fetch Events
|
||||
</Button>
|
||||
|
||||
{eventStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Statistics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">Total Events</p>
|
||||
<p className="text-2xl">{eventStats.total}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Time Range</p>
|
||||
<p className="text-sm">
|
||||
{eventStats.timeRange?.displayStart} to {eventStats.timeRange?.displayEnd}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Average Per Day</p>
|
||||
<p className="text-2xl">{eventStats.averagePerDay?.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{eventResults && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Raw Event Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="whitespace-pre-wrap overflow-auto max-h-96">
|
||||
{JSON.stringify(eventResults, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAggregateEndpoint = () => (
|
||||
<div className="space-y-4">
|
||||
{renderMetricSelector()}
|
||||
{renderTimeRangeControls({ showInterval: true })}
|
||||
|
||||
<Button
|
||||
onClick={fetchAggregatedEvents}
|
||||
disabled={loading.aggregation || !selectedMetric}
|
||||
className="w-full"
|
||||
>
|
||||
{loading.aggregation && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Fetch Aggregated Data
|
||||
</Button>
|
||||
|
||||
{aggregatedData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aggregated Events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={aggregatedData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="displayTime"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStatsEndpoint = () => (
|
||||
<div className="space-y-4">
|
||||
{renderTimeRangeControls({ showInterval: false })}
|
||||
|
||||
<Button
|
||||
onClick={fetchPeriodStats}
|
||||
disabled={loading.stats}
|
||||
className="w-full"
|
||||
>
|
||||
{loading.stats && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Fetch Period Statistics
|
||||
</Button>
|
||||
|
||||
{periodStats && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">Total Revenue</p>
|
||||
<p className="text-2xl">${periodStats.stats.revenue?.toFixed(2) || '0.00'}</p>
|
||||
{periodStats.stats.prevPeriodRevenue && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Previous: ${periodStats.stats.prevPeriodRevenue.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Order Count</p>
|
||||
<p className="text-2xl">{periodStats.stats.orderCount || 0}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Items: {periodStats.stats.itemCount || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Average Order Value</p>
|
||||
<p className="text-2xl">${periodStats.stats.averageOrderValue?.toFixed(2) || '0.00'}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Items per order: {periodStats.stats.averageItemsPerOrder?.toFixed(1) || '0.0'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Order Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(periodStats.stats.orderTypes || {}).map(([type, data]) => (
|
||||
<div key={type} className="flex justify-between">
|
||||
<span className="capitalize">{type.replace(/([A-Z])/g, ' $1').trim()}</span>
|
||||
<span>{data.count} ({data.percentage?.toFixed(1)}%)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Order Value Range</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{periodStats.stats.orderValueRange && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>Largest Order</span>
|
||||
<span>${periodStats.stats.orderValueRange.largest?.toFixed(2)} (#{periodStats.stats.orderValueRange.largestOrderId})</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Smallest Order</span>
|
||||
<span>${periodStats.stats.orderValueRange.smallest?.toFixed(2)} (#{periodStats.stats.orderValueRange.smallestOrderId})</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Products ({periodStats.stats.products?.total || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-60 overflow-auto space-y-2">
|
||||
{periodStats.stats.products?.list?.map(product => (
|
||||
<div key={product.id} className="flex justify-between py-1 border-b">
|
||||
<span className="truncate flex-1 mr-4">{product.name}</span>
|
||||
<span className="whitespace-nowrap">{product.count} sold</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Categories ({periodStats.stats.categories?.total || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-60 overflow-auto space-y-2">
|
||||
{periodStats.stats.categories?.list?.map(category => (
|
||||
<div key={category.name} className="flex justify-between py-1 border-b">
|
||||
<span className="truncate flex-1 mr-4">{category.name}</span>
|
||||
<span className="whitespace-nowrap">{category.count} items</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Shipping</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-2">Shipped orders: {periodStats.stats.shipping?.shippedCount || 0}</p>
|
||||
<div className="max-h-60 overflow-auto space-y-2">
|
||||
{periodStats.stats.shipping?.locations?.map(location => (
|
||||
<div key={location.name} className="flex justify-between py-1 border-b">
|
||||
<span className="truncate flex-1 mr-4">{location.name}</span>
|
||||
<span className="whitespace-nowrap">{location.count} orders</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Peak Times</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{periodStats.stats.peakOrderHour && (
|
||||
<div className="flex justify-between">
|
||||
<span>Peak Hour</span>
|
||||
<span>{periodStats.stats.peakOrderHour.displayHour} ({periodStats.stats.peakOrderHour.count} orders)</span>
|
||||
</div>
|
||||
)}
|
||||
{periodStats.stats.bestRevenueDay && (
|
||||
<div className="flex justify-between">
|
||||
<span>Best Day</span>
|
||||
<span>{periodStats.stats.bestRevenueDay.displayDate} (${periodStats.stats.bestRevenueDay.amount?.toFixed(2)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Refunds</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Count</span>
|
||||
<span>{periodStats.stats.refunds?.count || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Total Amount</span>
|
||||
<span>${periodStats.stats.refunds?.total?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Canceled Orders</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Count</span>
|
||||
<span>{periodStats.stats.canceledOrders?.count || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Total Amount</span>
|
||||
<span>${periodStats.stats.canceledOrders?.total?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderProductsEndpoint = () => (
|
||||
<div className="space-y-4">
|
||||
{renderTimeRangeControls({ showInterval: false })}
|
||||
|
||||
<Button
|
||||
onClick={fetchProductStats}
|
||||
disabled={loading.products}
|
||||
className="w-full"
|
||||
>
|
||||
{loading.products && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Fetch Product Statistics
|
||||
</Button>
|
||||
|
||||
{productStats && (
|
||||
<div className="space-y-4">
|
||||
{/* Products */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Products ({productStats.stats.products?.total || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-96 overflow-auto space-y-2">
|
||||
{productStats.stats.products?.list?.map(product => (
|
||||
<div key={product.id} className="p-2 border rounded">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{product.imageUrl && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={product.imageUrl}
|
||||
alt={product.name}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
onError={(e) => e.target.style.display = 'none'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">{product.name}</p>
|
||||
<p className="text-sm text-gray-500">SKU: {product.sku}</p>
|
||||
<p className="text-sm text-gray-500">Brand: {product.brand}</p>
|
||||
<p className="text-sm text-gray-500">Price: ${product.price.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">${product.totalRevenue.toFixed(2)}</p>
|
||||
<p className="text-sm font-medium">{product.totalQuantity} units sold</p>
|
||||
<p className="text-sm text-gray-500">{product.orderCount} orders</p>
|
||||
<p className="text-sm text-gray-500">Avg: ${product.averageOrderValue.toFixed(2)}/order</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Brands */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Brands ({productStats.stats.brands?.total || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-60 overflow-auto space-y-2">
|
||||
{productStats.stats.brands?.list?.map(brand => (
|
||||
<div key={brand.name} className="flex justify-between py-1 border-b">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">{brand.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{brand.productCount} products, {brand.orderCount} orders
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">{brand.totalQuantity} units sold</p>
|
||||
<p className="text-sm text-gray-500">${brand.totalRevenue.toFixed(2)} revenue</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Categories */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Categories ({productStats.stats.categories?.total || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-60 overflow-auto space-y-2">
|
||||
{productStats.stats.categories?.list?.map(category => (
|
||||
<div key={category.name} className="flex justify-between py-1 border-b">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">{category.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{category.productCount} products, {category.orderCount} orders
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">{category.totalQuantity} units sold</p>
|
||||
<p className="text-sm text-gray-500">${category.totalRevenue.toFixed(2)} revenue</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFeedEndpoint = () => (
|
||||
<div className="space-y-4">
|
||||
{renderTimeRangeControls({ showInterval: false })}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Select Event Types</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(METRIC_IDS).map(([key, value]) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={selectedMetrics.includes(value) ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setSelectedMetrics(prev =>
|
||||
prev.includes(value)
|
||||
? prev.filter(id => id !== value)
|
||||
: [...prev, value]
|
||||
);
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={fetchEventFeed}
|
||||
disabled={loading.feed || selectedMetrics.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{loading.feed && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Fetch Event Feed
|
||||
</Button>
|
||||
|
||||
{feedEvents.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{feedEvents.map(event => (
|
||||
<Card key={event.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{event.type}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="whitespace-pre-wrap overflow-auto max-h-96">
|
||||
{JSON.stringify(event.attributes, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Klaviyo API Endpoints</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={activeEndpoint} onValueChange={setActiveEndpoint}>
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="metrics">Metrics</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
<TabsTrigger value="aggregate">Aggregate</TabsTrigger>
|
||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||
<TabsTrigger value="products">Products</TabsTrigger>
|
||||
<TabsTrigger value="feed">Feed</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="metrics">
|
||||
{renderMetricsEndpoint()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events">
|
||||
{renderEventsEndpoint()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="aggregate">
|
||||
{renderAggregateEndpoint()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stats">
|
||||
{renderStatsEndpoint()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="products">
|
||||
{renderProductsEndpoint()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feed">
|
||||
{renderFeedEndpoint()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KlaviyoApiTest;
|
||||
2064
dashboard/src/components/dashboard/KlaviyoStats.jsx
Normal file
2064
dashboard/src/components/dashboard/KlaviyoStats.jsx
Normal file
File diff suppressed because it is too large
Load Diff
250
dashboard/src/components/dashboard/Navigation.jsx
Normal file
250
dashboard/src/components/dashboard/Navigation.jsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScroll } from "@/contexts/ScrollContext";
|
||||
import { ArrowUpToLine } from "lucide-react";
|
||||
|
||||
const Navigation = () => {
|
||||
const [activeSections, setActiveSections] = useState([]);
|
||||
const { isStuck } = useScroll();
|
||||
const buttonRefs = useRef({});
|
||||
const scrollContainerRef = useRef(null);
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
||||
const lastScrollLeft = useRef(0);
|
||||
const lastScrollTop = useRef(
|
||||
window.pageYOffset || document.documentElement.scrollTop
|
||||
);
|
||||
|
||||
// Define base sections that are always visible
|
||||
const baseSections = [
|
||||
{ id: "stats", label: "Statistics" },
|
||||
{
|
||||
id: "realtime",
|
||||
label: "Realtime",
|
||||
responsiveIds: ["realtime-lg", "realtime-md"],
|
||||
order: { md: 2, default: 1 },
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
label: "Top Products",
|
||||
responsiveIds: ["products-lg", "products-md"],
|
||||
order: { md: 1, default: 2 },
|
||||
},
|
||||
{ id: "feed", label: "Activity Feed" },
|
||||
{ id: "sales", label: "Sales Metrics" },
|
||||
{ id: "campaigns", label: "Campaigns" },
|
||||
{ id: "meta", label: "Meta Ads" },
|
||||
{ id: "analytics", label: "Analytics" },
|
||||
{ id: "behavior", label: "User Behavior" },
|
||||
{ id: "gorgias", label: "Customer Service" },
|
||||
{ id: "calls", label: "Calls" },
|
||||
];
|
||||
|
||||
const sortSections = (sections) => {
|
||||
const isMediumScreen = window.matchMedia(
|
||||
"(min-width: 768px) and (max-width: 1023px)"
|
||||
).matches;
|
||||
|
||||
return [...sections].sort((a, b) => {
|
||||
const aOrder = a.order
|
||||
? isMediumScreen
|
||||
? a.order.md
|
||||
: a.order.default
|
||||
: 0;
|
||||
const bOrder = b.order
|
||||
? isMediumScreen
|
||||
? b.order.md
|
||||
: b.order.default
|
||||
: 0;
|
||||
|
||||
if (aOrder && bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const sections = sortSections(baseSections);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToSection = (sectionId, responsiveIds) => {
|
||||
if (responsiveIds) {
|
||||
const visibleId = responsiveIds.find((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== "none";
|
||||
});
|
||||
|
||||
if (visibleId) {
|
||||
sectionId = visibleId;
|
||||
}
|
||||
}
|
||||
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
const offset = element.offsetTop - 80;
|
||||
window.scrollTo({
|
||||
top: offset,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Track horizontal scroll position changes
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleButtonBarScroll = () => {
|
||||
if (Math.abs(container.scrollLeft - lastScrollLeft.current) > 5) {
|
||||
setShouldAutoScroll(false);
|
||||
}
|
||||
lastScrollLeft.current = container.scrollLeft;
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleButtonBarScroll);
|
||||
return () => container.removeEventListener("scroll", handleButtonBarScroll);
|
||||
}, []);
|
||||
|
||||
// Handle page scroll and active sections
|
||||
useEffect(() => {
|
||||
const handlePageScroll = () => {
|
||||
const scrollTop =
|
||||
window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
if (Math.abs(scrollTop - lastScrollTop.current) > 5) {
|
||||
setShouldAutoScroll(true);
|
||||
lastScrollTop.current = scrollTop;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIds = [];
|
||||
const viewportHeight = window.innerHeight;
|
||||
const threshold = viewportHeight * 0.5;
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (section.responsiveIds) {
|
||||
const visibleId = section.responsiveIds.find((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.display === "none") return false;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top < viewportHeight - threshold && rect.bottom > threshold
|
||||
);
|
||||
});
|
||||
|
||||
if (visibleId) {
|
||||
activeIds.push(section.id);
|
||||
}
|
||||
} else {
|
||||
const element = document.getElementById(section.id);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
rect.top < viewportHeight - threshold &&
|
||||
rect.bottom > threshold
|
||||
) {
|
||||
activeIds.push(section.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setActiveSections(activeIds);
|
||||
|
||||
if (shouldAutoScroll && activeIds.length > 0) {
|
||||
const firstActiveButton = buttonRefs.current[activeIds[0]];
|
||||
if (firstActiveButton && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left:
|
||||
firstActiveButton.offsetLeft -
|
||||
scrollContainerRef.current.offsetWidth / 2 +
|
||||
firstActiveButton.offsetWidth / 2,
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handlePageScroll);
|
||||
handlePageScroll();
|
||||
return () => window.removeEventListener("scroll", handlePageScroll);
|
||||
}, [sections, shouldAutoScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky z-50 px-4 transition-all duration-200",
|
||||
isStuck ? "top-1 sm:top-2 md:top-4 rounded-lg" : "rounded-t-none"
|
||||
)}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-white dark:bg-gray-900 transition-all duration-200",
|
||||
isStuck
|
||||
? "rounded-lg mt-2 shadow-md"
|
||||
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
|
||||
)}
|
||||
>
|
||||
<CardContent className="py-2 px-4">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center min-w-0 relative">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto no-scrollbar min-w-0 -mx-1 px-1 touch-pan-x overscroll-y-contain pr-12"
|
||||
>
|
||||
<div className="flex flex-nowrap space-x-1">
|
||||
{sections.map(({ id, label, responsiveIds }) => (
|
||||
<Button
|
||||
key={id}
|
||||
ref={(el) => (buttonRefs.current[id] = el)}
|
||||
variant={activeSections.includes(id) ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"whitespace-nowrap flex-shrink-0 px-1 md:px-3 py-2 transition-all duration-200",
|
||||
activeSections.includes(id) &&
|
||||
"bg-blue-100 dark:bg-blue-900/70 text-primary dark:text-blue-100 shadow-sm hover:bg-blue-100 dark:hover:bg-blue-900/70 md:hover:bg-blue-200 dark:md:hover:bg-blue-900",
|
||||
!activeSections.includes(id) &&
|
||||
"hover:bg-blue-100 dark:hover:bg-blue-900/40 md:hover:bg-blue-50 dark:md:hover:bg-blue-900/20 hover:text-primary dark:hover:text-blue-100 dark:text-gray-400",
|
||||
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring focus-visible:ring-offset-background",
|
||||
"disabled:pointer-events-none disabled:opacity-50"
|
||||
)}
|
||||
onClick={() => scrollToSection(id, responsiveIds)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-white dark:bg-gray-900 pl-1 pr-0">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"flex-shrink-0 h-10 w-10 p-0 hover:bg-blue-100 dark:hover:bg-blue-900/40",
|
||||
isStuck ? "" : "hidden"
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<ArrowUpToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
380
dashboard/src/components/dashboard/ProductGrid.jsx
Normal file
380
dashboard/src/components/dashboard/ProductGrid.jsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, ArrowUpDown, AlertCircle, Package } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
TIME_RANGES,
|
||||
formatDateForInput,
|
||||
parseDateFromInput,
|
||||
} from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ProductGrid = ({
|
||||
timeRange = "today",
|
||||
startDate,
|
||||
endDate,
|
||||
onTimeRangeChange,
|
||||
title = "Top Products",
|
||||
description
|
||||
}) => {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
|
||||
const [customDateRange, setCustomDateRange] = useState({
|
||||
startDate: formatDateForInput(startDate) || "",
|
||||
endDate: formatDateForInput(endDate) || "",
|
||||
});
|
||||
const [sorting, setSorting] = useState({
|
||||
column: "totalQuantity",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [selectedTimeRange, customDateRange.startDate, customDateRange.endDate]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params =
|
||||
selectedTimeRange === "custom"
|
||||
? {
|
||||
startDate: parseDateFromInput(
|
||||
customDateRange.startDate
|
||||
)?.toISOString(),
|
||||
endDate: parseDateFromInput(
|
||||
customDateRange.endDate
|
||||
)?.toISOString(),
|
||||
}
|
||||
: { timeRange: selectedTimeRange };
|
||||
|
||||
const response = await axios.get("/api/klaviyo/events/products", {
|
||||
params,
|
||||
});
|
||||
setProducts(response.data.stats.products.list || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching products:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (value) => {
|
||||
setSelectedTimeRange(value);
|
||||
if (onTimeRangeChange) {
|
||||
onTimeRangeChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column) => {
|
||||
setSorting((prev) => ({
|
||||
column,
|
||||
direction:
|
||||
prev.column === column && prev.direction === "desc" ? "asc" : "desc",
|
||||
}));
|
||||
};
|
||||
|
||||
const sortedProducts = [...products].sort((a, b) => {
|
||||
const direction = sorting.direction === "desc" ? -1 : 1;
|
||||
const aValue = a[sorting.column];
|
||||
const bValue = b[sorting.column];
|
||||
|
||||
if (typeof aValue === "number") {
|
||||
return (aValue - bValue) * direction;
|
||||
}
|
||||
return String(aValue).localeCompare(String(bValue)) * direction;
|
||||
});
|
||||
|
||||
const SkeletonProduct = () => (
|
||||
<TableRow>
|
||||
<TableCell className="w-[50px] p-1">
|
||||
<Skeleton className="h-[50px] w-[50px] rounded" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[200px] px-0">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-0">
|
||||
<Skeleton className="h-4 w-12 mx-auto" />
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-0">
|
||||
<Skeleton className="h-4 w-20 mx-auto" />
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-0">
|
||||
<Skeleton className="h-4 w-16 mx-auto" />
|
||||
</TableCell>
|
||||
<TableCell className="text-center hidden md:table-cell px-0">
|
||||
<Skeleton className="h-4 w-16 mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
const LoadingState = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-[180px]" />
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[50px] min-w-[50px]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</TableHead>
|
||||
<TableHead className="text-right hidden md:table-cell">
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<SkeletonProduct key={i} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
|
||||
{description && (
|
||||
<CardDescription className="mt-1">{description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={selectedTimeRange}
|
||||
onValueChange={handleTimeRangeChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Custom Range</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedTimeRange === "custom" && (
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<Label htmlFor="startDate" className="sr-only">
|
||||
Start Date
|
||||
</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="datetime-local"
|
||||
value={customDateRange.startDate}
|
||||
onChange={(e) =>
|
||||
setCustomDateRange((prev) => ({
|
||||
...prev,
|
||||
startDate: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="endDate" className="sr-only">
|
||||
End Date
|
||||
</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
type="datetime-local"
|
||||
value={customDateRange.endDate}
|
||||
onChange={(e) =>
|
||||
setCustomDateRange((prev) => ({
|
||||
...prev,
|
||||
endDate: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
{loading && !products.length ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<p className="font-medium mb-2">Error loading products</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : !products?.length ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="font-medium mb-2">No product data available</p>
|
||||
<p className="text-sm text-muted-foreground">Try selecting a different time range</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[50px] min-w-[50px]" />
|
||||
<TableHead className="min-w-[200px] pr-0 pl-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("name")}
|
||||
className="h-8 justify-start w-full font-medium hover:bg-transparent"
|
||||
>
|
||||
Product
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right px-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("totalQuantity")}
|
||||
className="h-8 justify-end w-full font-medium hover:bg-transparent"
|
||||
>
|
||||
Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right px-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("totalRevenue")}
|
||||
className="h-8 justify-end w-full font-medium hover:bg-transparent"
|
||||
>
|
||||
Rev
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right px-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("orderCount")}
|
||||
className="h-8 justify-end w-full font-medium hover:bg-transparent"
|
||||
>
|
||||
Orders
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right hidden md:table-cell">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("price")}
|
||||
className="h-8 justify-end w-full font-medium hover:bg-transparent"
|
||||
>
|
||||
Price
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedProducts.map((product) => (
|
||||
<TableRow
|
||||
key={product.id}
|
||||
className="group hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="w-[50px] p-1">
|
||||
{product.ImgThumb && (
|
||||
<img
|
||||
src={product.ImgThumb}
|
||||
alt=""
|
||||
width={50}
|
||||
height={50}
|
||||
className="rounded bg-muted"
|
||||
onError={(e) => (e.target.style.display = "none")}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[200px] px-0">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:underline line-clamp-2 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{product.name}
|
||||
</a>
|
||||
<div className="flex gap-2 text-sm text-muted-foreground px-0">
|
||||
<span>${product.price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-medium px-0">
|
||||
{product.totalQuantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-emerald-600 dark:text-emerald-400 font-medium px-0">
|
||||
${product.totalRevenue.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground px-0">
|
||||
{product.orderCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-center hidden md:table-cell px-0">
|
||||
${product.price.toFixed(2)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGrid;
|
||||
993
dashboard/src/components/dashboard/SalesChart.jsx
Normal file
993
dashboard/src/components/dashboard/SalesChart.jsx
Normal file
@@ -0,0 +1,993 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, TrendingUp, TrendingDown, Info, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { TIME_RANGES, GROUP_BY_OPTIONS, formatDateForInput, parseDateFromInput } from "@/lib/constants";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const METRIC_IDS = {
|
||||
PLACED_ORDER: 'Y8cqcF',
|
||||
PAYMENT_REFUNDED: 'R7XUYh'
|
||||
};
|
||||
|
||||
// Map current periods to their previous equivalents
|
||||
const PREVIOUS_PERIOD_MAP = {
|
||||
today: 'yesterday',
|
||||
thisWeek: 'lastWeek',
|
||||
thisMonth: 'lastMonth',
|
||||
last7days: 'previous7days',
|
||||
last30days: 'previous30days',
|
||||
last90days: 'previous90days',
|
||||
yesterday: 'twoDaysAgo'
|
||||
};
|
||||
|
||||
// Add helper function to calculate previous period dates
|
||||
const calculatePreviousPeriodDates = (timeRange, startDate, endDate) => {
|
||||
if (timeRange && timeRange !== 'custom') {
|
||||
return {
|
||||
timeRange: PREVIOUS_PERIOD_MAP[timeRange]
|
||||
};
|
||||
} else if (startDate && endDate) {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const duration = end.getTime() - start.getTime();
|
||||
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
|
||||
return {
|
||||
startDate: prevStart.toISOString(),
|
||||
endDate: prevEnd.toISOString()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Enhanced helper function for consistent currency formatting with explicit rounding
|
||||
const formatCurrency = (value, useFractionDigits = true) => {
|
||||
if (typeof value !== 'number') return '$0.00';
|
||||
const roundedValue = parseFloat(value.toFixed(useFractionDigits ? 2 : 0));
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: useFractionDigits ? 2 : 0,
|
||||
maximumFractionDigits: useFractionDigits ? 2 : 0
|
||||
}).format(roundedValue);
|
||||
};
|
||||
|
||||
// Add a helper function for percentage formatting
|
||||
const formatPercentage = (value) => {
|
||||
if (typeof value !== 'number') return '0%';
|
||||
return `${value >= 0 ? '+' : ''}${Math.round(value)}%`;
|
||||
};
|
||||
|
||||
// Add color mapping for metrics
|
||||
const METRIC_COLORS = {
|
||||
revenue: '#8b5cf6',
|
||||
orders: '#10b981',
|
||||
avgOrderValue: '#9333ea',
|
||||
movingAverage: '#f59e0b',
|
||||
prevRevenue: '#f97316',
|
||||
prevOrders: '#0ea5e9',
|
||||
prevAvgOrderValue: '#f59e0b'
|
||||
};
|
||||
|
||||
// Memoize the StatCard component
|
||||
const StatCard = memo(({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
trend,
|
||||
trendValue,
|
||||
valuePrefix = "",
|
||||
valueSuffix = "",
|
||||
trendPrefix = "",
|
||||
trendSuffix = "",
|
||||
className = "",
|
||||
colorClass = "text-gray-900 dark:text-gray-100",
|
||||
info
|
||||
}) => (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{title}</span>
|
||||
{info && (
|
||||
<Info
|
||||
className="w-4 h-4 text-muted-foreground cursor-help"
|
||||
title={info}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className={`text-2xl font-bold mb-1 ${colorClass}`}>
|
||||
{valuePrefix}{value}{valueSuffix}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{trend && (
|
||||
<div className={`text-sm flex items-center gap-1 mt-2 ${trend === 'up' ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}`}>
|
||||
{trend === 'up' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
{trendPrefix}{trendValue}{trendSuffix}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
));
|
||||
|
||||
// Add display name for debugging
|
||||
StatCard.displayName = 'StatCard';
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const date = new Date(label);
|
||||
const formattedDate = date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
// Group metrics by type (current vs previous)
|
||||
const currentMetrics = payload.filter(p => !p.dataKey.toLowerCase().includes('prev'));
|
||||
const previousMetrics = payload.filter(p => p.dataKey.toLowerCase().includes('prev'));
|
||||
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<p className="font-medium text-sm border-b pb-1 mb-2">{formattedDate}</p>
|
||||
|
||||
<div className="space-y-1">
|
||||
{currentMetrics.map((entry, index) => {
|
||||
const value = entry.dataKey.toLowerCase().includes('revenue') ||
|
||||
entry.dataKey === 'avgOrderValue' ||
|
||||
entry.dataKey === 'movingAverage' ||
|
||||
entry.dataKey === 'aovMovingAverage'
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div key={index} className="flex justify-between items-center text-sm">
|
||||
<span style={{ color: entry.stroke || METRIC_COLORS[entry.dataKey.toLowerCase()] }}>
|
||||
{entry.name}:
|
||||
</span>
|
||||
<span className="font-medium ml-4">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{previousMetrics.length > 0 && (
|
||||
<>
|
||||
<div className="border-t my-2"></div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground mb-1">Previous Period</p>
|
||||
{previousMetrics.map((entry, index) => {
|
||||
const value = entry.dataKey.toLowerCase().includes('revenue') ||
|
||||
entry.dataKey.includes('avgOrderValue')
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div key={index} className="flex justify-between items-center text-sm">
|
||||
<span style={{ color: entry.stroke || METRIC_COLORS[entry.dataKey.toLowerCase()] }}>
|
||||
{entry.name.replace('Previous ', '')}:
|
||||
</span>
|
||||
<span className="font-medium ml-4">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const calculate7DayAverage = (data) => {
|
||||
if (!Array.isArray(data) || data.length === 0) return [];
|
||||
|
||||
return data.map((day, index, array) => {
|
||||
// Get up to 7 days of data, including current day
|
||||
const startIndex = Math.max(0, index - 6);
|
||||
const window = array.slice(startIndex, index + 1);
|
||||
|
||||
// Calculate averages for all metrics
|
||||
const validPoints = window.filter(point =>
|
||||
point &&
|
||||
typeof point.revenue === 'number' &&
|
||||
typeof point.orders === 'number' &&
|
||||
!isNaN(point.revenue) &&
|
||||
!isNaN(point.orders)
|
||||
);
|
||||
|
||||
if (validPoints.length === 0) {
|
||||
return {
|
||||
...day,
|
||||
movingAverage: 0,
|
||||
orderMovingAverage: 0,
|
||||
aovMovingAverage: 0
|
||||
};
|
||||
}
|
||||
|
||||
const revenueSum = validPoints.reduce((acc, curr) => acc + curr.revenue, 0);
|
||||
const orderSum = validPoints.reduce((acc, curr) => acc + curr.orders, 0);
|
||||
|
||||
const revenueAvg = revenueSum / validPoints.length;
|
||||
const orderAvg = orderSum / validPoints.length;
|
||||
const aovAvg = orderAvg > 0 ? revenueAvg / orderAvg : 0;
|
||||
|
||||
return {
|
||||
...day,
|
||||
movingAverage: Number(revenueAvg.toFixed(2)),
|
||||
orderMovingAverage: Number(orderAvg.toFixed(2)),
|
||||
aovMovingAverage: Number(aovAvg.toFixed(2))
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const processData = (stats = []) => {
|
||||
if (!Array.isArray(stats)) return [];
|
||||
|
||||
// First, convert the stats array into the base format
|
||||
const baseData = stats.map(day => ({
|
||||
timestamp: day.date || day.timestamp,
|
||||
revenue: Number(day.revenue || 0),
|
||||
orders: Number(day.orders || 0),
|
||||
avgOrderValue: Number(day.averageOrderValue || (day.orders > 0 ? day.revenue / day.orders : 0)),
|
||||
prevRevenue: Number(day.prevRevenue || 0),
|
||||
prevOrders: Number(day.prevOrders || 0),
|
||||
prevAvgOrderValue: Number(day.prevAvgOrderValue || (day.prevOrders > 0 ? day.prevRevenue / day.prevOrders : 0)),
|
||||
growth: {
|
||||
revenue: 0,
|
||||
orders: 0,
|
||||
avgOrderValue: 0
|
||||
}
|
||||
}));
|
||||
|
||||
// Calculate growth rates
|
||||
baseData.forEach(day => {
|
||||
// Calculate growth
|
||||
day.growth = {
|
||||
revenue: day.prevRevenue > 0 ? ((day.revenue - day.prevRevenue) / day.prevRevenue) * 100 : 0,
|
||||
orders: day.prevOrders > 0 ? ((day.orders - day.prevOrders) / day.prevOrders) * 100 : 0,
|
||||
avgOrderValue: day.prevAvgOrderValue > 0 ? ((day.avgOrderValue - day.prevAvgOrderValue) / day.prevAvgOrderValue) * 100 : 0
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate 7-day moving averages
|
||||
return calculate7DayAverage(baseData);
|
||||
};
|
||||
|
||||
const calculateSummaryStats = (data = []) => {
|
||||
if (!Array.isArray(data) || data.length === 0) return {};
|
||||
|
||||
// Calculate current period totals
|
||||
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
|
||||
const totalOrders = data.reduce((sum, item) => sum + item.orders, 0);
|
||||
const avgOrderValue = totalOrders ? totalRevenue / totalOrders : 0;
|
||||
|
||||
// Calculate previous period totals
|
||||
const prevRevenue = data.reduce((sum, item) => sum + item.prevRevenue, 0);
|
||||
const prevOrders = data.reduce((sum, item) => sum + item.prevOrders, 0);
|
||||
const prevAvgOrderValue = prevOrders ? prevRevenue / prevOrders : 0;
|
||||
|
||||
// Find best day
|
||||
const bestDay = data.reduce((best, current) => {
|
||||
if (current.revenue > (best?.revenue || 0)) {
|
||||
return {
|
||||
revenue: current.revenue,
|
||||
timestamp: current.timestamp,
|
||||
orders: current.orders,
|
||||
avgOrderValue: current.avgOrderValue
|
||||
};
|
||||
}
|
||||
return best;
|
||||
}, null);
|
||||
|
||||
// Calculate growth percentages
|
||||
const growth = {
|
||||
revenue: prevRevenue ? ((totalRevenue - prevRevenue) / prevRevenue) * 100 : 0,
|
||||
orders: prevOrders ? ((totalOrders - prevOrders) / prevOrders) * 100 : 0,
|
||||
avgOrderValue: prevAvgOrderValue ? ((avgOrderValue - prevAvgOrderValue) / prevAvgOrderValue) * 100 : 0
|
||||
};
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
totalOrders,
|
||||
avgOrderValue,
|
||||
bestDay,
|
||||
prevRevenue,
|
||||
prevOrders,
|
||||
prevAvgOrderValue,
|
||||
growth,
|
||||
movingAverages: {
|
||||
revenue: data[data.length - 1]?.movingAverage || 0,
|
||||
orders: data[data.length - 1]?.orderMovingAverage || 0,
|
||||
avgOrderValue: data[data.length - 1]?.aovMovingAverage || 0
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Add memoized SummaryStats component
|
||||
const SummaryStats = memo(({ stats = {} }) => {
|
||||
const {
|
||||
totalRevenue = 0,
|
||||
totalOrders = 0,
|
||||
avgOrderValue = 0,
|
||||
bestDay = null,
|
||||
prevRevenue = 0,
|
||||
prevOrders = 0,
|
||||
prevAvgOrderValue = 0,
|
||||
growth = { revenue: 0, orders: 0, avgOrderValue: 0 }
|
||||
} = stats;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 py-4 max-w-4xl">
|
||||
<StatCard
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(totalRevenue, false)}
|
||||
description={`Previous: ${formatCurrency(prevRevenue, false)}`}
|
||||
trend={growth.revenue >= 0 ? 'up' : 'down'}
|
||||
trendValue={formatPercentage(growth.revenue)}
|
||||
info="Total revenue for the selected period"
|
||||
colorClass="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Total Orders"
|
||||
value={totalOrders.toLocaleString()}
|
||||
description={`Previous: ${prevOrders.toLocaleString()} orders`}
|
||||
trend={growth.orders >= 0 ? 'up' : 'down'}
|
||||
trendValue={formatPercentage(growth.orders)}
|
||||
info="Total number of orders for the selected period"
|
||||
colorClass="text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Average Order Value"
|
||||
value={formatCurrency(avgOrderValue)}
|
||||
description={`Previous: ${formatCurrency(prevAvgOrderValue)}`}
|
||||
trend={growth.avgOrderValue >= 0 ? 'up' : 'down'}
|
||||
trendValue={formatPercentage(growth.avgOrderValue)}
|
||||
info="Average value per order for the selected period"
|
||||
colorClass="text-purple-600 dark:text-purple-400"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Best Day"
|
||||
value={formatCurrency(bestDay?.revenue || 0, false)}
|
||||
description={bestDay?.timestamp ?
|
||||
`${new Date(bestDay.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${bestDay.orders} orders` :
|
||||
'No data'}
|
||||
info="Day with highest revenue in the selected period"
|
||||
colorClass="text-orange-600 dark:text-orange-400"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SummaryStats.displayName = 'SummaryStats';
|
||||
|
||||
// Add these skeleton components near the top of the file
|
||||
const SkeletonChart = () => (
|
||||
<div className="h-[400px] w-full bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 relative">
|
||||
<div className="h-full w-full relative">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-gray-200 dark:bg-gray-700"
|
||||
style={{ top: `${20 + i * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-0 bg-gray-300 dark:bg-gray-600 animate-pulse"
|
||||
style={{
|
||||
opacity: 0.2,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonStats = () => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-32 mb-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonTable = () => (
|
||||
<div className="mt-4 overflow-x-auto rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<TableHead key={i}>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{[...Array(8)].map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SalesChart = ({
|
||||
timeRange = 'last30days',
|
||||
startDate,
|
||||
endDate,
|
||||
title = "Sales Overview",
|
||||
description = "Track your sales performance over time"
|
||||
}) => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
|
||||
const [showDailyTable, setShowDailyTable] = useState(false);
|
||||
const [metrics, setMetrics] = useState({
|
||||
revenue: true,
|
||||
orders: true,
|
||||
avgOrderValue: true,
|
||||
movingAverage: true,
|
||||
prevRevenue: false,
|
||||
prevOrders: false,
|
||||
prevAvgOrderValue: false
|
||||
});
|
||||
const [summaryStats, setSummaryStats] = useState({});
|
||||
const [customDateRange, setCustomDateRange] = useState({
|
||||
startDate: formatDateForInput(startDate) || '',
|
||||
endDate: formatDateForInput(endDate) || ''
|
||||
});
|
||||
|
||||
// Fetch data function
|
||||
const fetchData = useCallback(async (params) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Get previous period params
|
||||
const prevPeriodParams = calculatePreviousPeriodDates(
|
||||
params.timeRange,
|
||||
params.startDate,
|
||||
params.endDate
|
||||
);
|
||||
|
||||
// Fetch both current and previous period data
|
||||
const [currentResponse, prevResponse] = await Promise.all([
|
||||
axios.get('/api/klaviyo/events/stats/details', {
|
||||
params: {
|
||||
...params,
|
||||
metric: 'revenue',
|
||||
daily: true
|
||||
}
|
||||
}),
|
||||
axios.get('/api/klaviyo/events/stats/details', {
|
||||
params: {
|
||||
metric: 'revenue',
|
||||
daily: true,
|
||||
...(prevPeriodParams || {})
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
if (!currentResponse.data) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
// Process the data
|
||||
const currentStats = Array.isArray(currentResponse.data) ? currentResponse.data : currentResponse.data.stats || [];
|
||||
const prevStats = Array.isArray(prevResponse.data) ? prevResponse.data : (prevResponse.data?.stats || []);
|
||||
|
||||
// Map previous period data to current period dates
|
||||
const processedStats = currentStats.map((day, index) => {
|
||||
// Find the corresponding previous period day
|
||||
const prevDay = prevStats[index] || {};
|
||||
|
||||
return {
|
||||
...day,
|
||||
prevRevenue: Number(prevDay.revenue || 0),
|
||||
prevOrders: Number(prevDay.orders || 0),
|
||||
prevAvgOrderValue: Number(prevDay.averageOrderValue || (prevDay.orders > 0 ? prevDay.revenue / prevDay.orders : 0))
|
||||
};
|
||||
});
|
||||
|
||||
const processedData = processData(processedStats);
|
||||
const stats = calculateSummaryStats(processedData);
|
||||
|
||||
setData(processedData);
|
||||
setSummaryStats(stats);
|
||||
setError(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle time range change
|
||||
const handleTimeRangeChange = useCallback((value) => {
|
||||
setSelectedTimeRange(value);
|
||||
|
||||
const params = value === 'custom'
|
||||
? {
|
||||
startDate: parseDateFromInput(customDateRange.startDate)?.toISOString(),
|
||||
endDate: parseDateFromInput(customDateRange.endDate)?.toISOString()
|
||||
}
|
||||
: { timeRange: value };
|
||||
|
||||
fetchData(params);
|
||||
}, [customDateRange, fetchData]);
|
||||
|
||||
// Handle custom date change
|
||||
const handleCustomDateChange = useCallback((field, value) => {
|
||||
setCustomDateRange(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
|
||||
if (selectedTimeRange === 'custom' && customDateRange.startDate && customDateRange.endDate) {
|
||||
fetchData({
|
||||
startDate: parseDateFromInput(customDateRange.startDate)?.toISOString(),
|
||||
endDate: parseDateFromInput(customDateRange.endDate)?.toISOString()
|
||||
});
|
||||
}
|
||||
}, [selectedTimeRange, customDateRange, fetchData]);
|
||||
|
||||
// Initial load effect
|
||||
useEffect(() => {
|
||||
const params = selectedTimeRange === 'custom'
|
||||
? {
|
||||
startDate: parseDateFromInput(customDateRange.startDate)?.toISOString(),
|
||||
endDate: parseDateFromInput(customDateRange.endDate)?.toISOString()
|
||||
}
|
||||
: { timeRange: selectedTimeRange };
|
||||
|
||||
fetchData(params);
|
||||
}, [selectedTimeRange, customDateRange, fetchData]);
|
||||
|
||||
// Auto-refresh effect for 'today' view
|
||||
useEffect(() => {
|
||||
let intervalId = null;
|
||||
|
||||
if (selectedTimeRange === 'today') {
|
||||
// Initial fetch
|
||||
fetchData({ timeRange: 'today' });
|
||||
|
||||
// Set up interval
|
||||
intervalId = setInterval(() => {
|
||||
fetchData({ timeRange: 'today' });
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [selectedTimeRange, fetchData]);
|
||||
|
||||
const formatXAxis = (value) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const averageRevenue = data.length > 0
|
||||
? data.reduce((sum, day) => sum + day.revenue, 0) / data.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={selectedTimeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Custom Range</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTimeRange === 'custom' && (
|
||||
<div className="grid grid-cols-2 gap-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start Date</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="datetime-local"
|
||||
value={customDateRange.startDate}
|
||||
onChange={(e) => handleCustomDateChange('startDate', e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDate">End Date</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
type="datetime-local"
|
||||
value={customDateRange.endDate}
|
||||
onChange={(e) => handleCustomDateChange('endDate', e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show either skeletons or actual stats */}
|
||||
{loading ? (
|
||||
<SkeletonStats />
|
||||
) : (
|
||||
<SummaryStats stats={summaryStats} />
|
||||
)}
|
||||
|
||||
{/* Metric Toggles */}
|
||||
<div className="flex flex-wrap gap-4 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="revenue"
|
||||
checked={metrics.revenue}
|
||||
onCheckedChange={(checked) => setMetrics(prev => ({ ...prev, revenue: checked }))}
|
||||
/>
|
||||
<Label htmlFor="revenue" className="text-sm">Revenue</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="orders"
|
||||
checked={metrics.orders}
|
||||
onCheckedChange={(checked) => setMetrics(prev => ({ ...prev, orders: checked }))}
|
||||
/>
|
||||
<Label htmlFor="orders" className="text-sm">Orders</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="avgOrderValue"
|
||||
checked={metrics.avgOrderValue}
|
||||
onCheckedChange={(checked) => setMetrics(prev => ({ ...prev, avgOrderValue: checked }))}
|
||||
/>
|
||||
<Label htmlFor="avgOrderValue" className="text-sm">AOV</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="movingAverage"
|
||||
checked={metrics.movingAverage}
|
||||
onCheckedChange={(checked) => setMetrics(prev => ({ ...prev, movingAverage: checked }))}
|
||||
/>
|
||||
<Label htmlFor="movingAverage" className="text-sm">7-Day Avg</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="prevRevenue"
|
||||
checked={metrics.prevRevenue}
|
||||
onCheckedChange={(checked) => setMetrics(prev => ({ ...prev, prevRevenue: checked }))}
|
||||
/>
|
||||
<Label htmlFor="prevRevenue" className="text-sm">Prev Revenue</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="prevOrders"
|
||||
checked={metrics.prevOrders}
|
||||
onCheckedChange={(checked) => setMetrics(prev => ({ ...prev, prevOrders: checked }))}
|
||||
/>
|
||||
<Label htmlFor="prevOrders" className="text-sm">Prev Orders</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="prevAvgOrderValue"
|
||||
checked={metrics.prevAvgOrderValue}
|
||||
onCheckedChange={(checked) => setMetrics(prev => ({ ...prev, prevAvgOrderValue: checked }))}
|
||||
/>
|
||||
<Label htmlFor="prevAvgOrderValue" className="text-sm">Prev AOV</Label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0">
|
||||
{loading ? (
|
||||
<div className="space-y-6">
|
||||
<SkeletonChart />
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
{showDailyTable && <SkeletonTable />}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-[400px] text-destructive">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4" />
|
||||
<div className="font-medium mb-2">Error loading sales data</div>
|
||||
<div className="text-sm text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-12 w-12 mx-auto mb-4" />
|
||||
<div className="font-medium mb-2">No sales data available</div>
|
||||
<div className="text-sm">Try selecting a different time range</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[400px] mt-4 bg-card rounded-lg p-4 relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={formatXAxis}
|
||||
className="text-xs"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="revenue"
|
||||
tickFormatter={(value) => formatCurrency(value, false)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="orders"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
className="text-xs"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<ReferenceLine
|
||||
y={averageRevenue}
|
||||
yAxisId="revenue"
|
||||
stroke="currentColor"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: `Avg Revenue: ${formatCurrency(averageRevenue)}`,
|
||||
fill: "currentColor",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
{metrics.revenue && (
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{metrics.movingAverage && (
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="movingAverage"
|
||||
name="7-Day Average"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
)}
|
||||
{metrics.prevRevenue && (
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="prevRevenue"
|
||||
name="Previous Revenue"
|
||||
stroke="#f97316"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
)}
|
||||
{metrics.orders && (
|
||||
<Line
|
||||
yAxisId="orders"
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
name="Orders"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{metrics.prevOrders && (
|
||||
<Line
|
||||
yAxisId="orders"
|
||||
type="monotone"
|
||||
dataKey="prevOrders"
|
||||
name="Previous Orders"
|
||||
stroke="#0ea5e9"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
)}
|
||||
{metrics.avgOrderValue && (
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="avgOrderValue"
|
||||
name="Avg Order Value"
|
||||
stroke="#9333ea"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{metrics.prevAvgOrderValue && (
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="prevAvgOrderValue"
|
||||
name="Previous Avg Order"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDailyTable(!showDailyTable)}
|
||||
className="h-9"
|
||||
>
|
||||
{showDailyTable ? "Hide" : "Show"} Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showDailyTable && (
|
||||
<div className="mt-4 overflow-x-auto rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Orders</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Avg Order</TableHead>
|
||||
<TableHead className="text-right">Prev Orders</TableHead>
|
||||
<TableHead className="text-right">Prev Revenue</TableHead>
|
||||
<TableHead className="text-right">Prev Avg Order</TableHead>
|
||||
<TableHead className="text-right">7-Day Avg</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((day) => (
|
||||
<TableRow key={day.timestamp}>
|
||||
<TableCell>{formatXAxis(day.timestamp)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{day.orders.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(day.revenue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(day.avgOrderValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{day.prevOrders.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(day.prevRevenue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(day.prevAvgOrderValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(day.movingAverage)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesChart;
|
||||
1784
dashboard/src/components/dashboard/StatCards.jsx
Normal file
1784
dashboard/src/components/dashboard/StatCards.jsx
Normal file
File diff suppressed because it is too large
Load Diff
25
dashboard/src/components/dashboard/TableActions.jsx
Normal file
25
dashboard/src/components/dashboard/TableActions.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// components/TableActions.jsx
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Search } from "lucide-react";
|
||||
|
||||
export const TableActions = ({ onSearch, onExport }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter agents..."
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onExport}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
dashboard/src/components/dashboard/TimeRangeSelect.jsx
Normal file
44
dashboard/src/components/dashboard/TimeRangeSelect.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "yesterday", label: "Yesterday" },
|
||||
{ value: "last7days", label: "Last 7 Days" },
|
||||
{ value: "last30days", label: "Last 30 Days" },
|
||||
{ value: "last90days", label: "Last 90 Days" },
|
||||
];
|
||||
|
||||
export const TimeRangeSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
allowedRanges = [],
|
||||
}) => {
|
||||
const filteredOptions = allowedRanges.length
|
||||
? timeRangeOptions.filter((option) => allowedRanges.includes(option.value))
|
||||
: timeRangeOptions;
|
||||
|
||||
console.log("Allowed Ranges prop:", allowedRanges); // Debugging line
|
||||
console.log("Filtered Options:", filteredOptions); // Debugging line
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
26
dashboard/src/components/theme/ModeToggle.jsx
Normal file
26
dashboard/src/components/theme/ModeToggle.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "@/components/theme/ThemeProvider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className="w-9 h-9 rounded-md border-none bg-transparent hover:bg-transparent"
|
||||
>
|
||||
<div className="relative w-5 h-5">
|
||||
<Sun
|
||||
className="absolute inset-0 h-full w-full transition-all duration-300 text-yellow-500 dark:rotate-0 dark:scale-0 dark:opacity-0 rotate-0 scale-100 opacity-100"
|
||||
/>
|
||||
<Moon
|
||||
className="absolute inset-0 h-full w-full transition-all duration-300 text-slate-900 dark:text-slate-200 rotate-90 scale-0 opacity-0 dark:rotate-0 dark:scale-100 dark:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
78
dashboard/src/components/theme/ThemeProvider.jsx
Normal file
78
dashboard/src/components/theme/ThemeProvider.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
const ThemeProviderContext = createContext({
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
toggleTheme: () => null,
|
||||
})
|
||||
|
||||
function getSystemTheme() {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}) {
|
||||
// Always start with system theme
|
||||
const [theme, setTheme] = useState('system')
|
||||
const [systemTheme, setSystemTheme] = useState(getSystemTheme)
|
||||
|
||||
// Clear any stored theme on mount
|
||||
useEffect(() => {
|
||||
localStorage.removeItem(storageKey)
|
||||
}, [storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const handler = (e) => {
|
||||
setSystemTheme(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
setSystemTheme(darkModeQuery.matches ? 'dark' : 'light')
|
||||
darkModeQuery.addEventListener('change', handler)
|
||||
return () => darkModeQuery.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
const effectiveTheme = theme === 'system' ? systemTheme : theme
|
||||
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(effectiveTheme)
|
||||
}, [theme, systemTheme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === 'system') {
|
||||
const newTheme = systemTheme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
} else {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light'
|
||||
setTheme(newTheme)
|
||||
}
|
||||
}
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
systemTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
47
dashboard/src/components/ui/alert.jsx
Normal file
47
dashboard/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 }
|
||||
34
dashboard/src/components/ui/badge.jsx
Normal file
34
dashboard/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 }
|
||||
48
dashboard/src/components/ui/button.jsx
Normal file
48
dashboard/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 bg-background 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 }
|
||||
67
dashboard/src/components/ui/calendar.jsx
Normal file
67
dashboard/src/components/ui/calendar.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
66
dashboard/src/components/ui/calendaredit.jsx
Normal file
66
dashboard/src/components/ui/calendaredit.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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-2", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0",
|
||||
month: "w-full space-y-2",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-lg font-medium", // Reduced from text-4xl
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "ghost", size: "sm"}), // Changed from lg to sm
|
||||
"h-6 w-6" // Reduced from h-12 w-18
|
||||
),
|
||||
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-6 font-normal text-[0.7rem] w-full", // Reduced sizes
|
||||
row: "flex w-full mt-1", // Reduced margin
|
||||
cell: cn(
|
||||
"w-full relative p-0 text-center text-xs 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-6 w-6 p-0 font-normal text-xs aria-selected:opacity-100" // Reduced from h-12 w-12 and text-lg
|
||||
),
|
||||
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/50 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: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Calendar.displayName = "Calendar"
|
||||
export { Calendar }
|
||||
50
dashboard/src/components/ui/card.jsx
Normal file
50
dashboard/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 }
|
||||
308
dashboard/src/components/ui/chart.jsx
Normal file
308
dashboard/src/components/ui/chart.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = {
|
||||
light: "",
|
||||
dark: ".dark"
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
(<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>)
|
||||
);
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({
|
||||
id,
|
||||
config
|
||||
}) => {
|
||||
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
(<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`)
|
||||
.join("\n"),
|
||||
}} />)
|
||||
);
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef((
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
(<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>)
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
(<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor
|
||||
}
|
||||
} />
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef((
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
(<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}} />
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>)
|
||||
);
|
||||
})}
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config,
|
||||
payload,
|
||||
key
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key]
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key]
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
22
dashboard/src/components/ui/checkbox.jsx
Normal file
22
dashboard/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 }
|
||||
94
dashboard/src/components/ui/dialog.jsx
Normal file
94
dashboard/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,
|
||||
}
|
||||
53
dashboard/src/components/ui/input-otp.jsx
Normal file
53
dashboard/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
dashboard/src/components/ui/input.jsx
Normal file
19
dashboard/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
dashboard/src/components/ui/label.jsx
Normal file
16
dashboard/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 }
|
||||
27
dashboard/src/components/ui/popover.jsx
Normal file
27
dashboard/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",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
23
dashboard/src/components/ui/progress.jsx
Normal file
23
dashboard/src/components/ui/progress.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
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 }
|
||||
38
dashboard/src/components/ui/scroll-area.jsx
Normal file
38
dashboard/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
dashboard/src/components/ui/select.jsx
Normal file
119
dashboard/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 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-96 min-w-[8rem] overflow-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",
|
||||
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,
|
||||
}
|
||||
14
dashboard/src/components/ui/skeleton.jsx
Normal file
14
dashboard/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 }
|
||||
86
dashboard/src/components/ui/table.jsx
Normal file
86
dashboard/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
dashboard/src/components/ui/tabs.jsx
Normal file
41
dashboard/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 }
|
||||
86
dashboard/src/components/ui/toast.jsx
Normal file
86
dashboard/src/components/ui/toast.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
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 };
|
||||
35
dashboard/src/components/ui/toaster.jsx
Normal file
35
dashboard/src/components/ui/toaster.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
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>)
|
||||
);
|
||||
}
|
||||
26
dashboard/src/components/ui/tooltip.jsx
Normal file
26
dashboard/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",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
31
dashboard/src/contexts/ScrollContext.jsx
Normal file
31
dashboard/src/contexts/ScrollContext.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const ScrollContext = createContext();
|
||||
|
||||
export function ScrollProvider({ children }) {
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const headerHeight = 100; // Adjust as needed
|
||||
setIsStuck(window.scrollY > headerHeight);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider value={{ isStuck }}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useScroll() {
|
||||
const context = useContext(ScrollContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useScroll must be used within a ScrollProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
154
dashboard/src/hooks/use-toast.js
Normal file
154
dashboard/src/hooks/use-toast.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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 }
|
||||
204
dashboard/src/index.css
Normal file
204
dashboard/src/index.css
Normal file
@@ -0,0 +1,204 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-[hsl(var(--background))] text-[hsl(var(--foreground))];
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
--background: 0 0% 95%;
|
||||
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--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
|
||||
}
|
||||
.dark {
|
||||
|
||||
--background: 222.2 84% 4.9%;
|
||||
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
|
||||
--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 components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-500 text-white hover:bg-blue-600;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md p-6;
|
||||
}
|
||||
}
|
||||
|
||||
/* :root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
} */
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
36
dashboard/src/lib/constants.js
Normal file
36
dashboard/src/lib/constants.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export const TIME_RANGES = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'yesterday', label: 'Yesterday' },
|
||||
{ value: 'last7days', label: 'Last 7 Days' },
|
||||
{ value: 'last30days', label: 'Last 30 Days' },
|
||||
{ value: 'last90days', label: 'Last 90 Days' },
|
||||
{ value: 'thisWeek', label: 'This Week' },
|
||||
{ value: 'lastWeek', label: 'Last Week' },
|
||||
{ value: 'thisMonth', label: 'This Month' },
|
||||
{ value: 'lastMonth', label: 'Last Month' }
|
||||
];
|
||||
|
||||
export const GROUP_BY_OPTIONS = [
|
||||
{ value: 'hour', label: 'Hourly' },
|
||||
{ value: 'day', label: 'Daily' },
|
||||
{ value: 'week', label: 'Weekly' },
|
||||
{ value: 'month', label: 'Monthly' }
|
||||
];
|
||||
|
||||
// Format a date object to a datetime-local input string
|
||||
export const formatDateForInput = (date) => {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
// Parse a datetime-local input string to a date object
|
||||
export const parseDateFromInput = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
6
dashboard/src/lib/utils.js
Normal file
6
dashboard/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));
|
||||
}
|
||||
10
dashboard/src/main.jsx
Normal file
10
dashboard/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
14
dashboard/src/utils/debug.js
Normal file
14
dashboard/src/utils/debug.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// utils/debug.js
|
||||
export const debugLog = (component, action, data) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[${component}] ${action}:`, data);
|
||||
}
|
||||
};
|
||||
|
||||
export const debugError = (component, error, context = {}) => {
|
||||
console.error(`[${component}] Error:`, {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
...context
|
||||
});
|
||||
};
|
||||
62
dashboard/tailwind.config.js
Normal file
62
dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import animate from "tailwindcss-animate"
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
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))'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [animate],
|
||||
}
|
||||
188
dashboard/vite.config.js
Normal file
188
dashboard/vite.config.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { loadEnv } from "vite";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env file based on `mode` in the current directory.
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: "html-transform",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
/<title>(.*?)<\/title>/,
|
||||
`<title>${
|
||||
mode === "development" ? "[DEV] Dashboard" : "Dashboard"
|
||||
}</title>`
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api/klaviyo": {
|
||||
target: "https://dashboard.kent.pw",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/klaviyo/, "/api/klaviyo"),
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("error", (err, req, res) => {
|
||||
console.error("Klaviyo proxy error:", err);
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Proxy Error",
|
||||
message: err.message,
|
||||
details: err.stack
|
||||
})
|
||||
);
|
||||
});
|
||||
proxy.on("proxyReq", (proxyReq, req, _res) => {
|
||||
console.log("Outgoing Klaviyo request:", {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
path: proxyReq.path,
|
||||
headers: proxyReq.getHeaders(),
|
||||
});
|
||||
});
|
||||
proxy.on("proxyRes", (proxyRes, req, _res) => {
|
||||
console.log("Klaviyo proxy response:", {
|
||||
statusCode: proxyRes.statusCode,
|
||||
url: req.url,
|
||||
headers: proxyRes.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
"/auth": {
|
||||
target: "https://dashboard.kent.pw",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
cookieDomainRewrite: {
|
||||
"dashboard.kent.pw": "localhost",
|
||||
},
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("error", (err, req, res) => {
|
||||
console.log("Auth proxy error:", err);
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ error: "Proxy Error", message: err.message })
|
||||
);
|
||||
});
|
||||
proxy.on("proxyReq", (proxyReq, req, _res) => {
|
||||
console.log("Outgoing auth request:", {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
});
|
||||
});
|
||||
proxy.on("proxyRes", (proxyRes, req, _res) => {
|
||||
console.log("Auth proxy response:", {
|
||||
statusCode: proxyRes.statusCode,
|
||||
url: req.url,
|
||||
headers: proxyRes.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
onProxyReq: (proxyReq, req, res) => {
|
||||
// Log the outgoing request
|
||||
console.log("Proxy request:", {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
headers: req.headers,
|
||||
});
|
||||
},
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
// Log the incoming response
|
||||
console.log("Proxy response:", {
|
||||
status: proxyRes.statusCode,
|
||||
headers: proxyRes.headers,
|
||||
});
|
||||
},
|
||||
},
|
||||
"/api": {
|
||||
target: "https://dashboard.kent.pw",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, "/api"),
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("error", (err, req, res) => {
|
||||
console.log("API proxy error:", err);
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ error: "Proxy Error", message: err.message })
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
"/health": {
|
||||
target: "https://dashboard.kent.pw",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("error", (err, req, res) => {
|
||||
console.log("Health proxy error:", err);
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ error: "Proxy Error", message: err.message })
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
"/api/aircall": {
|
||||
target: "https://dashboard.kent.pw",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/aircall/, "/api/aircall"),
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("error", (err, req, res) => {
|
||||
console.log("Aircall proxy error:", err);
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ error: "Proxy Error", message: err.message })
|
||||
);
|
||||
});
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "build",
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ["react", "react-dom", "react-router-dom"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
...env,
|
||||
NODE_ENV: JSON.stringify(mode),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user