Overdue initial commit

This commit is contained in:
2024-12-21 09:49:53 -05:00
commit 7c1f7e84ba
180 changed files with 37827 additions and 0 deletions

25
dashboard/.gitignore vendored Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

View 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
View File

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

View 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
View 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
View File

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

File diff suppressed because it is too large Load Diff

7449
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
dashboard/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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
View 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
View 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;

View 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

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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>
)
}

View 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
}

View File

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

View File

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

View File

@@ -0,0 +1,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 }

View 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 }

View 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 }

View File

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

View File

@@ -0,0 +1,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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }

View 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 }

View File

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

View File

@@ -0,0 +1,119 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background 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,
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 };

View 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>)
);
}

View File

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

View 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;
}

View 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
View 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;
}
}

View 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;
};

View 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
View 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>,
)

View 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
});
};

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