Compare commits
3 Commits
main
...
shadcn-adm
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f69ac14c5 | |||
| fde574c7a0 | |||
| eb9bbed092 |
@@ -1,5 +0,0 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8080
|
|
||||||
VITE_PROXY_V1_TARGET=http://localhost:8080
|
|
||||||
VITE_PROXY_API_TARGET=http://localhost:8445
|
|
||||||
VITE_PROXY_WS_TARGET=ws://localhost:8446
|
|
||||||
VITE_USE_TRAEFIK=false
|
|
||||||
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Environment Variables for Vite React Template
|
||||||
|
|
||||||
|
# Google Tag Manager ID (optional)
|
||||||
|
# Only set this in production deployments to enable analytics tracking
|
||||||
|
# Leave empty or omit for development/open-source usage
|
||||||
|
VITE_GTM_ID=
|
||||||
|
|
||||||
|
# Example for your production deployment:
|
||||||
|
# VITE_GTM_ID=GTM-XXXXXXXX
|
||||||
|
|
||||||
|
# Base path for deployment (optional)
|
||||||
|
# Used when deploying to a subdirectory like /templates/dashboard/
|
||||||
|
VITE_BASENAME=
|
||||||
|
|
||||||
|
# Example for subdirectory deployment:
|
||||||
|
# VITE_BASENAME=/templates/dashboard/shadcn-dashboard-landing-template
|
||||||
27
.gitignore
vendored
@@ -1,24 +1,3 @@
|
|||||||
# Logs
|
/node_modules/
|
||||||
logs
|
/repo/
|
||||||
*.log
|
package-lock.json
|
||||||
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?
|
|
||||||
12
Dockerfile
@@ -1,12 +0,0 @@
|
|||||||
FROM node:20-alpine as build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
16
Makefile
@@ -1,16 +0,0 @@
|
|||||||
.PHONY: install dev dev-direct build clean
|
|
||||||
|
|
||||||
install:
|
|
||||||
npm install
|
|
||||||
|
|
||||||
dev:
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
dev-direct:
|
|
||||||
npm run dev:direct
|
|
||||||
|
|
||||||
build:
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rf dist node_modules
|
|
||||||
73
README.md
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + 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) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -3,19 +3,20 @@ import globals from 'globals'
|
|||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default tseslint.config([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs['recommended-latest'],
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
35
index.html
@@ -2,9 +2,40 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" href="favicon.png" type="image/x-icon">
|
||||||
|
<link rel="icon" href="favicon-dark.png" type="image/png" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>eventhubfrontadmin</title>
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>Shadcn Dashboard & Landing Template</title>
|
||||||
|
<meta name="title" content="Shadcn Dashboard & Landing Template" />
|
||||||
|
<meta name="description" content="Open-source Shadcn UI dashboard + landing page template built with React (Vite) and Next.js. Clean, modern, and production-ready." />
|
||||||
|
|
||||||
|
<!-- Canonical (points to the main template URL) -->
|
||||||
|
<link rel="canonical" href="https://shadcnstore.com/templates/dashboard/shadcn-dashboard-landing-template/" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook / LinkedIn -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="ShadcnStore" />
|
||||||
|
<meta property="og:title" content="Shadcn Dashboard & Landing Template" />
|
||||||
|
<meta property="og:description" content="Open-source Shadcn UI dashboard + landing page template built with React (Vite) and Next.js. Clean, modern, and production-ready." />
|
||||||
|
<meta property="og:url" content="https://shadcnstore.com/templates/dashboard/shadcn-dashboard-landing-template/" />
|
||||||
|
<!-- Use an absolute URL so scrapers can fetch the image reliably -->
|
||||||
|
<meta property="og:image" content="https://shadcnstore.com/templates/dashboard/shadcn-dashboard-landing-template/og-image.png" />
|
||||||
|
<meta property="og:image:alt" content="Screenshot of the Shadcn Dashboard & Landing Template" />
|
||||||
|
<!-- Optional: dimensions (leave generic if not exact) -->
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Shadcn Dashboard & Landing Template" />
|
||||||
|
<meta name="twitter:description" content="Open-source Shadcn UI dashboard + landing page template built with React (Vite) and Next.js. Clean, modern, and production-ready." />
|
||||||
|
<meta name="twitter:image" content="https://shadcnstore.com/templates/dashboard/shadcn-dashboard-landing-template/og-image.png" />
|
||||||
|
<meta name="twitter:image:alt" content="Screenshot of the Shadcn Dashboard & Landing Template" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4175
package-lock.json
generated
88
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "eventhubfrontadmin",
|
"name": "shadcn-dashboard-vite",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -10,30 +10,68 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ra-data-simple-rest": "^5.14.6",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"react": "^19.2.5",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"react-admin": "^5.14.6",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"react-dom": "^19.2.5"
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"add": "^2.0.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.536.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-day-picker": "^9.8.1",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
|
"react-resizable-panels": "^3.0.4",
|
||||||
|
"react-router-dom": "^7.7.1",
|
||||||
|
"recharts": "2.15.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.0.15",
|
||||||
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^9.30.1",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.2.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^17.5.0",
|
"globals": "^16.3.0",
|
||||||
"typescript": "~6.0.2",
|
"tw-animate-css": "^1.3.6",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^8.0.10"
|
"typescript-eslint": "^8.35.1",
|
||||||
},
|
"vite": "^7.0.4"
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"dev:cluster": "vite",
|
|
||||||
"dev:direct": "vite --mode development.direct",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4495
pnpm-lock.yaml
generated
Normal file
BIN
public/apps.png
Normal file
|
After Width: | Height: | Size: 842 KiB |
BIN
public/customizer.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
public/dashboard-dark.png
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
public/dashboard-light.png
Normal file
|
After Width: | Height: | Size: 475 KiB |
BIN
public/dashboard.png
Normal file
|
After Width: | Height: | Size: 768 KiB |
BIN
public/favicon-dark.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 655 B |
|
Before Width: | Height: | Size: 9.3 KiB |
BIN
public/feature-1-dark.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/feature-1-light.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/feature-2-dark.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
public/feature-2-light.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/hero-images-container.png
Normal file
|
After Width: | Height: | Size: 938 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
BIN
public/og-image.png
Normal file
|
After Width: | Height: | Size: 909 KiB |
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
204
src/App.css
@@ -1,184 +1,42 @@
|
|||||||
.counter {
|
#root {
|
||||||
font-size: 16px;
|
max-width: 1280px;
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
padding: 2rem;
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#docs {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
border-right: 1px solid var(--border);
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#next-steps ul {
|
.card {
|
||||||
list-style: none;
|
padding: 2em;
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#spacer {
|
.read-the-docs {
|
||||||
height: 88px;
|
color: #888;
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
border-left-color: var(--border);
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
right: 0;
|
|
||||||
border-right-color: var(--border);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/App.tsx
@@ -1,82 +1,30 @@
|
|||||||
import { Admin, Resource, ListGuesser, fetchUtils } from 'react-admin';
|
import { BrowserRouter as Router } from 'react-router-dom'
|
||||||
import simpleRestProvider from 'ra-data-simple-rest';
|
import { ThemeProvider } from '@/components/theme-provider'
|
||||||
|
import { SidebarConfigProvider } from '@/contexts/sidebar-context'
|
||||||
|
import { AppRouter } from '@/components/router/app-router'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { initGTM } from '@/utils/analytics'
|
||||||
|
|
||||||
// Кастомный httpClient, добавляющий X-Total-Count, если его нет
|
// Get basename from environment (for deployment) or use empty string for development
|
||||||
const httpClient = (url: string, options: any = {}) => {
|
const basename = import.meta.env.VITE_BASENAME || ''
|
||||||
// Добавляем JWT токен
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
options.headers = new Headers({
|
|
||||||
...options.headers,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchUtils.fetchJson(url, options).then((response) => {
|
function App() {
|
||||||
const { headers, json } = response;
|
// Initialize GTM on app load
|
||||||
// Если это GET-запрос и ответ - массив, добавляем X-Total-Count
|
useEffect(() => {
|
||||||
if (
|
initGTM();
|
||||||
!options.method || options.method === 'GET'
|
}, []);
|
||||||
) {
|
|
||||||
if (Array.isArray(json)) {
|
|
||||||
// Создаём новый объект Response с добавленным заголовком
|
|
||||||
const newHeaders = new Headers(headers);
|
|
||||||
if (!newHeaders.has('X-Total-Count')) {
|
|
||||||
newHeaders.set('X-Total-Count', json.length.toString());
|
|
||||||
}
|
|
||||||
return Promise.resolve({
|
|
||||||
status: response.status,
|
|
||||||
headers: newHeaders,
|
|
||||||
body: json,
|
|
||||||
json: json,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataProvider = simpleRestProvider('/api', httpClient);
|
return (
|
||||||
|
<div className="font-sans antialiased" style={{ fontFamily: 'var(--font-inter)' }}>
|
||||||
|
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||||
|
<SidebarConfigProvider>
|
||||||
|
<Router basename={basename}>
|
||||||
|
<AppRouter />
|
||||||
|
</Router>
|
||||||
|
</SidebarConfigProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const authProvider = {
|
export default App
|
||||||
login: ({ username, password }: any) => {
|
|
||||||
return fetch('/v1/admin/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: username, password }),
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.ok) return res.json();
|
|
||||||
throw new Error(res.statusText);
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
localStorage.setItem('token', data.token);
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
logout: () => {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
checkAuth: () =>
|
|
||||||
localStorage.getItem('token') ? Promise.resolve() : Promise.reject(),
|
|
||||||
checkError: (error: any) => {
|
|
||||||
if (error.status === 401) {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
return Promise.reject();
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
getPermissions: () => Promise.resolve(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => (
|
|
||||||
<Admin dataProvider={dataProvider} authProvider={authProvider}>
|
|
||||||
<Resource name="users" list={ListGuesser} />
|
|
||||||
<Resource name="events" list={ListGuesser} />
|
|
||||||
<Resource name="complaints" list={ListGuesser} />
|
|
||||||
<Resource name="bugs" list={ListGuesser} />
|
|
||||||
</Admin>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
export function ForgotPasswordForm2({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"form">) {
|
||||||
|
return (
|
||||||
|
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Forgot your password?</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
Enter your email address and we'll send you a link to reset your password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Send Reset Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Remember your password?{" "}
|
||||||
|
<a href="/auth/sign-in-2" className="underline underline-offset-4">
|
||||||
|
Back to sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/app/auth/forgot-password-2/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ForgotPasswordForm2 } from "./components/forgot-password-form-2"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export default function ForgotPassword2Page() {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||||
|
<div className="flex justify-center gap-2 md:justify-start">
|
||||||
|
<a href="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
ShadcnStore
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<ForgotPasswordForm2 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted relative hidden lg:block">
|
||||||
|
<img
|
||||||
|
src="https://ui.shadcn.com/placeholder.svg"
|
||||||
|
alt="Image"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.95] dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export function ForgotPasswordForm3({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<form className="p-6 md:p-8">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<a href="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl">ShadcnStore</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Forgot your password?</h1>
|
||||||
|
<p className="text-muted-foreground text-balance">
|
||||||
|
Enter your email to reset your ShadcnStore account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Send Reset Link
|
||||||
|
</Button>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Remember your password?{" "}
|
||||||
|
<a href="/auth/sign-in-3" className="underline underline-offset-4">
|
||||||
|
Back to sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="bg-muted relative hidden md:block">
|
||||||
|
<img
|
||||||
|
src="https://ui.shadcn.com/placeholder.svg"
|
||||||
|
alt="Image"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.95] dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||||
|
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||||
|
and <a href="#">Privacy Policy</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/app/auth/forgot-password-3/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ForgotPasswordForm3 } from "./components/forgot-password-form-3"
|
||||||
|
|
||||||
|
export default function ForgotPassword3Page() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<ForgotPasswordForm3 className="w-full max-w-sm md:max-w-4xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
export function ForgotPasswordForm1({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl">Forgot your password?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email address and we'll send you a link to reset your password
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Send Reset Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Remember your password?{" "}
|
||||||
|
<a href="/auth/sign-in" className="underline underline-offset-4">
|
||||||
|
Back to sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/app/auth/forgot-password/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ForgotPasswordForm1 } from "./components/forgot-password-form-1"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||||
|
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||||
|
<a href="/" className="flex items-center gap-2 self-center font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
ShadcnStore
|
||||||
|
</a>
|
||||||
|
<ForgotPasswordForm1 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
src/app/auth/sign-in-2/components/login-form-2.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
export function LoginForm2({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"form">) {
|
||||||
|
return (
|
||||||
|
<form className={cn("flex flex-col gap-6", className)} {...props} action="/dashboard">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Login to your account</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
Enter your email below to login to your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" type="email" placeholder="test@example.com" defaultValue="test@example.com" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<a
|
||||||
|
href="/auth/forgot-password-2"
|
||||||
|
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Input id="password" type="password" defaultValue="password" required />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||||
|
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Login with GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<a href="/auth/sign-up-2" className="underline underline-offset-4">
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/app/auth/sign-in-2/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { LoginForm2 } from "./components/login-form-2"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||||
|
<div className="flex justify-center gap-2 md:justify-start">
|
||||||
|
<a href="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
ShadcnStore
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<LoginForm2 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted relative hidden lg:block">
|
||||||
|
<img
|
||||||
|
src="https://ui.shadcn.com/placeholder.svg"
|
||||||
|
alt="Image"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.95] dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
src/app/auth/sign-in-3/components/login-form-3.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export function LoginForm3({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<form className="p-6 md:p-8" action="/dashboard">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<a href="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl">ShadcnStore</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Welcome back</h1>
|
||||||
|
<p className="text-muted-foreground text-balance">
|
||||||
|
Login to your ShadcnStore account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="test@example.com"
|
||||||
|
defaultValue="test@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<a
|
||||||
|
href="/auth/forgot-password-3"
|
||||||
|
className="ml-auto text-sm underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Input id="password" type="password" defaultValue="password" required />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||||
|
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Login with Apple</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Login with Google</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Login with Meta</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<a href="/auth/sign-up-3" className="underline underline-offset-4">
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="bg-muted relative hidden md:block">
|
||||||
|
<img
|
||||||
|
src="https://ui.shadcn.com/placeholder.svg"
|
||||||
|
alt="Image"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.95] dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||||
|
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||||
|
and <a href="#">Privacy Policy</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/app/auth/sign-in-3/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { LoginForm3 } from "./components/login-form-3"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||||
|
<div className="w-full max-w-sm md:max-w-4xl">
|
||||||
|
<LoginForm3 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/app/auth/sign-in/components/login-form-1.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
|
||||||
|
const loginFormSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type LoginFormValues = z.infer<typeof loginFormSchema>
|
||||||
|
|
||||||
|
export function LoginForm1({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
const form = useForm<LoginFormValues>({
|
||||||
|
resolver: zodResolver(loginFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "password",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email below to login to your account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form action="/">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="test@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<a
|
||||||
|
href="/auth/forgot-password"
|
||||||
|
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" className="w-full cursor-pointer" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Login with Google
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<a href="/auth/sign-up" className="underline underline-offset-4">
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||||
|
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||||
|
and <a href="#">Privacy Policy</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/app/auth/sign-in/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { LoginForm1 } from "./components/login-form-1"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||||
|
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||||
|
<a href="/" className="flex items-center gap-2 self-center font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
ShadcnStore
|
||||||
|
</a>
|
||||||
|
<LoginForm1 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/app/auth/sign-up-2/components/signup-form-2.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
|
||||||
|
export function SignupForm2({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"form">) {
|
||||||
|
return (
|
||||||
|
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
Enter your information to create a new account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="firstName">First Name</Label>
|
||||||
|
<Input id="firstName" placeholder="John" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="lastName">Last Name</Label>
|
||||||
|
<Input id="lastName" placeholder="Doe" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input id="password" type="password" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input id="confirmPassword" type="password" required />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox id="terms" required />
|
||||||
|
<Label htmlFor="terms" className="text-sm">
|
||||||
|
I agree to the{" "}
|
||||||
|
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||||
|
Terms of Service
|
||||||
|
</a>{" "}
|
||||||
|
and{" "}
|
||||||
|
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||||
|
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign up with GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/auth/sign-in-2" className="underline underline-offset-4">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/app/auth/sign-up-2/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { SignupForm2 } from "./components/signup-form-2"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export default function SignUp2Page() {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||||
|
<div className="flex justify-center gap-2 md:justify-start">
|
||||||
|
<a href="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
ShadcnStore
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<SignupForm2 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted relative hidden lg:block">
|
||||||
|
<img
|
||||||
|
src="https://ui.shadcn.com/placeholder.svg"
|
||||||
|
alt="Image"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.95] dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/app/auth/sign-up-3/components/signup-form-3.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export function SignupForm3({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<form className="p-6 md:p-8">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<a href="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl">ShadcnStore</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||||
|
<p className="text-muted-foreground text-balance">
|
||||||
|
Enter your information to create a new account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="firstName">First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
placeholder="John"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="lastName">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
placeholder="Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input id="password" type="password" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input id="confirmPassword" type="password" required />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox id="terms" required />
|
||||||
|
<Label htmlFor="terms" className="text-sm">
|
||||||
|
I agree to the{" "}
|
||||||
|
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||||
|
Terms of Service
|
||||||
|
</a>{" "}
|
||||||
|
and{" "}
|
||||||
|
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||||
|
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Sign up with Apple</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Sign up with Google</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Sign up with Meta</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/auth/sign-in-3" className="underline underline-offset-4">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="bg-muted relative hidden md:block">
|
||||||
|
<img
|
||||||
|
src="https://ui.shadcn.com/placeholder.svg"
|
||||||
|
alt="Image"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.95] dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||||
|
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||||
|
and <a href="#">Privacy Policy</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/app/auth/sign-up-3/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { SignupForm3 } from "./components/signup-form-3"
|
||||||
|
|
||||||
|
export default function SignUp3Page() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<SignupForm3 className="w-full max-w-5xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
src/app/auth/sign-up/components/signup-form-1.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
|
||||||
|
const signupFormSchema = z.object({
|
||||||
|
firstName: z.string().min(1, "First name is required"),
|
||||||
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||||
|
confirmPassword: z.string().min(6, "Please confirm your password"),
|
||||||
|
terms: z.boolean().refine(val => val === true, "You must agree to the terms"),
|
||||||
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
})
|
||||||
|
|
||||||
|
type SignupFormValues = z.infer<typeof signupFormSchema>
|
||||||
|
|
||||||
|
export function SignupForm1({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
const form = useForm<SignupFormValues>({
|
||||||
|
resolver: zodResolver(signupFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
terms: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(data: SignupFormValues) {
|
||||||
|
console.log("Signup attempt:", data)
|
||||||
|
// Here you would typically handle the signup
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl">Create Account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your information to create a new account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="firstName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>First Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="John" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Doe" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Confirm Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="terms"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-start space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="text-sm">
|
||||||
|
I agree to the terms of service and privacy policy
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" className="w-full cursor-pointer" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign up with Google
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/auth/sign-in" className="underline underline-offset-4">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||||
|
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||||
|
and <a href="#">Privacy Policy</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/app/auth/sign-up/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { SignupForm1 } from "./components/signup-form-1"
|
||||||
|
import { Logo } from "@/components/logo"
|
||||||
|
|
||||||
|
export default function SignUpPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||||
|
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||||
|
<a href="/" className="flex items-center gap-2 self-center font-medium">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||||
|
<Logo size={24} />
|
||||||
|
</div>
|
||||||
|
ShadcnStore
|
||||||
|
</a>
|
||||||
|
<SignupForm1 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
347
src/app/calendar/components/calendar-main.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
Users,
|
||||||
|
MoreHorizontal,
|
||||||
|
Search,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
|
ChevronDown,
|
||||||
|
Menu
|
||||||
|
} from "lucide-react"
|
||||||
|
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { type CalendarEvent } from "../types"
|
||||||
|
|
||||||
|
// Import data
|
||||||
|
import eventsData from "../data/events.json"
|
||||||
|
|
||||||
|
interface CalendarMainProps {
|
||||||
|
selectedDate?: Date
|
||||||
|
onDateSelect?: (date: Date) => void
|
||||||
|
onMenuClick?: () => void
|
||||||
|
events?: CalendarEvent[]
|
||||||
|
onEventClick?: (event: CalendarEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarMain({ selectedDate, onDateSelect, onMenuClick, events, onEventClick }: CalendarMainProps) {
|
||||||
|
// Convert JSON events to CalendarEvent objects with proper Date objects, fallback to imported data
|
||||||
|
const sampleEvents: CalendarEvent[] = events || eventsData.map(event => ({
|
||||||
|
...event,
|
||||||
|
date: new Date(event.date),
|
||||||
|
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
||||||
|
}))
|
||||||
|
|
||||||
|
const [currentDate, setCurrentDate] = useState(selectedDate || new Date())
|
||||||
|
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
|
||||||
|
const [showEventDialog, setShowEventDialog] = useState(false)
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
|
||||||
|
const monthStart = startOfMonth(currentDate)
|
||||||
|
const monthEnd = endOfMonth(currentDate)
|
||||||
|
|
||||||
|
// Extend to show full weeks (including previous/next month days)
|
||||||
|
const calendarStart = new Date(monthStart)
|
||||||
|
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
|
||||||
|
|
||||||
|
const calendarEnd = new Date(monthEnd)
|
||||||
|
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
|
||||||
|
|
||||||
|
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
||||||
|
|
||||||
|
const getEventsForDay = (date: Date) => {
|
||||||
|
return sampleEvents.filter(event => isSameDay(event.date, date))
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateMonth = (direction: "prev" | "next") => {
|
||||||
|
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToToday = () => {
|
||||||
|
setCurrentDate(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventClick = (event: CalendarEvent) => {
|
||||||
|
if (onEventClick) {
|
||||||
|
onEventClick(event)
|
||||||
|
} else {
|
||||||
|
setSelectedEvent(event)
|
||||||
|
setShowEventDialog(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCalendarGrid = () => {
|
||||||
|
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 bg-background">
|
||||||
|
{/* Calendar Header */}
|
||||||
|
<div className="grid grid-cols-7 border-b">
|
||||||
|
{weekDays.map(day => (
|
||||||
|
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Body */}
|
||||||
|
<div className="grid grid-cols-7 flex-1">
|
||||||
|
{calendarDays.map(day => {
|
||||||
|
const dayEvents = getEventsForDay(day)
|
||||||
|
const isCurrentMonth = isSameMonth(day, currentDate)
|
||||||
|
const isDayToday = isToday(day)
|
||||||
|
const isSelected = selectedDate && isSameDay(day, selectedDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day.toISOString()}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[120px] border-r border-b last:border-r-0 p-2 cursor-pointer transition-colors",
|
||||||
|
isCurrentMonth ? "bg-background hover:bg-accent/50" : "bg-muted/30 text-muted-foreground",
|
||||||
|
isSelected && "ring-2 ring-primary ring-inset",
|
||||||
|
isDayToday && "bg-accent/20"
|
||||||
|
)}
|
||||||
|
onClick={() => onDateSelect?.(day)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isDayToday && "bg-primary text-primary-foreground rounded-md w-6 h-6 flex items-center justify-center text-xs"
|
||||||
|
)}>
|
||||||
|
{format(day, 'd')}
|
||||||
|
</span>
|
||||||
|
{dayEvents.length > 2 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
+{dayEvents.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayEvents.slice(0, 2).map(event => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className={cn(
|
||||||
|
"text-xs p-1 rounded-sm text-white cursor-pointer truncate",
|
||||||
|
event.color
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEventClick(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span className="truncate">{event.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderListView = () => {
|
||||||
|
const upcomingEvents = sampleEvents
|
||||||
|
.filter(event => event.date >= new Date())
|
||||||
|
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{upcomingEvents.map(event => (
|
||||||
|
<Card key={event.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleEventClick(event)}>
|
||||||
|
<CardContent className="px-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={cn("w-3 h-3 rounded-full mt-1.5", event.color)} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">{event.title}</h3>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center flex-wrap gap-1">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
{format(event.date, 'MMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center flex-wrap gap-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{event.time}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center flex-wrap gap-1">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{event.location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{event.attendees.slice(0, 3).map((attendee, index) => (
|
||||||
|
<Avatar key={index} className="border-2 border-background">
|
||||||
|
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="cursor-pointer">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col flex-wrap gap-4 p-6 border-b md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="xl:hidden cursor-pointer"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
>
|
||||||
|
<Menu className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigateMonth("prev")} className="cursor-pointer">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigateMonth("next")} className="cursor-pointer">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={goToToday} className="cursor-pointer">
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{format(currentDate, 'MMMM yyyy')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search events..." className="pl-10 w-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="cursor-pointer">
|
||||||
|
{viewMode === "month" && <Grid3X3 className="w-4 h-4 mr-2" />}
|
||||||
|
{viewMode === "list" && <List className="w-4 h-4 mr-2" />}
|
||||||
|
{viewMode.charAt(0).toUpperCase() + viewMode.slice(1)}
|
||||||
|
<ChevronDown className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => setViewMode("month")} className="cursor-pointer">
|
||||||
|
<Grid3X3 className="w-4 h-4 mr-2" />
|
||||||
|
Month
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setViewMode("list")} className="cursor-pointer">
|
||||||
|
<List className="w-4 h-4 mr-2" />
|
||||||
|
List
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Content */}
|
||||||
|
{viewMode === "month" ? renderCalendarGrid() : renderListView()}
|
||||||
|
|
||||||
|
{/* Event Detail Dialog */}
|
||||||
|
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{selectedEvent?.title || "Event Details"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
View and manage this calendar event
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedEvent && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{format(selectedEvent.date, 'EEEE, MMMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{selectedEvent.time} ({selectedEvent.duration})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{selectedEvent.location}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Attendees:</span>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{selectedEvent.attendees.map((attendee: string, index: number) => (
|
||||||
|
<Avatar key={index} className="w-6 h-6 border-2 border-background">
|
||||||
|
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
|
||||||
|
{selectedEvent.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button variant="outline" className="flex-1 cursor-pointer" onClick={() => {
|
||||||
|
setShowEventDialog(false)
|
||||||
|
}}>Edit</Button>
|
||||||
|
<Button variant="destructive" className="flex-1 cursor-pointer" onClick={() => {
|
||||||
|
setShowEventDialog(false)
|
||||||
|
}}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/app/calendar/components/calendar-sidebar.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
|
||||||
|
import { Calendars } from "./calendars"
|
||||||
|
import { DatePicker } from "./date-picker"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
interface CalendarSidebarProps {
|
||||||
|
selectedDate?: Date
|
||||||
|
onDateSelect?: (date: Date) => void
|
||||||
|
onNewCalendar?: () => void
|
||||||
|
onNewEvent?: () => void
|
||||||
|
events?: Array<{ date: Date; count: number }>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarSidebar({
|
||||||
|
selectedDate,
|
||||||
|
onDateSelect,
|
||||||
|
onNewCalendar,
|
||||||
|
onNewEvent,
|
||||||
|
events = [],
|
||||||
|
className
|
||||||
|
}: CalendarSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full bg-background rounded-lg ${className}`}>
|
||||||
|
{/* Add New Event Button */}
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<Button
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onClick={onNewEvent}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add New Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Picker */}
|
||||||
|
<DatePicker
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
onDateSelect={onDateSelect}
|
||||||
|
events={events}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Calendars */}
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<Calendars
|
||||||
|
onNewCalendar={onNewCalendar}
|
||||||
|
onCalendarToggle={(calendarId, visible) => {
|
||||||
|
console.log(`Calendar ${calendarId} visibility: ${visible}`)
|
||||||
|
}}
|
||||||
|
onCalendarEdit={(calendarId) => {
|
||||||
|
console.log(`Edit calendar: ${calendarId}`)
|
||||||
|
}}
|
||||||
|
onCalendarDelete={(calendarId) => {
|
||||||
|
console.log(`Delete calendar: ${calendarId}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start cursor-pointer"
|
||||||
|
onClick={onNewCalendar}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Calendar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
381
src/app/calendar/components/calendar-unified.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
|
ChevronDown,
|
||||||
|
Menu,
|
||||||
|
Plus
|
||||||
|
} from "lucide-react"
|
||||||
|
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { type CalendarEvent } from "../types"
|
||||||
|
|
||||||
|
// Import data
|
||||||
|
import eventsData from "../data/events.json"
|
||||||
|
import calendarsData from "../data/calendars.json"
|
||||||
|
|
||||||
|
interface CalendarMainProps {
|
||||||
|
eventDates?: Array<{ date: Date; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarMain({ eventDates = [] }: CalendarMainProps) {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
|
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
|
||||||
|
const [showEventDialog, setShowEventDialog] = useState(false)
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
|
||||||
|
|
||||||
|
// Convert JSON events to CalendarEvent objects with proper Date objects
|
||||||
|
const sampleEvents: CalendarEvent[] = eventsData.map(event => ({
|
||||||
|
...event,
|
||||||
|
date: new Date(event.date),
|
||||||
|
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
||||||
|
}))
|
||||||
|
|
||||||
|
const monthStart = startOfMonth(currentDate)
|
||||||
|
const monthEnd = endOfMonth(currentDate)
|
||||||
|
|
||||||
|
// Extend to show full weeks (including previous/next month days)
|
||||||
|
const calendarStart = new Date(monthStart)
|
||||||
|
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
|
||||||
|
|
||||||
|
const calendarEnd = new Date(monthEnd)
|
||||||
|
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
|
||||||
|
|
||||||
|
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
||||||
|
|
||||||
|
const getEventsForDay = (date: Date) => {
|
||||||
|
return sampleEvents.filter(event => isSameDay(event.date, date))
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateMonth = (direction: "prev" | "next") => {
|
||||||
|
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToToday = () => {
|
||||||
|
setCurrentDate(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventClick = (event: CalendarEvent) => {
|
||||||
|
setSelectedEvent(event)
|
||||||
|
setShowEventDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateSelect = (date: Date) => {
|
||||||
|
setSelectedDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewCalendar = () => {
|
||||||
|
console.log("Creating new calendar")
|
||||||
|
// In a real app, this would open a new calendar form
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewEvent = () => {
|
||||||
|
console.log("Creating new event")
|
||||||
|
// In a real app, this would open event form
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCalendarGrid = () => {
|
||||||
|
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 bg-background">
|
||||||
|
{/* Calendar Header */}
|
||||||
|
<div className="grid grid-cols-7 border-b">
|
||||||
|
{weekDays.map(day => (
|
||||||
|
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className="grid grid-cols-7 min-h-[600px]">
|
||||||
|
{calendarDays.map((day) => {
|
||||||
|
const dayEvents = getEventsForDay(day)
|
||||||
|
const isCurrentMonth = isSameMonth(day, currentDate)
|
||||||
|
const isDayToday = isToday(day)
|
||||||
|
const isSelected = isSameDay(day, selectedDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day.toISOString()}
|
||||||
|
className={cn(
|
||||||
|
"relative border-r border-b last:border-r-0 p-2 min-h-[120px] hover:bg-muted/50 cursor-pointer transition-colors",
|
||||||
|
!isCurrentMonth && "text-muted-foreground bg-muted/20",
|
||||||
|
isDayToday && "bg-blue-50 dark:bg-blue-900/20",
|
||||||
|
isSelected && "bg-blue-100 dark:bg-blue-800/30"
|
||||||
|
)}
|
||||||
|
onClick={() => handleDateSelect(day)}
|
||||||
|
>
|
||||||
|
{/* Date Number */}
|
||||||
|
<div className={cn(
|
||||||
|
"text-sm font-medium mb-1",
|
||||||
|
isDayToday && "text-blue-600 dark:text-blue-400"
|
||||||
|
)}>
|
||||||
|
{format(day, 'd')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayEvents.slice(0, 3).map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity truncate",
|
||||||
|
event.color
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEventClick(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.time} {event.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 3 && (
|
||||||
|
<div className="text-xs text-muted-foreground px-2">
|
||||||
|
+{dayEvents.length - 3} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSidebar = () => (
|
||||||
|
<div className="w-full h-full bg-background border-r">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold">Calendar</h2>
|
||||||
|
<Button size="sm" onClick={handleNewEvent}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Picker */}
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={(date) => date && handleDateSelect(date)}
|
||||||
|
className="rounded-md border"
|
||||||
|
modifiers={{
|
||||||
|
eventDay: eventDates.map(ed => ed.date)
|
||||||
|
}}
|
||||||
|
modifiersStyles={{
|
||||||
|
eventDay: { fontWeight: 'bold' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Calendars List */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium">My Calendars</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleNewCalendar}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{calendarsData.map((calendar) => (
|
||||||
|
<div key={calendar.id} className="flex items-center space-x-2">
|
||||||
|
<div className={cn("w-3 h-3 rounded-full", calendar.color)} />
|
||||||
|
<span className="text-sm">{calendar.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg bg-background relative">
|
||||||
|
<div className="flex min-h-[800px]">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<div className="hidden xl:block w-80 flex-shrink-0">
|
||||||
|
{renderSidebar()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Calendar Panel */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Calendar Toolbar */}
|
||||||
|
<div className="border-b bg-background px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="xl:hidden"
|
||||||
|
onClick={() => setShowCalendarSheet(true)}
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Month Navigation */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigateMonth("prev")}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold min-w-[140px] text-center">
|
||||||
|
{format(currentDate, 'MMMM yyyy')}
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigateMonth("next")}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={goToToday}>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="hidden sm:flex items-center space-x-2">
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs">
|
||||||
|
<Search className="h-4 w-4 mr-1" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Grid3X3 className="h-4 w-4 mr-1" />
|
||||||
|
{viewMode === "month" ? "Month" : viewMode === "week" ? "Week" : viewMode === "day" ? "Day" : "List"}
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setViewMode("month")}>
|
||||||
|
<Grid3X3 className="h-4 w-4 mr-2" />
|
||||||
|
Month
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setViewMode("week")}>
|
||||||
|
<List className="h-4 w-4 mr-2" />
|
||||||
|
Week
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setViewMode("day")}>
|
||||||
|
<CalendarIcon className="h-4 w-4 mr-2" />
|
||||||
|
Day
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setViewMode("list")}>
|
||||||
|
<List className="h-4 w-4 mr-2" />
|
||||||
|
List
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Content */}
|
||||||
|
{renderCalendarGrid()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile/Tablet Sheet */}
|
||||||
|
<Sheet open={showCalendarSheet} onOpenChange={setShowCalendarSheet}>
|
||||||
|
<SheetContent side="left" className="w-80 p-0">
|
||||||
|
<SheetHeader className="p-4 pb-2">
|
||||||
|
<SheetTitle>Calendar</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Browse dates and manage your calendar events
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
{renderSidebar()}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Event Details Dialog */}
|
||||||
|
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{selectedEvent?.title}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Event details and information
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedEvent && (
|
||||||
|
<div className="space-y-4 pt-4">
|
||||||
|
<div className="flex items-center space-x-2 text-sm">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{selectedEvent.time} • {selectedEvent.duration}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEvent.location && (
|
||||||
|
<div className="flex items-center space-x-2 text-sm">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{selectedEvent.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEvent.attendees.length > 0 && (
|
||||||
|
<div className="flex items-center space-x-2 text-sm">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{selectedEvent.attendees.map((attendee, index) => (
|
||||||
|
<Avatar key={index} className="h-6 w-6">
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{attendee}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEvent.description && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{selectedEvent.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 pt-4">
|
||||||
|
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
|
||||||
|
{selectedEvent.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
src/app/calendar/components/calendar.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CalendarSidebar } from "./calendar-sidebar"
|
||||||
|
import { CalendarMain } from "./calendar-main"
|
||||||
|
import { EventForm } from "./event-form"
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { type CalendarEvent } from "../types"
|
||||||
|
import { useCalendar } from "../use-calendar"
|
||||||
|
|
||||||
|
interface CalendarProps {
|
||||||
|
events: CalendarEvent[]
|
||||||
|
eventDates: Array<{ date: Date; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Calendar({ events, eventDates }: CalendarProps) {
|
||||||
|
const calendar = useCalendar(events)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="border rounded-lg bg-background relative">
|
||||||
|
<div className="flex min-h-[800px]">
|
||||||
|
{/* Desktop Sidebar - Hidden on mobile/tablet, shown on extra large screens */}
|
||||||
|
<div className="hidden xl:block w-80 flex-shrink-0 border-r">
|
||||||
|
<CalendarSidebar
|
||||||
|
selectedDate={calendar.selectedDate}
|
||||||
|
onDateSelect={calendar.handleDateSelect}
|
||||||
|
onNewCalendar={calendar.handleNewCalendar}
|
||||||
|
onNewEvent={calendar.handleNewEvent}
|
||||||
|
events={eventDates}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Calendar Panel */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CalendarMain
|
||||||
|
selectedDate={calendar.selectedDate}
|
||||||
|
onDateSelect={calendar.handleDateSelect}
|
||||||
|
onMenuClick={() => calendar.setShowCalendarSheet(true)}
|
||||||
|
events={calendar.events}
|
||||||
|
onEventClick={calendar.handleEditEvent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile/Tablet Sheet - Positioned relative to calendar container */}
|
||||||
|
<Sheet open={calendar.showCalendarSheet} onOpenChange={calendar.setShowCalendarSheet}>
|
||||||
|
<SheetContent side="left" className="w-80 p-0" style={{ position: 'absolute' }}>
|
||||||
|
<SheetHeader className="p-4 pb-2">
|
||||||
|
<SheetTitle>Calendar</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Browse dates and manage your calendar events
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<CalendarSidebar
|
||||||
|
selectedDate={calendar.selectedDate}
|
||||||
|
onDateSelect={calendar.handleDateSelect}
|
||||||
|
onNewCalendar={calendar.handleNewCalendar}
|
||||||
|
onNewEvent={calendar.handleNewEvent}
|
||||||
|
events={eventDates}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Form Dialog */}
|
||||||
|
<EventForm
|
||||||
|
event={calendar.editingEvent}
|
||||||
|
open={calendar.showEventForm}
|
||||||
|
onOpenChange={calendar.setShowEventForm}
|
||||||
|
onSave={calendar.handleSaveEvent}
|
||||||
|
onDelete={calendar.handleDeleteEvent}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
src/app/calendar/components/calendars.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Check, ChevronRight, Plus, Eye, EyeOff, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface CalendarItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
visible: boolean
|
||||||
|
type: "personal" | "work" | "shared"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarGroup {
|
||||||
|
name: string
|
||||||
|
items: CalendarItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarsProps {
|
||||||
|
calendars?: {
|
||||||
|
name: string
|
||||||
|
items: string[]
|
||||||
|
}[]
|
||||||
|
onCalendarToggle?: (calendarId: string, visible: boolean) => void
|
||||||
|
onCalendarEdit?: (calendarId: string) => void
|
||||||
|
onCalendarDelete?: (calendarId: string) => void
|
||||||
|
onNewCalendar?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced calendar data with colors and visibility
|
||||||
|
const enhancedCalendars: CalendarGroup[] = [
|
||||||
|
{
|
||||||
|
name: "My Calendars",
|
||||||
|
items: [
|
||||||
|
{ id: "personal", name: "Personal", color: "bg-blue-500", visible: true, type: "personal" },
|
||||||
|
{ id: "work", name: "Work", color: "bg-green-500", visible: true, type: "work" },
|
||||||
|
{ id: "family", name: "Family", color: "bg-pink-500", visible: true, type: "personal" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Favorites",
|
||||||
|
items: [
|
||||||
|
{ id: "holidays", name: "Holidays", color: "bg-red-500", visible: true, type: "shared" },
|
||||||
|
{ id: "birthdays", name: "Birthdays", color: "bg-purple-500", visible: true, type: "personal" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Other",
|
||||||
|
items: [
|
||||||
|
{ id: "travel", name: "Travel", color: "bg-orange-500", visible: false, type: "personal" },
|
||||||
|
{ id: "reminders", name: "Reminders", color: "bg-yellow-500", visible: true, type: "personal" },
|
||||||
|
{ id: "deadlines", name: "Deadlines", color: "bg-red-600", visible: true, type: "work" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Calendars({
|
||||||
|
onCalendarToggle,
|
||||||
|
onCalendarEdit,
|
||||||
|
onCalendarDelete,
|
||||||
|
onNewCalendar
|
||||||
|
}: CalendarsProps) {
|
||||||
|
const [calendarData, setCalendarData] = useState(enhancedCalendars)
|
||||||
|
|
||||||
|
const handleToggleVisibility = (calendarId: string) => {
|
||||||
|
setCalendarData(prev => prev.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.map(item =>
|
||||||
|
item.id === calendarId
|
||||||
|
? { ...item, visible: !item.visible }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
})))
|
||||||
|
|
||||||
|
const calendar = calendarData.flatMap(g => g.items).find(c => c.id === calendarId)
|
||||||
|
if (calendar) {
|
||||||
|
onCalendarToggle?.(calendarId, !calendar.visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{calendarData.map((calendar, index) => (
|
||||||
|
<div key={calendar.name}>
|
||||||
|
<Collapsible
|
||||||
|
defaultOpen={index === 0}
|
||||||
|
className="group/collapsible"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger className="flex items-center justify-between w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-md cursor-pointer">
|
||||||
|
<span className="text-sm font-medium">{calendar.name}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{index === 0 && (
|
||||||
|
<div
|
||||||
|
className="h-5 w-5 flex items-center justify-center opacity-0 group-hover/collapsible:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onNewCalendar?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{calendar.items.map((item) => (
|
||||||
|
<div key={item.id} className="group/calendar-item">
|
||||||
|
<div className="flex items-center justify-between p-2 hover:bg-accent/50 rounded-md">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
{/* Calendar Color & Visibility Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleVisibility(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-square size-4 shrink-0 items-center justify-center rounded-sm border transition-all cursor-pointer",
|
||||||
|
item.visible
|
||||||
|
? cn("border-transparent text-white", item.color)
|
||||||
|
: "border-border bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.visible && <Check className="size-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Calendar Name */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-1 truncate text-sm cursor-pointer",
|
||||||
|
!item.visible && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => handleToggleVisibility(item.id)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Visibility Icon */}
|
||||||
|
<div className="opacity-0 group-hover/calendar-item:opacity-100">
|
||||||
|
{item.visible ? (
|
||||||
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* More Options */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className="h-5 w-5 flex items-center justify-center p-0 opacity-0 group-hover/calendar-item:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="right">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onCalendarEdit?.(item.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
Edit calendar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleToggleVisibility(item.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{item.visible ? "Hide" : "Show"} calendar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onCalendarDelete?.(item.id)}
|
||||||
|
className="cursor-pointer text-destructive"
|
||||||
|
>
|
||||||
|
Delete calendar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/app/calendar/components/date-picker.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
|
||||||
|
interface DatePickerProps {
|
||||||
|
selectedDate?: Date
|
||||||
|
onDateSelect?: (date: Date) => void
|
||||||
|
events?: Array<{ date: Date; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatePicker({ selectedDate, onDateSelect, events = [] }: DatePickerProps) {
|
||||||
|
const [date, setDate] = useState<Date | undefined>(selectedDate || new Date())
|
||||||
|
|
||||||
|
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||||
|
if (selectedDate) {
|
||||||
|
setDate(selectedDate)
|
||||||
|
onDateSelect?.(selectedDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of dates with events for styling
|
||||||
|
const eventDates = events.reduce((acc, event) => {
|
||||||
|
const dateKey = event.date.toDateString()
|
||||||
|
acc[dateKey] = event.count
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, number>)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={handleDateSelect}
|
||||||
|
className="w-full [&_[role=gridcell]_button]:cursor-pointer [&_button]:cursor-pointer"
|
||||||
|
modifiers={{
|
||||||
|
hasEvents: (date) => {
|
||||||
|
const eventCount = eventDates[date.toDateString()]
|
||||||
|
return Boolean(eventCount && eventCount > 0)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
modifiersClassNames={{
|
||||||
|
hasEvents: "relative after:absolute after:bottom-1 after:right-1 after:w-1.5 after:h-1.5 after:bg-primary after:rounded-full"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
339
src/app/calendar/components/event-form.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { CalendarIcon, Clock, MapPin, Users, Type, Tag } from "lucide-react"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { type CalendarEvent } from "../types"
|
||||||
|
|
||||||
|
interface EventFormProps {
|
||||||
|
event?: CalendarEvent | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSave: (event: Partial<CalendarEvent>) => void
|
||||||
|
onDelete?: (eventId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTypes = [
|
||||||
|
{ value: "meeting", label: "Meeting", color: "bg-blue-500" },
|
||||||
|
{ value: "event", label: "Event", color: "bg-green-500" },
|
||||||
|
{ value: "personal", label: "Personal", color: "bg-pink-500" },
|
||||||
|
{ value: "task", label: "Task", color: "bg-orange-500" },
|
||||||
|
{ value: "reminder", label: "Reminder", color: "bg-purple-500" }
|
||||||
|
]
|
||||||
|
|
||||||
|
const timeSlots = [
|
||||||
|
"9:00 AM", "9:30 AM", "10:00 AM", "10:30 AM", "11:00 AM", "11:30 AM",
|
||||||
|
"12:00 PM", "12:30 PM", "1:00 PM", "1:30 PM", "2:00 PM", "2:30 PM",
|
||||||
|
"3:00 PM", "3:30 PM", "4:00 PM", "4:30 PM", "5:00 PM", "5:30 PM",
|
||||||
|
"6:00 PM", "6:30 PM", "7:00 PM", "7:30 PM", "8:00 PM", "8:30 PM"
|
||||||
|
]
|
||||||
|
|
||||||
|
const durationOptions = [
|
||||||
|
"15 min", "30 min", "45 min", "1 hour", "1.5 hours", "2 hours", "3 hours", "All day"
|
||||||
|
]
|
||||||
|
|
||||||
|
export function EventForm({ event, open, onOpenChange, onSave, onDelete }: EventFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: event?.title || "",
|
||||||
|
date: event?.date || new Date(),
|
||||||
|
time: event?.time || "9:00 AM",
|
||||||
|
duration: event?.duration || "1 hour",
|
||||||
|
type: event?.type || "meeting",
|
||||||
|
location: event?.location || "",
|
||||||
|
description: event?.description || "",
|
||||||
|
attendees: event?.attendees || [],
|
||||||
|
allDay: false,
|
||||||
|
reminder: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const [showCalendar, setShowCalendar] = useState(false)
|
||||||
|
const [newAttendee, setNewAttendee] = useState("")
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const eventData: Partial<CalendarEvent> = {
|
||||||
|
...formData,
|
||||||
|
id: event?.id,
|
||||||
|
color: eventTypes.find(t => t.value === formData.type)?.color || "bg-blue-500"
|
||||||
|
}
|
||||||
|
onSave(eventData)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (event?.id && onDelete) {
|
||||||
|
onDelete(event.id)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAttendee = () => {
|
||||||
|
if (newAttendee.trim() && !formData.attendees.includes(newAttendee.trim())) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
attendees: [...prev.attendees, newAttendee.trim()]
|
||||||
|
}))
|
||||||
|
setNewAttendee("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAttendee = (attendee: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
attendees: prev.attendees.filter(a => a !== attendee)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEventType = eventTypes.find(t => t.value === formData.type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<div className={cn("w-3 h-3 rounded-full", selectedEventType?.color)} />
|
||||||
|
{event ? "Edit Event" : "Create New Event"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{event ? "Make changes to this event" : "Add a new event to your calendar"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Event Title */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="flex items-center gap-2">
|
||||||
|
<Type className="w-4 h-4" />
|
||||||
|
Event Title
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="Enter event title..."
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
className="text-lg font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Type */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
Event Type
|
||||||
|
</Label>
|
||||||
|
<Select value={formData.type} onValueChange={(value) => setFormData(prev => ({ ...prev, type: value as CalendarEvent["type"] }))}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{eventTypes.map(type => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn("w-3 h-3 rounded-full", type.color)} />
|
||||||
|
{type.label}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date and Time */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
Date
|
||||||
|
</Label>
|
||||||
|
<Popover open={showCalendar} onOpenChange={setShowCalendar}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||||
|
{format(formData.date, "PPP")}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={formData.date}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (date) {
|
||||||
|
setFormData(prev => ({ ...prev, date }))
|
||||||
|
setShowCalendar(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Time
|
||||||
|
</Label>
|
||||||
|
<Select value={formData.time} onValueChange={(value) => setFormData(prev => ({ ...prev, time: value }))}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeSlots.map(time => (
|
||||||
|
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration and All Day */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Duration</Label>
|
||||||
|
<Select value={formData.duration} onValueChange={(value) => setFormData(prev => ({ ...prev, duration: value }))}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{durationOptions.map(duration => (
|
||||||
|
<SelectItem key={duration} value={duration}>{duration}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Options</Label>
|
||||||
|
<div className="flex items-center space-x-4 h-10">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="all-day"
|
||||||
|
checked={formData.allDay}
|
||||||
|
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allDay: checked }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="all-day" className="text-sm">All day</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="reminder"
|
||||||
|
checked={formData.reminder}
|
||||||
|
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, reminder: checked }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="reminder" className="text-sm">Reminder</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="location" className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
Location
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="location"
|
||||||
|
placeholder="Add location..."
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, location: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attendees */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
Attendees
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Add attendee..."
|
||||||
|
value={newAttendee}
|
||||||
|
onChange={(e) => setNewAttendee(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === "Enter" && addAttendee()}
|
||||||
|
/>
|
||||||
|
<Button onClick={addAttendee} variant="outline" className="cursor-pointer">Add</Button>
|
||||||
|
</div>
|
||||||
|
{formData.attendees.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{formData.attendees.map((attendee, index) => (
|
||||||
|
<Badge key={index} variant="secondary" className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<Avatar className="w-5 h-5">
|
||||||
|
<AvatarFallback className="text-[10px] font-medium">
|
||||||
|
{attendee.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm">{attendee}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeAttendee(attendee)}
|
||||||
|
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Add description..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-6">
|
||||||
|
<Button onClick={handleSave} className="flex-1 cursor-pointer">
|
||||||
|
{event ? "Update Event" : "Create Event"}
|
||||||
|
</Button>
|
||||||
|
{event && onDelete && (
|
||||||
|
<Button onClick={handleDelete} variant="destructive" className="cursor-pointer">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => onOpenChange(false)} variant="outline" className="cursor-pointer">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
src/app/calendar/components/quick-actions.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
Download,
|
||||||
|
Share,
|
||||||
|
Bell
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
interface QuickActionsProps {
|
||||||
|
onNewEvent?: () => void
|
||||||
|
onNewMeeting?: () => void
|
||||||
|
onNewReminder?: () => void
|
||||||
|
onSettings?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActions({
|
||||||
|
onNewEvent,
|
||||||
|
onNewMeeting,
|
||||||
|
onNewReminder,
|
||||||
|
onSettings
|
||||||
|
}: QuickActionsProps) {
|
||||||
|
const quickStats = [
|
||||||
|
{ label: "Today's Events", value: "3", color: "bg-blue-500" },
|
||||||
|
{ label: "This Week", value: "12", color: "bg-green-500" },
|
||||||
|
{ label: "Pending", value: "2", color: "bg-orange-500" }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{quickStats.map((stat, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${stat.color}`} />
|
||||||
|
<span className="text-sm text-muted-foreground">{stat.label}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{stat.value}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start cursor-pointer"
|
||||||
|
onClick={onNewEvent}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Event
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start cursor-pointer"
|
||||||
|
onClick={onNewMeeting}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 mr-2" />
|
||||||
|
Schedule Meeting
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start cursor-pointer"
|
||||||
|
onClick={onNewReminder}
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
|
Set Reminder
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator className="my-3" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<Share className="w-4 h-4 mr-2" />
|
||||||
|
Share Calendar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start cursor-pointer"
|
||||||
|
onClick={onSettings}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Next Up
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-2 h-2 bg-blue-500 rounded-full mt-2" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">Team Standup</p>
|
||||||
|
<p className="text-xs text-muted-foreground">9:00 AM • Conference Room A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-2 h-2 bg-purple-500 rounded-full mt-2" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">Design Review</p>
|
||||||
|
<p className="text-xs text-muted-foreground">2:00 PM • Virtual</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/app/calendar/data.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type CalendarEvent, type Calendar } from "./types"
|
||||||
|
|
||||||
|
// Import JSON data
|
||||||
|
import eventsData from "./data/events.json"
|
||||||
|
import eventDatesData from "./data/event-dates.json"
|
||||||
|
import calendarsData from "./data/calendars.json"
|
||||||
|
|
||||||
|
// Convert JSON events to CalendarEvent objects with proper Date objects
|
||||||
|
// Always use current month and year, but preserve day and time from JSON
|
||||||
|
export const events: CalendarEvent[] = eventsData.map(event => {
|
||||||
|
const now = new Date()
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
const currentMonth = now.getMonth() // 0-based month
|
||||||
|
|
||||||
|
// Parse the day from the date string (format: "11T09:00:00.000Z")
|
||||||
|
const dayAndTime = event.date.split('T')
|
||||||
|
const day = parseInt(dayAndTime[0])
|
||||||
|
const timeStr = dayAndTime[1] // "09:00:00.000Z"
|
||||||
|
|
||||||
|
// Parse hours and minutes from time string
|
||||||
|
const timeParts = timeStr.split(':')
|
||||||
|
const hours = parseInt(timeParts[0])
|
||||||
|
const minutes = parseInt(timeParts[1])
|
||||||
|
|
||||||
|
// Create date with current year/month but original day and time
|
||||||
|
const eventDate = new Date(currentYear, currentMonth, day, hours, minutes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
date: eventDate,
|
||||||
|
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert event dates for calendar picker - also use current month/year
|
||||||
|
export const eventDates = eventDatesData.map(item => {
|
||||||
|
const now = new Date()
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
const currentMonth = now.getMonth()
|
||||||
|
|
||||||
|
// Parse day from date string
|
||||||
|
const day = parseInt(item.date.split('T')[0])
|
||||||
|
const eventDate = new Date(currentYear, currentMonth, day)
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: eventDate,
|
||||||
|
count: item.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calendars data
|
||||||
|
export const calendars: Calendar[] = calendarsData as Calendar[]
|
||||||
|
|
||||||
|
// Export individual collections for convenience
|
||||||
|
export { eventsData, eventDatesData, calendarsData }
|
||||||
37
src/app/calendar/data/calendars.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "personal",
|
||||||
|
"name": "Personal",
|
||||||
|
"color": "bg-blue-500",
|
||||||
|
"visible": true,
|
||||||
|
"type": "personal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work",
|
||||||
|
"name": "Work",
|
||||||
|
"color": "bg-green-500",
|
||||||
|
"visible": true,
|
||||||
|
"type": "work"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shared",
|
||||||
|
"name": "Team Calendar",
|
||||||
|
"color": "bg-purple-500",
|
||||||
|
"visible": true,
|
||||||
|
"type": "shared"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meetings",
|
||||||
|
"name": "Meetings",
|
||||||
|
"color": "bg-orange-500",
|
||||||
|
"visible": true,
|
||||||
|
"type": "work"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "events",
|
||||||
|
"name": "Events",
|
||||||
|
"color": "bg-pink-500",
|
||||||
|
"visible": true,
|
||||||
|
"type": "shared"
|
||||||
|
}
|
||||||
|
]
|
||||||
30
src/app/calendar/data/event-dates.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "11T00:00:00.000Z",
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "15T00:00:00.000Z",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "18T00:00:00.000Z",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "20T00:00:00.000Z",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "22T00:00:00.000Z",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "25T00:00:00.000Z",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "27T00:00:00.000Z",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
62
src/app/calendar/data/events.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Team Standup",
|
||||||
|
"date": "11T09:00:00.000Z",
|
||||||
|
"time": "9:00 AM",
|
||||||
|
"duration": "30 min",
|
||||||
|
"type": "meeting",
|
||||||
|
"attendees": ["JD", "SM", "AR"],
|
||||||
|
"location": "Conference Room A",
|
||||||
|
"color": "bg-blue-500",
|
||||||
|
"description": "Daily team standup meeting to discuss progress and blockers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Design Review",
|
||||||
|
"date": "11T14:00:00.000Z",
|
||||||
|
"time": "2:00 PM",
|
||||||
|
"duration": "1 hour",
|
||||||
|
"type": "meeting",
|
||||||
|
"attendees": ["ER", "LC"],
|
||||||
|
"location": "Virtual",
|
||||||
|
"color": "bg-purple-500",
|
||||||
|
"description": "Review new UI designs and provide feedback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Product Launch",
|
||||||
|
"date": "15T10:00:00.000Z",
|
||||||
|
"time": "10:00 AM",
|
||||||
|
"duration": "2 hours",
|
||||||
|
"type": "event",
|
||||||
|
"attendees": ["TL", "ST"],
|
||||||
|
"location": "Main Hall",
|
||||||
|
"color": "bg-green-500",
|
||||||
|
"description": "Official product launch event with stakeholders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Client Presentation",
|
||||||
|
"date": "18T15:00:00.000Z",
|
||||||
|
"time": "3:00 PM",
|
||||||
|
"duration": "1 hour",
|
||||||
|
"type": "meeting",
|
||||||
|
"attendees": ["AT", "SM"],
|
||||||
|
"location": "Client Office",
|
||||||
|
"color": "bg-orange-500",
|
||||||
|
"description": "Present project progress to client stakeholders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Birthday Party 🎉",
|
||||||
|
"date": "20T19:00:00.000Z",
|
||||||
|
"time": "7:00 PM",
|
||||||
|
"duration": "3 hours",
|
||||||
|
"type": "personal",
|
||||||
|
"attendees": ["PB", "VB"],
|
||||||
|
"location": "Home",
|
||||||
|
"color": "bg-pink-500",
|
||||||
|
"description": "Birthday celebration with friends and family"
|
||||||
|
}
|
||||||
|
]
|
||||||
16
src/app/calendar/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseLayout } from "@/components/layouts/base-layout"
|
||||||
|
import { Calendar } from "./components/calendar"
|
||||||
|
import { events, eventDates } from "./data"
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
return (
|
||||||
|
<BaseLayout
|
||||||
|
title="Calendar"
|
||||||
|
description="Manage your schedule and events"
|
||||||
|
>
|
||||||
|
<div className="px-4 lg:px-6">
|
||||||
|
<Calendar events={events} eventDates={eventDates} />
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/app/calendar/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface CalendarEvent {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
date: Date
|
||||||
|
time: string
|
||||||
|
duration: string
|
||||||
|
type: "meeting" | "event" | "personal" | "task" | "reminder"
|
||||||
|
attendees: string[]
|
||||||
|
location: string
|
||||||
|
color: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Calendar {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
visible: boolean
|
||||||
|
type: "personal" | "work" | "shared"
|
||||||
|
}
|
||||||
90
src/app/calendar/use-calendar.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { type CalendarEvent } from "./types"
|
||||||
|
|
||||||
|
export interface UseCalendarState {
|
||||||
|
selectedDate: Date
|
||||||
|
showEventForm: boolean
|
||||||
|
editingEvent: CalendarEvent | null
|
||||||
|
showCalendarSheet: boolean
|
||||||
|
events: CalendarEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCalendarActions {
|
||||||
|
setSelectedDate: (date: Date) => void
|
||||||
|
setShowEventForm: (show: boolean) => void
|
||||||
|
setEditingEvent: (event: CalendarEvent | null) => void
|
||||||
|
setShowCalendarSheet: (show: boolean) => void
|
||||||
|
handleDateSelect: (date: Date) => void
|
||||||
|
handleNewEvent: () => void
|
||||||
|
handleNewCalendar: () => void
|
||||||
|
handleSaveEvent: (eventData: Partial<CalendarEvent>) => void
|
||||||
|
handleDeleteEvent: (eventId: number) => void
|
||||||
|
handleEditEvent: (event: CalendarEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCalendarReturn extends UseCalendarState, UseCalendarActions {}
|
||||||
|
|
||||||
|
export function useCalendar(initialEvents: CalendarEvent[] = []): UseCalendarReturn {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
|
||||||
|
const [showEventForm, setShowEventForm] = useState(false)
|
||||||
|
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
|
||||||
|
const [events] = useState<CalendarEvent[]>(initialEvents)
|
||||||
|
|
||||||
|
const handleDateSelect = useCallback((date: Date) => {
|
||||||
|
setSelectedDate(date)
|
||||||
|
// Auto-close mobile sheet when date is selected
|
||||||
|
setShowCalendarSheet(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNewEvent = useCallback(() => {
|
||||||
|
setEditingEvent(null)
|
||||||
|
setShowEventForm(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNewCalendar = useCallback(() => {
|
||||||
|
console.log("Creating new calendar")
|
||||||
|
// In a real app, this would open a new calendar form
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveEvent = useCallback((eventData: Partial<CalendarEvent>) => {
|
||||||
|
console.log("Saving event:", eventData)
|
||||||
|
// In a real app, this would save to a backend
|
||||||
|
setShowEventForm(false)
|
||||||
|
setEditingEvent(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteEvent = useCallback((eventId: number) => {
|
||||||
|
console.log("Deleting event:", eventId)
|
||||||
|
// In a real app, this would delete from backend
|
||||||
|
setShowEventForm(false)
|
||||||
|
setEditingEvent(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEditEvent = useCallback((event: CalendarEvent) => {
|
||||||
|
setEditingEvent(event)
|
||||||
|
setShowEventForm(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
selectedDate,
|
||||||
|
showEventForm,
|
||||||
|
editingEvent,
|
||||||
|
showCalendarSheet,
|
||||||
|
events,
|
||||||
|
// Actions
|
||||||
|
setSelectedDate,
|
||||||
|
setShowEventForm,
|
||||||
|
setEditingEvent,
|
||||||
|
setShowCalendarSheet,
|
||||||
|
handleDateSelect,
|
||||||
|
handleNewEvent,
|
||||||
|
handleNewCalendar,
|
||||||
|
handleSaveEvent,
|
||||||
|
handleDeleteEvent,
|
||||||
|
handleEditEvent,
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/app/chat/components/chat-header.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
Video,
|
||||||
|
Info,
|
||||||
|
Search,
|
||||||
|
MoreVertical,
|
||||||
|
Users,
|
||||||
|
Bell,
|
||||||
|
BellOff
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { type Conversation, type User } from "@/app/chat/use-chat"
|
||||||
|
|
||||||
|
interface ChatHeaderProps {
|
||||||
|
conversation: Conversation | null
|
||||||
|
users: User[]
|
||||||
|
onToggleMute?: () => void
|
||||||
|
onToggleInfo?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatHeader({
|
||||||
|
conversation,
|
||||||
|
users,
|
||||||
|
onToggleMute,
|
||||||
|
onToggleInfo
|
||||||
|
}: ChatHeaderProps) {
|
||||||
|
if (!conversation) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-muted-foreground">Select a conversation to start chatting</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConversationUsers = () => {
|
||||||
|
if (conversation.type === "direct") {
|
||||||
|
return users.filter(user => conversation.participants.includes(user.id))
|
||||||
|
}
|
||||||
|
return users.filter(user => conversation.participants.includes(user.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationUsers = getConversationUsers()
|
||||||
|
const primaryUser = conversationUsers[0]
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (conversation.type === "group") {
|
||||||
|
const onlineCount = conversationUsers.filter(user => user.status === "online").length
|
||||||
|
return `${conversation.participants.length} members, ${onlineCount} online`
|
||||||
|
} else if (primaryUser) {
|
||||||
|
switch (primaryUser.status) {
|
||||||
|
case "online":
|
||||||
|
return "Active now"
|
||||||
|
case "away":
|
||||||
|
return "Away"
|
||||||
|
case "offline":
|
||||||
|
return `Last seen ${new Date(primaryUser.lastSeen).toLocaleDateString()}`
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (conversation.type === "group") return "text-muted-foreground"
|
||||||
|
|
||||||
|
switch (primaryUser?.status) {
|
||||||
|
case "online":
|
||||||
|
return "text-green-600"
|
||||||
|
case "away":
|
||||||
|
return "text-yellow-600"
|
||||||
|
case "offline":
|
||||||
|
return "text-muted-foreground"
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between h-full">
|
||||||
|
{/* Left side - Avatar and info */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-10 w-10 cursor-pointer">
|
||||||
|
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{conversation.type === "group" ? (
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||||
|
)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="font-semibold truncate">{conversation.name}</h2>
|
||||||
|
{conversation.isMuted && (
|
||||||
|
<BellOff className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{conversation.type === "group" && (
|
||||||
|
<Badge variant="secondary" className="text-xs cursor-pointer">
|
||||||
|
Group
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm ${getStatusColor()}`}>
|
||||||
|
{getStatusText()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Action buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* Search */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Search in conversation</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Phone call */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Voice call</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Video call */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Video call</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onToggleInfo}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Conversation info</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* More options */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onToggleMute}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{conversation.isMuted ? (
|
||||||
|
<>
|
||||||
|
<Bell className="h-4 w-4 mr-2" />
|
||||||
|
Unmute conversation
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BellOff className="h-4 w-4 mr-2" />
|
||||||
|
Mute conversation
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
|
Search messages
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{conversation.type === "group" && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
Manage members
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||||
|
Delete conversation
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
src/app/chat/components/chat.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Menu, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ConversationList } from "./conversation-list"
|
||||||
|
import { ChatHeader } from "./chat-header"
|
||||||
|
import { MessageList } from "./message-list"
|
||||||
|
import { MessageInput } from "./message-input"
|
||||||
|
import { useChat, type Conversation, type Message, type User } from "@/app/chat/use-chat"
|
||||||
|
|
||||||
|
interface ChatProps {
|
||||||
|
conversations: Conversation[]
|
||||||
|
messages: Record<string, Message[]>
|
||||||
|
users: User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chat({
|
||||||
|
conversations,
|
||||||
|
messages,
|
||||||
|
users,
|
||||||
|
}: ChatProps) {
|
||||||
|
const {
|
||||||
|
selectedConversation,
|
||||||
|
setSelectedConversation,
|
||||||
|
setConversations,
|
||||||
|
setMessages,
|
||||||
|
setUsers,
|
||||||
|
addMessage,
|
||||||
|
toggleMute,
|
||||||
|
} = useChat()
|
||||||
|
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
// Close sidebar when clicking outside on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth >= 1024) { // lg breakpoint
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialize data
|
||||||
|
useEffect(() => {
|
||||||
|
setConversations(conversations)
|
||||||
|
setUsers(users)
|
||||||
|
|
||||||
|
// Set messages for all conversations
|
||||||
|
Object.entries(messages).forEach(([conversationId, conversationMessages]) => {
|
||||||
|
setMessages(conversationId, conversationMessages)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-select first conversation if none selected
|
||||||
|
if (!selectedConversation && conversations.length > 0) {
|
||||||
|
setSelectedConversation(conversations[0].id)
|
||||||
|
}
|
||||||
|
}, [conversations, messages, users, selectedConversation, setConversations, setMessages, setUsers, setSelectedConversation])
|
||||||
|
|
||||||
|
const currentConversation = conversations.find(conv => conv.id === selectedConversation)
|
||||||
|
const currentMessages = selectedConversation ? messages[selectedConversation] || [] : []
|
||||||
|
|
||||||
|
const handleSendMessage = (content: string) => {
|
||||||
|
if (!selectedConversation) return
|
||||||
|
|
||||||
|
const newMessage = {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
content,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
senderId: "current-user",
|
||||||
|
type: "text" as const,
|
||||||
|
isEdited: false,
|
||||||
|
reactions: [],
|
||||||
|
replyTo: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(selectedConversation, newMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleMute = () => {
|
||||||
|
if (selectedConversation) {
|
||||||
|
toggleMute(selectedConversation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div className="h-full min-h-[600px] max-h-[calc(100vh-200px)] flex rounded-lg border overflow-hidden bg-background">
|
||||||
|
{/* Mobile Sidebar Overlay */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conversations Sidebar - Responsive */}
|
||||||
|
<div className={`
|
||||||
|
w-100 border-r bg-background flex-shrink-0
|
||||||
|
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
|
lg:relative lg:block
|
||||||
|
fixed inset-y-0 left-0 z-50
|
||||||
|
transition-transform duration-300 ease-in-out
|
||||||
|
`}>
|
||||||
|
{/* Sidebar Header with Close Button (Mobile Only) */}
|
||||||
|
<div className="lg:hidden p-4 border-b flex items-center justify-between bg-background">
|
||||||
|
<h2 className="text-lg font-semibold">Messages</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConversationList
|
||||||
|
conversations={conversations}
|
||||||
|
selectedConversation={selectedConversation}
|
||||||
|
onSelectConversation={(id) => {
|
||||||
|
setSelectedConversation(id)
|
||||||
|
setIsSidebarOpen(false) // Close sidebar on mobile after selection
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Panel - Flexible Width */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 bg-background">
|
||||||
|
{/* Chat Header with Hamburger Menu */}
|
||||||
|
<div className="flex items-center h-16 px-4 border-b bg-background">
|
||||||
|
{/* Hamburger Menu Button - Only visible when sidebar is hidden on mobile */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
className="cursor-pointer lg:hidden mr-2"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<ChatHeader
|
||||||
|
conversation={currentConversation || null}
|
||||||
|
users={users}
|
||||||
|
onToggleMute={handleToggleMute}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{selectedConversation ? (
|
||||||
|
<>
|
||||||
|
<MessageList
|
||||||
|
messages={currentMessages}
|
||||||
|
users={users}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Message Input */}
|
||||||
|
<MessageInput
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
placeholder={`Message ${currentConversation?.name || ""}...`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Welcome to Chat</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Select a conversation to start messaging
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
src/app/chat/components/conversation-list-new.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Pin,
|
||||||
|
VolumeX,
|
||||||
|
MoreHorizontal,
|
||||||
|
Users,
|
||||||
|
Hash
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { useChat, type Conversation } from "@/app/chat/use-chat"
|
||||||
|
|
||||||
|
interface ConversationListProps {
|
||||||
|
conversations: Conversation[]
|
||||||
|
selectedConversation: string | null
|
||||||
|
onSelectConversation: (conversationId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced time formatting function
|
||||||
|
function formatMessageTime(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
if (isToday(date)) {
|
||||||
|
return format(date, 'h:mm a') // 3:30 PM
|
||||||
|
} else if (isYesterday(date)) {
|
||||||
|
return 'Yesterday'
|
||||||
|
} else if (isThisWeek(date)) {
|
||||||
|
return format(date, 'EEEE') // Day name
|
||||||
|
} else if (isThisYear(date)) {
|
||||||
|
return format(date, 'MMM d') // Jan 15
|
||||||
|
} else {
|
||||||
|
return format(date, 'dd/MM/yy') // 15/01/24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationList({
|
||||||
|
conversations,
|
||||||
|
selectedConversation,
|
||||||
|
onSelectConversation
|
||||||
|
}: ConversationListProps) {
|
||||||
|
const { searchQuery, setSearchQuery, togglePin, toggleMute } = useChat()
|
||||||
|
|
||||||
|
const filteredConversations = conversations.filter((conversation) =>
|
||||||
|
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedConversations = filteredConversations.sort((a, b) => {
|
||||||
|
// Pinned conversations first
|
||||||
|
if (a.isPinned && !b.isPinned) return -1
|
||||||
|
if (!a.isPinned && b.isPinned) return 1
|
||||||
|
|
||||||
|
// Then by last message timestamp
|
||||||
|
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getOnlineStatus = (conversation: Conversation) => {
|
||||||
|
if (conversation.type === "direct" && conversation.participants.length === 1) {
|
||||||
|
// In a real app, you'd check user online status
|
||||||
|
return Math.random() > 0.5 // Mock online status
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
|
||||||
|
<h2 className="text-lg font-semibold">Messages</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-4 border-b flex-shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 cursor-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversations */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2">
|
||||||
|
{sortedConversations.map((conversation) => (
|
||||||
|
<div
|
||||||
|
key={conversation.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative group overflow-hidden hover:bg-accent/50 transition-colors",
|
||||||
|
selectedConversation === conversation.id
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectConversation(conversation.id)}
|
||||||
|
>
|
||||||
|
{/* Avatar with online indicator */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<Avatar className={cn(
|
||||||
|
"h-12 w-12",
|
||||||
|
selectedConversation === conversation.id && "ring-2 ring-background"
|
||||||
|
)}>
|
||||||
|
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||||
|
<AvatarFallback className="text-sm">
|
||||||
|
{conversation.type === "group" ? (
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||||
|
)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{/* Online indicator for direct messages */}
|
||||||
|
{conversation.type === "direct" && getOnlineStatus(conversation) && (
|
||||||
|
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group indicator */}
|
||||||
|
{conversation.type === "group" && (
|
||||||
|
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
|
||||||
|
<Hash className="h-2 w-2 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||||
|
<h3 className="font-medium truncate min-w-0 max-w-[180px]">{conversation.name}</h3>
|
||||||
|
{conversation.isPinned && (
|
||||||
|
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{conversation.isMuted && (
|
||||||
|
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2 whitespace-nowrap">
|
||||||
|
{formatMessageTime(conversation.lastMessage.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[200px]">
|
||||||
|
{conversation.lastMessage.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Unread count */}
|
||||||
|
{conversation.unreadCount > 0 && (
|
||||||
|
<Badge variant="default" className="ml-2 min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
|
||||||
|
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions menu */}
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 ml-2 flex-shrink-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 cursor-pointer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
togglePin(conversation.id)
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Pin className="h-4 w-4 mr-2" />
|
||||||
|
{conversation.isPinned ? "Unpin" : "Pin"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleMute(conversation.id)
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<VolumeX className="h-4 w-4 mr-2" />
|
||||||
|
{conversation.isMuted ? "Unmute" : "Mute"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||||
|
Delete conversation
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
208
src/app/chat/components/conversation-list.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Pin,
|
||||||
|
VolumeX,
|
||||||
|
MoreVertical,
|
||||||
|
Users,
|
||||||
|
Hash,
|
||||||
|
Settings,
|
||||||
|
UserPlus,
|
||||||
|
Filter
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { useChat, type Conversation } from "@/app/chat/use-chat"
|
||||||
|
|
||||||
|
interface ConversationListProps {
|
||||||
|
conversations: Conversation[]
|
||||||
|
selectedConversation: string | null
|
||||||
|
onSelectConversation: (conversationId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced time formatting function
|
||||||
|
function formatMessageTime(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
if (isToday(date)) {
|
||||||
|
return format(date, 'h:mm a') // 3:30 PM
|
||||||
|
} else if (isYesterday(date)) {
|
||||||
|
return 'Yesterday'
|
||||||
|
} else if (isThisWeek(date)) {
|
||||||
|
return format(date, 'EEEE') // Day name
|
||||||
|
} else if (isThisYear(date)) {
|
||||||
|
return format(date, 'MMM d') // Jan 15
|
||||||
|
} else {
|
||||||
|
return format(date, 'dd/MM/yy') // 15/01/24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationList({
|
||||||
|
conversations,
|
||||||
|
selectedConversation,
|
||||||
|
onSelectConversation
|
||||||
|
}: ConversationListProps) {
|
||||||
|
const { searchQuery, setSearchQuery } = useChat()
|
||||||
|
|
||||||
|
const filteredConversations = conversations.filter((conversation) =>
|
||||||
|
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedConversations = filteredConversations.sort((a, b) => {
|
||||||
|
// Pinned conversations first
|
||||||
|
if (a.isPinned && !b.isPinned) return -1
|
||||||
|
if (!a.isPinned && b.isPinned) return 1
|
||||||
|
|
||||||
|
// Then by last message timestamp
|
||||||
|
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getOnlineStatus = (conversation: Conversation) => {
|
||||||
|
if (conversation.type === "direct" && conversation.participants.length === 1) {
|
||||||
|
// In a real app, you'd check user online status
|
||||||
|
return Math.random() > 0.5 // Mock online status
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* Header - Hidden on mobile (handled by parent) */}
|
||||||
|
<div className="hidden lg:flex items-center justify-between h-16 px-4 border-b flex-shrink-0">
|
||||||
|
<h2 className="text-lg font-semibold">Messages</h2>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
|
New Chat
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Filter Messages
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Chat Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-4 py-3 border-b flex-shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 cursor-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversations */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2">
|
||||||
|
{sortedConversations.map((conversation) => (
|
||||||
|
<div
|
||||||
|
key={conversation.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative overflow-hidden hover:bg-accent/50 transition-colors",
|
||||||
|
selectedConversation === conversation.id
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectConversation(conversation.id)}
|
||||||
|
>
|
||||||
|
{/* Avatar with online indicator */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<Avatar className={cn(
|
||||||
|
"h-12 w-12",
|
||||||
|
selectedConversation === conversation.id && "ring-2 ring-background"
|
||||||
|
)}>
|
||||||
|
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||||
|
<AvatarFallback className="text-sm">
|
||||||
|
{conversation.type === "group" ? (
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||||
|
)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{/* Online indicator for direct messages */}
|
||||||
|
{conversation.type === "direct" && getOnlineStatus(conversation) && (
|
||||||
|
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group indicator */}
|
||||||
|
{conversation.type === "group" && (
|
||||||
|
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
|
||||||
|
<Hash className="h-2 w-2 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden pr-2">
|
||||||
|
<h3 className="font-medium truncate min-w-0 max-w-[160px] lg:max-w-[180px]">{conversation.name}</h3>
|
||||||
|
{conversation.isPinned && (
|
||||||
|
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{conversation.isMuted && (
|
||||||
|
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||||
|
{formatMessageTime(conversation.lastMessage.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[180px] lg:max-w-[200px] pr-2">
|
||||||
|
{conversation.lastMessage.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Unread count */}
|
||||||
|
{conversation.unreadCount > 0 && (
|
||||||
|
<Badge variant="default" className="min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
|
||||||
|
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
225
src/app/chat/components/message-input.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef } from "react"
|
||||||
|
import {
|
||||||
|
Send,
|
||||||
|
Paperclip,
|
||||||
|
Smile,
|
||||||
|
Image as ImageIcon,
|
||||||
|
FileText,
|
||||||
|
Mic,
|
||||||
|
MoreHorizontal
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
interface MessageInputProps {
|
||||||
|
onSendMessage: (content: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageInput({
|
||||||
|
onSendMessage,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = "Type a message..."
|
||||||
|
}: MessageInputProps) {
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
const trimmedMessage = message.trim()
|
||||||
|
if (trimmedMessage && !disabled) {
|
||||||
|
onSendMessage(trimmedMessage)
|
||||||
|
setMessage("")
|
||||||
|
setIsTyping(false)
|
||||||
|
|
||||||
|
// Reset textarea height
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setMessage(value)
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = "auto"
|
||||||
|
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle typing indicator
|
||||||
|
if (value.trim() && !isTyping) {
|
||||||
|
setIsTyping(true)
|
||||||
|
} else if (!value.trim() && isTyping) {
|
||||||
|
setIsTyping(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = (type: "image" | "file") => {
|
||||||
|
// In a real app, this would open a file picker
|
||||||
|
console.log(`Upload ${type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
{/* Attachment button */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
className="cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Attach file</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent side="top" align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleFileUpload("image")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4 mr-2" />
|
||||||
|
Photo or video
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleFileUpload("file")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Document
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* Message input */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={message}
|
||||||
|
onChange={handleTextareaChange}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[40px] max-h-[120px] resize-none cursor-text disabled:cursor-not-allowed",
|
||||||
|
"pr-20" // Space for emoji and more buttons
|
||||||
|
)}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Input action buttons */}
|
||||||
|
<div className="absolute right-2 bottom-2 flex items-center gap-1">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Smile className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Add emoji</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>More options</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice message or send button */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{message.trim() ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={disabled}
|
||||||
|
className="cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
className="cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{message.trim() ? "Send message" : "Voice message"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Typing indicator */}
|
||||||
|
{isTyping && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
|
You are typing...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
296
src/app/chat/components/message-list.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { format, isToday, isYesterday } from "date-fns"
|
||||||
|
import { CheckCheck, MoreHorizontal, Reply, Copy, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { assetUrl } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { type Message, type User } from "@/app/chat/use-chat"
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: Message[]
|
||||||
|
users: User[]
|
||||||
|
currentUserId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({ messages, users, currentUserId = "current-user" }: MessageListProps) {
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const previousMessageCountRef = useRef(0)
|
||||||
|
const isInitialLoadRef = useRef(true)
|
||||||
|
const previousConversationRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Reset scroll behavior when switching conversations
|
||||||
|
useEffect(() => {
|
||||||
|
const currentConversationId = messages.length > 0 ? messages[0]?.id?.split('-')[0] : null
|
||||||
|
if (currentConversationId !== previousConversationRef.current) {
|
||||||
|
isInitialLoadRef.current = true
|
||||||
|
previousConversationRef.current = currentConversationId
|
||||||
|
}
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Auto-scroll to bottom only when new messages are added (not on initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip auto-scroll on initial load
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
isInitialLoadRef.current = false
|
||||||
|
previousMessageCountRef.current = messages.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-scroll if new messages were added
|
||||||
|
if (messages.length > previousMessageCountRef.current && bottomRef.current) {
|
||||||
|
bottomRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMessageCountRef.current = messages.length
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const getUserById = (userId: string) => {
|
||||||
|
if (userId === currentUserId) {
|
||||||
|
return {
|
||||||
|
id: currentUserId,
|
||||||
|
name: "You",
|
||||||
|
avatar: assetUrl("avatars/current-user.png"),
|
||||||
|
status: "online" as const,
|
||||||
|
email: "you@example.com",
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
role: "Developer",
|
||||||
|
department: "Engineering"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users.find(user => user.id === userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMessageTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
if (isToday(date)) {
|
||||||
|
return format(date, "HH:mm")
|
||||||
|
} else if (isYesterday(date)) {
|
||||||
|
return `Yesterday ${format(date, "HH:mm")}`
|
||||||
|
} else {
|
||||||
|
return format(date, "MMM d, HH:mm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowAvatar = (message: Message, index: number) => {
|
||||||
|
if (message.senderId === currentUserId) return false
|
||||||
|
if (index === 0) return true
|
||||||
|
|
||||||
|
const prevMessage = messages[index - 1]
|
||||||
|
return prevMessage.senderId !== message.senderId
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowName = (message: Message, index: number) => {
|
||||||
|
if (message.senderId === currentUserId) return false
|
||||||
|
if (index === 0) return true
|
||||||
|
|
||||||
|
const prevMessage = messages[index - 1]
|
||||||
|
return prevMessage.senderId !== message.senderId
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConsecutiveMessage = (message: Message, index: number) => {
|
||||||
|
if (index === 0) return false
|
||||||
|
|
||||||
|
const prevMessage = messages[index - 1]
|
||||||
|
const timeDiff = new Date(message.timestamp).getTime() - new Date(prevMessage.timestamp).getTime()
|
||||||
|
|
||||||
|
return prevMessage.senderId === message.senderId && timeDiff < 5 * 60 * 1000 // 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMessagesByDay = (messages: Message[]) => {
|
||||||
|
const groups: { date: string; messages: Message[] }[] = []
|
||||||
|
|
||||||
|
messages.forEach((message) => {
|
||||||
|
const messageDate = format(new Date(message.timestamp), "yyyy-MM-dd")
|
||||||
|
const lastGroup = groups[groups.length - 1]
|
||||||
|
|
||||||
|
if (lastGroup && lastGroup.date === messageDate) {
|
||||||
|
lastGroup.messages.push(message)
|
||||||
|
} else {
|
||||||
|
groups.push({
|
||||||
|
date: messageDate,
|
||||||
|
messages: [message]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateHeader = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
if (isToday(date)) {
|
||||||
|
return "Today"
|
||||||
|
} else if (isYesterday(date)) {
|
||||||
|
return "Yesterday"
|
||||||
|
} else {
|
||||||
|
return format(date, "EEEE, MMMM d")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageGroups = groupMessagesByDay(messages)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="flex-1 px-4" ref={scrollAreaRef}>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{messageGroups.map((group) => (
|
||||||
|
<div key={group.date}>
|
||||||
|
{/* Date separator */}
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<div className="text-xs text-muted-foreground bg-background px-3 py-1 rounded-full border">
|
||||||
|
{formatDateHeader(group.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages for this day */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.messages.map((message, messageIndex) => {
|
||||||
|
const user = getUserById(message.senderId)
|
||||||
|
const isOwnMessage = message.senderId === currentUserId
|
||||||
|
const showAvatar = shouldShowAvatar(message, messageIndex)
|
||||||
|
const showName = shouldShowName(message, messageIndex)
|
||||||
|
const isConsecutive = isConsecutiveMessage(message, messageIndex)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
"flex gap-3 group",
|
||||||
|
isOwnMessage && "flex-row-reverse",
|
||||||
|
isConsecutive && !isOwnMessage && "ml-12"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
{!isOwnMessage && (
|
||||||
|
<div className="w-8">
|
||||||
|
{showAvatar && user && (
|
||||||
|
<Avatar className="h-8 w-8 cursor-pointer">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{user.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message content */}
|
||||||
|
<div className={cn("flex-1 max-w-[70%]", isOwnMessage && "flex flex-col items-end")}>
|
||||||
|
{/* Sender name for group messages */}
|
||||||
|
{showName && user && !isOwnMessage && (
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">
|
||||||
|
{user.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message bubble */}
|
||||||
|
<div className="relative group/message">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-3 py-2 text-sm break-words",
|
||||||
|
isOwnMessage
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted",
|
||||||
|
isConsecutive && "mt-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p>{message.content}</p>
|
||||||
|
|
||||||
|
{/* Message reactions */}
|
||||||
|
{message.reactions.length > 0 && (
|
||||||
|
<div className="flex gap-1 mt-2">
|
||||||
|
{message.reactions.map((reaction, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs border cursor-pointer",
|
||||||
|
"bg-background/90 backdrop-blur-sm shadow-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{reaction.emoji}</span>
|
||||||
|
<span className="text-muted-foreground">{reaction.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamp and status */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-1 mt-1 text-xs",
|
||||||
|
isOwnMessage
|
||||||
|
? "text-primary-foreground/70 justify-end"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
<span>{formatMessageTime(message.timestamp)}</span>
|
||||||
|
{message.isEdited && (
|
||||||
|
<span className="italic">(edited)</span>
|
||||||
|
)}
|
||||||
|
{isOwnMessage && (
|
||||||
|
<div className="flex">
|
||||||
|
{/* Message status indicators */}
|
||||||
|
<CheckCheck className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message actions */}
|
||||||
|
<div className="absolute top-0 right-0 opacity-0 group-hover/message:opacity-100">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Reply className="h-4 w-4 mr-2" />
|
||||||
|
Reply
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Copy
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isOwnMessage && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Scroll anchor */}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
src/app/chat/data/conversations.json
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "conv-1",
|
||||||
|
"type": "direct",
|
||||||
|
"participants": ["1"],
|
||||||
|
"name": "Sarah Mitchell",
|
||||||
|
"avatar": "/avatars/01.png",
|
||||||
|
"lastMessage": {
|
||||||
|
"id": "msg-1-4",
|
||||||
|
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
|
||||||
|
"timestamp": "2025-08-11T15:30:00Z",
|
||||||
|
"senderId": "1"
|
||||||
|
},
|
||||||
|
"unreadCount": 2,
|
||||||
|
"isPinned": true,
|
||||||
|
"isMuted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-2",
|
||||||
|
"type": "group",
|
||||||
|
"participants": ["2", "3", "5"],
|
||||||
|
"name": "Project Alpha",
|
||||||
|
"avatar": "/avatars/team-alpha.png",
|
||||||
|
"lastMessage": {
|
||||||
|
"id": "msg-2-8",
|
||||||
|
"content": "David: Marketing campaign is scheduled for next week",
|
||||||
|
"timestamp": "2025-08-11T08:15:00Z",
|
||||||
|
"senderId": "2"
|
||||||
|
},
|
||||||
|
"unreadCount": 0,
|
||||||
|
"isPinned": false,
|
||||||
|
"isMuted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-3",
|
||||||
|
"type": "group",
|
||||||
|
"participants": ["2", "3", "5"],
|
||||||
|
"name": "Frontend Team",
|
||||||
|
"avatar": "/avatars/team-frontend.png",
|
||||||
|
"lastMessage": {
|
||||||
|
"id": "msg-3-6",
|
||||||
|
"content": "Alex: The new component library is ready for testing",
|
||||||
|
"timestamp": "2025-08-11T23:45:00Z",
|
||||||
|
"senderId": "3"
|
||||||
|
},
|
||||||
|
"unreadCount": 1,
|
||||||
|
"isPinned": false,
|
||||||
|
"isMuted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-4",
|
||||||
|
"type": "direct",
|
||||||
|
"participants": ["3"],
|
||||||
|
"name": "Emily Rodriguez",
|
||||||
|
"avatar": "/avatars/03.png",
|
||||||
|
"lastMessage": {
|
||||||
|
"id": "msg-4-3",
|
||||||
|
"content": "Let's review the wireframes together tomorrow",
|
||||||
|
"timestamp": "2025-08-10T16:30:00Z",
|
||||||
|
"senderId": "3"
|
||||||
|
},
|
||||||
|
"unreadCount": 1,
|
||||||
|
"isPinned": false,
|
||||||
|
"isMuted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-5",
|
||||||
|
"type": "direct",
|
||||||
|
"participants": ["5"],
|
||||||
|
"name": "Lisa Chen",
|
||||||
|
"avatar": "/avatars/05.png",
|
||||||
|
"lastMessage": {
|
||||||
|
"id": "msg-5-3",
|
||||||
|
"content": "Found a few edge cases in the new feature",
|
||||||
|
"timestamp": "2025-08-06T14:20:00Z",
|
||||||
|
"senderId": "5"
|
||||||
|
},
|
||||||
|
"unreadCount": 0,
|
||||||
|
"isPinned": false,
|
||||||
|
"isMuted": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-6",
|
||||||
|
"type": "direct",
|
||||||
|
"participants": ["2"],
|
||||||
|
"name": "Alex Thompson",
|
||||||
|
"avatar": "/avatars/02.png",
|
||||||
|
"lastMessage": {
|
||||||
|
"id": "msg-6-3",
|
||||||
|
"content": "Code review completed, looks good to merge! 👍",
|
||||||
|
"timestamp": "2025-01-15T17:45:00Z",
|
||||||
|
"senderId": "2"
|
||||||
|
},
|
||||||
|
"unreadCount": 0,
|
||||||
|
"isPinned": false,
|
||||||
|
"isMuted": false
|
||||||
|
}
|
||||||
|
]
|
||||||
224
src/app/chat/data/messages.json
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
{
|
||||||
|
"conv-1": [
|
||||||
|
{
|
||||||
|
"id": "msg-1-1",
|
||||||
|
"content": "Hey! How's the new dashboard coming along?",
|
||||||
|
"timestamp": "2024-01-15T10:15:00Z",
|
||||||
|
"senderId": "1",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-1-2",
|
||||||
|
"content": "It's going great! We've implemented the new design system and it looks fantastic.",
|
||||||
|
"timestamp": "2024-01-15T10:17:00Z",
|
||||||
|
"senderId": "current-user",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [{"emoji": "👍", "users": ["1"], "count": 1}],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-1-3",
|
||||||
|
"content": "That's awesome! Can you share a preview?",
|
||||||
|
"timestamp": "2024-01-15T10:18:00Z",
|
||||||
|
"senderId": "1",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-1-4",
|
||||||
|
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
|
||||||
|
"timestamp": "2024-01-15T10:30:00Z",
|
||||||
|
"senderId": "1",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [{"emoji": "❤️", "users": ["current-user"], "count": 1}],
|
||||||
|
"replyTo": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conv-2": [
|
||||||
|
{
|
||||||
|
"id": "msg-2-1",
|
||||||
|
"content": "Hey team! The component library update is ready",
|
||||||
|
"timestamp": "2024-01-15T09:00:00Z",
|
||||||
|
"senderId": "2",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-2-2",
|
||||||
|
"content": "Awesome work Alex! 🚀",
|
||||||
|
"timestamp": "2024-01-15T09:05:00Z",
|
||||||
|
"senderId": "3",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-2-3",
|
||||||
|
"content": "I've tested the new Button and Input components, they work perfectly",
|
||||||
|
"timestamp": "2024-01-15T09:10:00Z",
|
||||||
|
"senderId": "5",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [{"emoji": "✅", "users": ["2", "3"], "count": 2}],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-2-4",
|
||||||
|
"content": "Great! I'll start integrating them into the main app",
|
||||||
|
"timestamp": "2024-01-15T09:15:00Z",
|
||||||
|
"senderId": "current-user",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conv-3": [
|
||||||
|
{
|
||||||
|
"id": "msg-3-1",
|
||||||
|
"content": "Hi! I've completed the wireframes for the new user onboarding flow",
|
||||||
|
"timestamp": "2024-01-15T09:30:00Z",
|
||||||
|
"senderId": "3",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-3-2",
|
||||||
|
"content": "That's fantastic Emily! When can we review them?",
|
||||||
|
"timestamp": "2024-01-15T09:32:00Z",
|
||||||
|
"senderId": "current-user",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-3-3",
|
||||||
|
"content": "How about tomorrow at 2 PM? I'll share my screen and walk through the designs",
|
||||||
|
"timestamp": "2024-01-15T09:35:00Z",
|
||||||
|
"senderId": "3",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-3-4",
|
||||||
|
"content": "Perfect! Looking forward to it",
|
||||||
|
"timestamp": "2024-01-15T09:40:00Z",
|
||||||
|
"senderId": "current-user",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conv-4": [
|
||||||
|
{
|
||||||
|
"id": "msg-4-1",
|
||||||
|
"content": "Hi! I've been working on the wireframes for the new feature",
|
||||||
|
"timestamp": "2025-08-10T14:15:00Z",
|
||||||
|
"senderId": "3",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-4-2",
|
||||||
|
"content": "That's great! I'd love to take a look at them",
|
||||||
|
"timestamp": "2025-08-10T14:18:00Z",
|
||||||
|
"senderId": "current-user",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-4-3",
|
||||||
|
"content": "Let's review the wireframes together tomorrow",
|
||||||
|
"timestamp": "2025-08-10T16:30:00Z",
|
||||||
|
"senderId": "3",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
|
||||||
|
"replyTo": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conv-5": [
|
||||||
|
{
|
||||||
|
"id": "msg-5-1",
|
||||||
|
"content": "I've been testing the new feature and it looks good overall",
|
||||||
|
"timestamp": "2025-08-06T13:45:00Z",
|
||||||
|
"senderId": "5",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-5-2",
|
||||||
|
"content": "Thanks for testing it! Any issues you found?",
|
||||||
|
"timestamp": "2025-08-06T14:10:00Z",
|
||||||
|
"senderId": "current-user",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-5-3",
|
||||||
|
"content": "Found a few edge cases in the new feature",
|
||||||
|
"timestamp": "2025-08-06T14:20:00Z",
|
||||||
|
"senderId": "5",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conv-6": [
|
||||||
|
{
|
||||||
|
"id": "msg-6-1",
|
||||||
|
"content": "Hey! I've finished the code review for the latest PR",
|
||||||
|
"timestamp": "2025-01-15T16:30:00Z",
|
||||||
|
"senderId": "2",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-6-2",
|
||||||
|
"content": "Thanks for the quick review! Any feedback?",
|
||||||
|
"timestamp": "2025-01-15T17:15:00Z",
|
||||||
|
"senderId": "current-user",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [],
|
||||||
|
"replyTo": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg-6-3",
|
||||||
|
"content": "Code review completed, looks good to merge! 👍",
|
||||||
|
"timestamp": "2025-01-15T17:45:00Z",
|
||||||
|
"senderId": "2",
|
||||||
|
"type": "text",
|
||||||
|
"isEdited": false,
|
||||||
|
"reactions": [{"emoji": "🎉", "users": ["current-user"], "count": 1}],
|
||||||
|
"replyTo": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
52
src/app/chat/data/users.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Sarah Mitchell",
|
||||||
|
"email": "sarah.mitchell@example.com",
|
||||||
|
"avatar": "/avatars/01.png",
|
||||||
|
"status": "online",
|
||||||
|
"lastSeen": "2024-01-15T10:30:00Z",
|
||||||
|
"role": "Project Manager",
|
||||||
|
"department": "Product"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Alex Thompson",
|
||||||
|
"email": "alex.thompson@example.com",
|
||||||
|
"avatar": "/avatars/02.png",
|
||||||
|
"status": "away",
|
||||||
|
"lastSeen": "2024-01-15T09:45:00Z",
|
||||||
|
"role": "Senior Developer",
|
||||||
|
"department": "Engineering"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"name": "Emily Rodriguez",
|
||||||
|
"email": "emily.rodriguez@example.com",
|
||||||
|
"avatar": "/avatars/03.png",
|
||||||
|
"status": "online",
|
||||||
|
"lastSeen": "2024-01-15T10:25:00Z",
|
||||||
|
"role": "UX Designer",
|
||||||
|
"department": "Design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"name": "David Kim",
|
||||||
|
"email": "david.kim@example.com",
|
||||||
|
"avatar": "/avatars/04.png",
|
||||||
|
"status": "offline",
|
||||||
|
"lastSeen": "2024-01-14T18:30:00Z",
|
||||||
|
"role": "Marketing Lead",
|
||||||
|
"department": "Marketing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"name": "Lisa Chen",
|
||||||
|
"email": "lisa.chen@example.com",
|
||||||
|
"avatar": "/avatars/05.png",
|
||||||
|
"status": "online",
|
||||||
|
"lastSeen": "2024-01-15T10:20:00Z",
|
||||||
|
"role": "QA Engineer",
|
||||||
|
"department": "Engineering"
|
||||||
|
}
|
||||||
|
]
|
||||||
58
src/app/chat/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { BaseLayout } from "@/components/layouts/base-layout"
|
||||||
|
import { Chat } from "./components/chat"
|
||||||
|
import { type Conversation, type Message, type User } from "./use-chat"
|
||||||
|
|
||||||
|
// Import static data
|
||||||
|
import conversationsData from "./data/conversations.json"
|
||||||
|
import messagesData from "./data/messages.json"
|
||||||
|
import usersData from "./data/users.json"
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||||
|
const [messages, setMessages] = useState<Record<string, Message[]>>({})
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
// In a real app, these would be API calls
|
||||||
|
setConversations(conversationsData as Conversation[])
|
||||||
|
setMessages(messagesData as Record<string, Message[]>)
|
||||||
|
setUsers(usersData as User[])
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load chat data:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<BaseLayout title="Chat" description="Team communication and messaging">
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="text-muted-foreground">Loading chat...</div>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseLayout title="Chat" description="Team communication and messaging">
|
||||||
|
<div className="px-4 md:px-6">
|
||||||
|
<Chat
|
||||||
|
conversations={conversations}
|
||||||
|
messages={messages}
|
||||||
|
users={users}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
src/app/chat/use-chat.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { create } from "zustand"
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
status: "online" | "away" | "offline"
|
||||||
|
lastSeen: string
|
||||||
|
role: string
|
||||||
|
department: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
senderId: string
|
||||||
|
type: "text" | "image" | "file"
|
||||||
|
isEdited: boolean
|
||||||
|
reactions: Array<{
|
||||||
|
emoji: string
|
||||||
|
users: string[]
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
replyTo: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string
|
||||||
|
type: "direct" | "group"
|
||||||
|
participants: string[]
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
lastMessage: {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
senderId: string
|
||||||
|
}
|
||||||
|
unreadCount: number
|
||||||
|
isPinned: boolean
|
||||||
|
isMuted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
conversations: Conversation[]
|
||||||
|
messages: Record<string, Message[]>
|
||||||
|
users: User[]
|
||||||
|
selectedConversation: string | null
|
||||||
|
searchQuery: string
|
||||||
|
isTyping: Record<string, boolean>
|
||||||
|
onlineUsers: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatActions {
|
||||||
|
setConversations: (conversations: Conversation[]) => void
|
||||||
|
setMessages: (conversationId: string, messages: Message[]) => void
|
||||||
|
setUsers: (users: User[]) => void
|
||||||
|
setSelectedConversation: (conversationId: string | null) => void
|
||||||
|
setSearchQuery: (query: string) => void
|
||||||
|
addMessage: (conversationId: string, message: Message) => void
|
||||||
|
markAsRead: (conversationId: string) => void
|
||||||
|
togglePin: (conversationId: string) => void
|
||||||
|
toggleMute: (conversationId: string) => void
|
||||||
|
setTyping: (conversationId: string, isTyping: boolean) => void
|
||||||
|
setOnlineUsers: (userIds: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChat = create<ChatState & ChatActions>((set, get) => ({
|
||||||
|
// State
|
||||||
|
conversations: [],
|
||||||
|
messages: {},
|
||||||
|
users: [],
|
||||||
|
selectedConversation: null,
|
||||||
|
searchQuery: "",
|
||||||
|
isTyping: {},
|
||||||
|
onlineUsers: [],
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setConversations: (conversations) => set({ conversations }),
|
||||||
|
|
||||||
|
setMessages: (conversationId, messages) =>
|
||||||
|
set((state) => ({
|
||||||
|
messages: { ...state.messages, [conversationId]: messages }
|
||||||
|
})),
|
||||||
|
|
||||||
|
setUsers: (users) => set({ users }),
|
||||||
|
|
||||||
|
setSelectedConversation: (conversationId) => {
|
||||||
|
set({ selectedConversation: conversationId })
|
||||||
|
if (conversationId) {
|
||||||
|
get().markAsRead(conversationId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
|
||||||
|
addMessage: (conversationId, message) =>
|
||||||
|
set((state) => ({
|
||||||
|
messages: {
|
||||||
|
...state.messages,
|
||||||
|
[conversationId]: [...(state.messages[conversationId] || []), message]
|
||||||
|
},
|
||||||
|
conversations: state.conversations.map((conv) =>
|
||||||
|
conv.id === conversationId
|
||||||
|
? {
|
||||||
|
...conv,
|
||||||
|
lastMessage: {
|
||||||
|
id: message.id,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
senderId: message.senderId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: conv
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
|
||||||
|
markAsRead: (conversationId) =>
|
||||||
|
set((state) => ({
|
||||||
|
conversations: state.conversations.map((conv) =>
|
||||||
|
conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
|
||||||
|
togglePin: (conversationId) =>
|
||||||
|
set((state) => ({
|
||||||
|
conversations: state.conversations.map((conv) =>
|
||||||
|
conv.id === conversationId ? { ...conv, isPinned: !conv.isPinned } : conv
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
|
||||||
|
toggleMute: (conversationId) =>
|
||||||
|
set((state) => ({
|
||||||
|
conversations: state.conversations.map((conv) =>
|
||||||
|
conv.id === conversationId ? { ...conv, isMuted: !conv.isMuted } : conv
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
|
||||||
|
setTyping: (conversationId, isTyping) =>
|
||||||
|
set((state) => ({
|
||||||
|
isTyping: { ...state.isTyping, [conversationId]: isTyping }
|
||||||
|
})),
|
||||||
|
|
||||||
|
setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),
|
||||||
|
}))
|
||||||
248
src/app/dashboard-2/components/customer-insights.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Users, MapPin, TrendingUp, Target, ArrowUpIcon, UserIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const customerGrowthData = [
|
||||||
|
{ month: "Jan", new: 245, returning: 890, churn: 45 },
|
||||||
|
{ month: "Feb", new: 312, returning: 934, churn: 52 },
|
||||||
|
{ month: "Mar", new: 289, returning: 1023, churn: 38 },
|
||||||
|
{ month: "Apr", new: 456, returning: 1156, churn: 61 },
|
||||||
|
{ month: "May", new: 523, returning: 1298, churn: 47 },
|
||||||
|
{ month: "Jun", new: 634, returning: 1445, churn: 55 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
new: {
|
||||||
|
label: "New Customers",
|
||||||
|
color: "var(--chart-1)",
|
||||||
|
},
|
||||||
|
returning: {
|
||||||
|
label: "Returning",
|
||||||
|
color: "var(--chart-2)",
|
||||||
|
},
|
||||||
|
churn: {
|
||||||
|
label: "Churned",
|
||||||
|
color: "var(--chart-3)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const demographicsData = [
|
||||||
|
{ ageGroup: "18-24", customers: 2847, percentage: "18.0%", growth: "+15.2%", growthColor: "text-green-600" },
|
||||||
|
{ ageGroup: "25-34", customers: 4521, percentage: "28.5%", growth: "+8.7%", growthColor: "text-green-600" },
|
||||||
|
{ ageGroup: "35-44", customers: 3982, percentage: "25.1%", growth: "+3.4%", growthColor: "text-blue-600" },
|
||||||
|
{ ageGroup: "45-54", customers: 2734, percentage: "17.2%", growth: "+1.2%", growthColor: "text-orange-600" },
|
||||||
|
{ ageGroup: "55+", customers: 1763, percentage: "11.2%", growth: "-2.1%", growthColor: "text-red-600" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const regionsData = [
|
||||||
|
{ region: "North America", customers: 6847, revenue: "$847,523", growth: "+12.3%", growthColor: "text-green-600" },
|
||||||
|
{ region: "Europe", customers: 4521, revenue: "$563,891", growth: "+9.7%", growthColor: "text-green-600" },
|
||||||
|
{ region: "Asia Pacific", customers: 2892, revenue: "$321,456", growth: "+18.4%", growthColor: "text-blue-600" },
|
||||||
|
{ region: "Latin America", customers: 1123, revenue: "$187,234", growth: "+15.8%", growthColor: "text-green-600" },
|
||||||
|
{ region: "Others", customers: 464, revenue: "$67,891", growth: "+5.2%", growthColor: "text-orange-600" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function CustomerInsights() {
|
||||||
|
const [activeTab, setActiveTab] = useState("growth")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Customer Insights</CardTitle>
|
||||||
|
<CardDescription>Growth trends and demographics</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3 bg-muted/50 p-1 rounded-lg h-12">
|
||||||
|
<TabsTrigger
|
||||||
|
value="growth"
|
||||||
|
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Growth</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="demographics"
|
||||||
|
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
<UserIcon className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Demographics</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="regions"
|
||||||
|
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Regions</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="growth" className="mt-8 space-y-6">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{/* Chart and Key Metrics Side by Side */}
|
||||||
|
<div className="grid grid-cols-10 gap-6">
|
||||||
|
{/* Chart Area - 70% */}
|
||||||
|
<div className="col-span-10 xl:col-span-7">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-6">Customer Growth Trends</h3>
|
||||||
|
<ChartContainer config={chartConfig} className="h-[375px] w-full">
|
||||||
|
<BarChart data={customerGrowthData} margin={{ top: 20, right: 20, bottom: 20, left: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
className="text-xs"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={{ stroke: 'var(--border)' }}
|
||||||
|
axisLine={{ stroke: 'var(--border)' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
className="text-xs"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={{ stroke: 'var(--border)' }}
|
||||||
|
axisLine={{ stroke: 'var(--border)' }}
|
||||||
|
domain={[0, 'dataMax']}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Bar dataKey="new" fill="var(--color-new)" radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="returning" fill="var(--color-returning)" radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="churn" fill="var(--color-churn)" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Metrics - 30% */}
|
||||||
|
<div className="col-span-10 xl:col-span-3 space-y-5">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-6">Key Metrics</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-5">
|
||||||
|
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">Total Customers</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">15,847</div>
|
||||||
|
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
|
||||||
|
<ArrowUpIcon className="h-3 w-3" />
|
||||||
|
+12.5% from last month
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Retention Rate</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">92.4%</div>
|
||||||
|
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
|
||||||
|
<ArrowUpIcon className="h-3 w-3" />
|
||||||
|
+2.1% improvement
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Target className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Avg. LTV</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">$2,847</div>
|
||||||
|
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
|
||||||
|
<ArrowUpIcon className="h-3 w-3" />
|
||||||
|
+8.3% growth
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="demographics" className="mt-8">
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-b">
|
||||||
|
<TableHead className="py-5 px-6 font-semibold">Age Group</TableHead>
|
||||||
|
<TableHead className="text-right py-5 px-6 font-semibold">Customers</TableHead>
|
||||||
|
<TableHead className="text-right py-5 px-6 font-semibold">Percentage</TableHead>
|
||||||
|
<TableHead className="text-right py-5 px-6 font-semibold">Growth</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{demographicsData.map((row, index) => (
|
||||||
|
<TableRow key={index} className="hover:bg-muted/30 transition-colors">
|
||||||
|
<TableCell className="font-medium py-5 px-6">{row.ageGroup}</TableCell>
|
||||||
|
<TableCell className="text-right py-5 px-6">{row.customers.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-right py-5 px-6">{row.percentage}</TableCell>
|
||||||
|
<TableCell className="text-right py-5 px-6">
|
||||||
|
<span className={`font-medium ${row.growthColor}`}>{row.growth}</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-6">
|
||||||
|
<div className="text-muted-foreground text-sm hidden sm:block">
|
||||||
|
0 of {demographicsData.length} row(s) selected.
|
||||||
|
</div>
|
||||||
|
<div className="space-x-2 space-y-2">
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
|
||||||
|
<TabsContent value="regions" className="mt-8">
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-b">
|
||||||
|
<TableHead className="py-5 px-6 font-semibold">Region</TableHead>
|
||||||
|
<TableHead className="text-right py-5 px-6 font-semibold">Customers</TableHead>
|
||||||
|
<TableHead className="text-right py-5 px-6 font-semibold">Revenue</TableHead>
|
||||||
|
<TableHead className="text-right py-5 px-6 font-semibold">Growth</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{regionsData.map((row, index) => (
|
||||||
|
<TableRow key={index} className="hover:bg-muted/30 transition-colors">
|
||||||
|
<TableCell className="font-medium py-5 px-6">{row.region}</TableCell>
|
||||||
|
<TableCell className="text-right py-5 px-6">{row.customers.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-right py-5 px-6">{row.revenue}</TableCell>
|
||||||
|
<TableCell className="text-right py-5 px-6">
|
||||||
|
<span className={`font-medium ${row.growthColor}`}>{row.growth}</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-6">
|
||||||
|
<div className="text-muted-foreground text-sm hidden sm:block">
|
||||||
|
0 of {regionsData.length} row(s) selected.
|
||||||
|
</div>
|
||||||
|
<div className="space-x-2 space-y-2">
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/app/dashboard-2/components/dashboard-header.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Calendar, Clock, RefreshCw, Filter } from "lucide-react"
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
export function DashboardHeader() {
|
||||||
|
const [dateRange, setDateRange] = useState("30d")
|
||||||
|
const lastUpdated = new Date().toLocaleString()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-3xl font-bold">Business Dashboard</CardTitle>
|
||||||
|
<CardDescription className="text-base mt-2">
|
||||||
|
Comprehensive overview of your business performance and key metrics
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge variant="outline" className="cursor-pointer">
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
Live Data
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Date Range:</span>
|
||||||
|
<Select value={dateRange} onValueChange={setDateRange}>
|
||||||
|
<SelectTrigger className="w-40 cursor-pointer">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7d" className="cursor-pointer">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30d" className="cursor-pointer">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90d" className="cursor-pointer">Last 90 days</SelectItem>
|
||||||
|
<SelectItem value="1y" className="cursor-pointer">Last year</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Last updated: {lastUpdated}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/app/dashboard-2/components/metrics-overview.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
ShoppingCart,
|
||||||
|
BarChart3
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{
|
||||||
|
title: "Total Revenue",
|
||||||
|
value: "$54,230",
|
||||||
|
description: "Monthly revenue",
|
||||||
|
change: "+12%",
|
||||||
|
trend: "up",
|
||||||
|
icon: DollarSign,
|
||||||
|
footer: "Trending up this month",
|
||||||
|
subfooter: "Revenue for the last 6 months"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Active Customers",
|
||||||
|
value: "2,350",
|
||||||
|
description: "Total active users",
|
||||||
|
change: "+5.2%",
|
||||||
|
trend: "up",
|
||||||
|
icon: Users,
|
||||||
|
footer: "Strong user retention",
|
||||||
|
subfooter: "Engagement exceeds targets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Orders",
|
||||||
|
value: "1,247",
|
||||||
|
description: "Orders this month",
|
||||||
|
change: "-2.1%",
|
||||||
|
trend: "down",
|
||||||
|
icon: ShoppingCart,
|
||||||
|
footer: "Down 2% this period",
|
||||||
|
subfooter: "Order volume needs attention"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Conversion Rate",
|
||||||
|
value: "3.24%",
|
||||||
|
description: "Average conversion",
|
||||||
|
change: "+8.3%",
|
||||||
|
trend: "up",
|
||||||
|
icon: BarChart3,
|
||||||
|
footer: "Steady performance increase",
|
||||||
|
subfooter: "Meets conversion projections"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MetricsOverview() {
|
||||||
|
return (
|
||||||
|
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 @5xl:grid-cols-4">
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const TrendIcon = metric.trend === "up" ? TrendingUp : TrendingDown
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={metric.title} className=" cursor-pointer">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{metric.title}</CardDescription>
|
||||||
|
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
{metric.value}
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<TrendIcon className="h-4 w-4" />
|
||||||
|
{metric.change}
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
{metric.footer} <TrendIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{metric.subfooter}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/app/dashboard-2/components/quick-actions.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Plus, Settings, FileText, Download } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
export function QuickActions() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button className="cursor-pointer">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Sale
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="cursor-pointer">
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Actions
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Generate Report
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export Data
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Dashboard Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
src/app/dashboard-2/components/recent-transactions.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Eye, MoreHorizontal } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
import { assetUrl } from "@/lib/utils"
|
||||||
|
|
||||||
|
const transactions = [
|
||||||
|
{
|
||||||
|
id: "TXN-001",
|
||||||
|
customer: {
|
||||||
|
name: "Olivia Martin",
|
||||||
|
email: "olivia.martin@email.com",
|
||||||
|
avatar: assetUrl("avatars/01.png"),
|
||||||
|
},
|
||||||
|
amount: "$1,999.00",
|
||||||
|
status: "completed",
|
||||||
|
date: "2 hours ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TXN-002",
|
||||||
|
customer: {
|
||||||
|
name: "Jackson Lee",
|
||||||
|
email: "jackson.lee@email.com",
|
||||||
|
avatar: assetUrl("avatars/02.png"),
|
||||||
|
},
|
||||||
|
amount: "$2,999.00",
|
||||||
|
status: "pending",
|
||||||
|
date: "5 hours ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TXN-003",
|
||||||
|
customer: {
|
||||||
|
name: "Isabella Nguyen",
|
||||||
|
email: "isabella.nguyen@email.com",
|
||||||
|
avatar: assetUrl("avatars/03.png"),
|
||||||
|
},
|
||||||
|
amount: "$39.00",
|
||||||
|
status: "completed",
|
||||||
|
date: "1 day ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TXN-004",
|
||||||
|
customer: {
|
||||||
|
name: "William Kim",
|
||||||
|
email: "will@email.com",
|
||||||
|
avatar: assetUrl("avatars/04.png"),
|
||||||
|
},
|
||||||
|
amount: "$299.00",
|
||||||
|
status: "failed",
|
||||||
|
date: "2 days ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TXN-005",
|
||||||
|
customer: {
|
||||||
|
name: "Sofia Davis",
|
||||||
|
email: "sofia.davis@email.com",
|
||||||
|
avatar: assetUrl("avatars/05.png"),
|
||||||
|
},
|
||||||
|
amount: "$99.00",
|
||||||
|
status: "completed",
|
||||||
|
date: "3 days ago",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function RecentTransactions() {
|
||||||
|
return (
|
||||||
|
<Card className="cursor-pointer">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Recent Transactions</CardTitle>
|
||||||
|
<CardDescription>Latest customer transactions</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{transactions.map((transaction) => (
|
||||||
|
<div key={transaction.id} >
|
||||||
|
<div className="flex p-3 rounded-lg border gap-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={transaction.customer.avatar} alt={transaction.customer.name} />
|
||||||
|
<AvatarFallback>{transaction.customer.name.split(" ").map(n => n[0]).join("")}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-1 items-center flex-wrap justify-between gap-1">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{transaction.customer.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{transaction.customer.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
transaction.status === "completed" ? "default" :
|
||||||
|
transaction.status === "pending" ? "secondary" : "destructive"
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{transaction.status}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium">{transaction.amount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{transaction.date}</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 cursor-pointer">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem className="cursor-pointer">View Details</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">Download Receipt</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">Contact Customer</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
205
src/app/dashboard-2/components/revenue-breakdown.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label, Pie, PieChart, Sector } from "recharts"
|
||||||
|
import type { PieSectorDataItem } from "recharts/types/polar/Pie"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { ChartContainer, ChartStyle, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const revenueData = [
|
||||||
|
{ category: "subscriptions", value: 45, amount: 24500, fill: "var(--color-subscriptions)" },
|
||||||
|
{ category: "sales", value: 30, amount: 16300, fill: "var(--color-sales)" },
|
||||||
|
{ category: "services", value: 15, amount: 8150, fill: "var(--color-services)" },
|
||||||
|
{ category: "partnerships", value: 10, amount: 5430, fill: "var(--color-partnerships)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
revenue: {
|
||||||
|
label: "Revenue",
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
label: "Amount",
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
label: "Subscriptions",
|
||||||
|
color: "var(--chart-1)",
|
||||||
|
},
|
||||||
|
sales: {
|
||||||
|
label: "One-time Sales",
|
||||||
|
color: "var(--chart-2)",
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
label: "Services",
|
||||||
|
color: "var(--chart-3)",
|
||||||
|
},
|
||||||
|
partnerships: {
|
||||||
|
label: "Partnerships",
|
||||||
|
color: "var(--chart-4)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenueBreakdown() {
|
||||||
|
const id = "revenue-breakdown"
|
||||||
|
const [activeCategory, setActiveCategory] = React.useState("sales")
|
||||||
|
|
||||||
|
const activeIndex = React.useMemo(
|
||||||
|
() => revenueData.findIndex((item) => item.category === activeCategory),
|
||||||
|
[activeCategory]
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories = React.useMemo(() => revenueData.map((item) => item.category), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card data-chart={id} className="flex flex-col cursor-pointer">
|
||||||
|
<ChartStyle id={id} config={chartConfig} />
|
||||||
|
<CardHeader className="flex flex-col space-y-2 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Revenue Breakdown</CardTitle>
|
||||||
|
<CardDescription>Revenue distribution by source</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Select value={activeCategory} onValueChange={setActiveCategory}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-[175px] rounded-lg cursor-pointer"
|
||||||
|
aria-label="Select a category"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end" className="rounded-lg">
|
||||||
|
{categories.map((key) => {
|
||||||
|
const config = chartConfig[key as keyof typeof chartConfig]
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={key}
|
||||||
|
value={key}
|
||||||
|
className="rounded-md [&_span]:flex cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="flex h-3 w-3 shrink-0 "
|
||||||
|
style={{
|
||||||
|
backgroundColor: `var(--color-${key})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{config?.label}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" className="cursor-pointer">
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 justify-center">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ChartContainer
|
||||||
|
id={id}
|
||||||
|
config={chartConfig}
|
||||||
|
className="mx-auto aspect-square w-full max-w-[300px]"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent hideLabel />}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={revenueData}
|
||||||
|
dataKey="amount"
|
||||||
|
nameKey="category"
|
||||||
|
innerRadius={60}
|
||||||
|
strokeWidth={5}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
activeShape={({
|
||||||
|
outerRadius = 0,
|
||||||
|
...props
|
||||||
|
}: PieSectorDataItem) => (
|
||||||
|
<g>
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||||
|
<Sector
|
||||||
|
{...props}
|
||||||
|
outerRadius={outerRadius + 25}
|
||||||
|
innerRadius={outerRadius + 12}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
<tspan
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
className="fill-foreground text-3xl font-bold"
|
||||||
|
>
|
||||||
|
${(revenueData[activeIndex].amount / 1000).toFixed(0)}K
|
||||||
|
</tspan>
|
||||||
|
<tspan
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={(viewBox.cy || 0) + 24}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
>
|
||||||
|
Revenue
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center space-y-4">
|
||||||
|
{revenueData.map((item, index) => {
|
||||||
|
const config = chartConfig[item.category as keyof typeof chartConfig]
|
||||||
|
const isActive = index === activeIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.category}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${
|
||||||
|
isActive ? 'bg-muted' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveCategory(item.category)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="flex h-3 w-3 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `var(--color-${item.category})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{config?.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-bold">${(item.amount / 1000).toFixed(1)}K</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{item.value}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
src/app/dashboard-2/components/sales-chart.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const salesData = [
|
||||||
|
{ month: "Jan", sales: 12500, target: 15000 },
|
||||||
|
{ month: "Feb", sales: 18200, target: 15000 },
|
||||||
|
{ month: "Mar", sales: 16800, target: 15000 },
|
||||||
|
{ month: "Apr", sales: 22400, target: 20000 },
|
||||||
|
{ month: "May", sales: 24600, target: 20000 },
|
||||||
|
{ month: "Jun", sales: 28200, target: 25000 },
|
||||||
|
{ month: "Jul", sales: 31500, target: 25000 },
|
||||||
|
{ month: "Aug", sales: 29800, target: 25000 },
|
||||||
|
{ month: "Sep", sales: 33200, target: 30000 },
|
||||||
|
{ month: "Oct", sales: 35100, target: 30000 },
|
||||||
|
{ month: "Nov", sales: 38900, target: 35000 },
|
||||||
|
{ month: "Dec", sales: 42300, target: 35000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
sales: {
|
||||||
|
label: "Sales",
|
||||||
|
color: "var(--primary)",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
label: "Target",
|
||||||
|
color: "var(--primary)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesChart() {
|
||||||
|
const [timeRange, setTimeRange] = useState("12m")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="cursor-pointer">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Sales Performance</CardTitle>
|
||||||
|
<CardDescription>Monthly sales vs targets</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
|
<SelectTrigger className="w-32 cursor-pointer">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3m" className="cursor-pointer">Last 3 months</SelectItem>
|
||||||
|
<SelectItem value="6m" className="cursor-pointer">Last 6 months</SelectItem>
|
||||||
|
<SelectItem value="12m" className="cursor-pointer">Last 12 months</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" className="cursor-pointer">
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 pt-6">
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<ChartContainer config={chartConfig} className="h-[350px] w-full">
|
||||||
|
<AreaChart data={salesData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorSales" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorTarget" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-target)" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-target)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
className="text-xs"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
className="text-xs"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => `$${value.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="target"
|
||||||
|
stackId="1"
|
||||||
|
stroke="var(--color-target)"
|
||||||
|
fill="url(#colorTarget)"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="sales"
|
||||||
|
stackId="2"
|
||||||
|
stroke="var(--color-sales)"
|
||||||
|
fill="url(#colorSales)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
src/app/dashboard-2/components/top-products.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Eye, Star, TrendingUp } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Premium Dashboard",
|
||||||
|
sales: 2847,
|
||||||
|
revenue: "$142,350",
|
||||||
|
growth: "+23%",
|
||||||
|
rating: 4.8,
|
||||||
|
stock: 145,
|
||||||
|
category: "Software",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Analytics Pro",
|
||||||
|
sales: 1923,
|
||||||
|
revenue: "$96,150",
|
||||||
|
growth: "+18%",
|
||||||
|
rating: 4.6,
|
||||||
|
stock: 67,
|
||||||
|
category: "Tools",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Mobile App Suite",
|
||||||
|
sales: 1456,
|
||||||
|
revenue: "$72,800",
|
||||||
|
growth: "+12%",
|
||||||
|
rating: 4.9,
|
||||||
|
stock: 234,
|
||||||
|
category: "Mobile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Enterprise License",
|
||||||
|
sales: 892,
|
||||||
|
revenue: "$178,400",
|
||||||
|
growth: "+8%",
|
||||||
|
rating: 4.7,
|
||||||
|
stock: 12,
|
||||||
|
category: "Enterprise",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Basic Subscription",
|
||||||
|
sales: 3421,
|
||||||
|
revenue: "$68,420",
|
||||||
|
growth: "+31%",
|
||||||
|
rating: 4.4,
|
||||||
|
stock: 999,
|
||||||
|
category: "Subscription",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function TopProducts() {
|
||||||
|
return (
|
||||||
|
<Card className="cursor-pointer">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Top Products</CardTitle>
|
||||||
|
<CardDescription>Best performing products this month</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{products.map((product, index) => (
|
||||||
|
<div key={product.id} className="flex items-center p-3 rounded-lg border gap-2">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
||||||
|
#{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center justify-between space-x-3 flex-1 flex-wrap">
|
||||||
|
<div className="">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium truncate">{product.name}</p>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{product.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||||
|
<span className="text-xs text-muted-foreground">{product.rating}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">•</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{product.sales} sales</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right space-y-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">{product.revenue}</p>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-green-600 border-green-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
{product.growth}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Stock: {product.stock}</span>
|
||||||
|
<Progress
|
||||||
|
value={product.stock > 100 ? 100 : (product.stock / 100) * 100}
|
||||||
|
className="w-12 h-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/app/dashboard-2/data/dashboard-data.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"totalRevenue": 54231.89,
|
||||||
|
"revenueChange": 12.5,
|
||||||
|
"activeCustomers": 2350,
|
||||||
|
"customerChange": 5.2,
|
||||||
|
"totalOrders": 1247,
|
||||||
|
"orderChange": -2.1,
|
||||||
|
"conversionRate": 3.24,
|
||||||
|
"conversionChange": 8.3,
|
||||||
|
"salesData": [
|
||||||
|
{ "month": "Jan", "sales": 12500, "target": 15000 },
|
||||||
|
{ "month": "Feb", "sales": 18200, "target": 15000 },
|
||||||
|
{ "month": "Mar", "sales": 16800, "target": 15000 },
|
||||||
|
{ "month": "Apr", "sales": 22400, "target": 20000 },
|
||||||
|
{ "month": "May", "sales": 24600, "target": 20000 },
|
||||||
|
{ "month": "Jun", "sales": 28200, "target": 25000 },
|
||||||
|
{ "month": "Jul", "sales": 31500, "target": 25000 },
|
||||||
|
{ "month": "Aug", "sales": 29800, "target": 25000 },
|
||||||
|
{ "month": "Sep", "sales": 33200, "target": 30000 },
|
||||||
|
{ "month": "Oct", "sales": 35100, "target": 30000 },
|
||||||
|
{ "month": "Nov", "sales": 38900, "target": 35000 },
|
||||||
|
{ "month": "Dec", "sales": 42300, "target": 35000 }
|
||||||
|
],
|
||||||
|
"revenueBreakdown": [
|
||||||
|
{ "name": "Subscriptions", "value": 45, "amount": 24500, "color": "hsl(210, 100%, 50%)" },
|
||||||
|
{ "name": "One-time Sales", "value": 30, "amount": 16300, "color": "hsl(280, 100%, 70%)" },
|
||||||
|
{ "name": "Services", "value": 15, "amount": 8150, "color": "hsl(120, 100%, 40%)" },
|
||||||
|
{ "name": "Partnerships", "value": 10, "amount": 5430, "color": "hsl(30, 100%, 50%)" }
|
||||||
|
],
|
||||||
|
"customerGrowth": [
|
||||||
|
{ "month": "Jan", "new": 245, "returning": 890, "churn": 45 },
|
||||||
|
{ "month": "Feb", "new": 312, "returning": 934, "churn": 52 },
|
||||||
|
{ "month": "Mar", "new": 289, "returning": 1023, "churn": 38 },
|
||||||
|
{ "month": "Apr", "new": 456, "returning": 1156, "churn": 61 },
|
||||||
|
{ "month": "May", "new": 523, "returning": 1298, "churn": 47 },
|
||||||
|
{ "month": "Jun", "new": 634, "returning": 1445, "churn": 55 }
|
||||||
|
],
|
||||||
|
"lastUpdated": "2025-08-12T15:30:00Z"
|
||||||
|
}
|
||||||
50
src/app/dashboard-2/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { BaseLayout } from "@/components/layouts/base-layout"
|
||||||
|
import { MetricsOverview } from "./components/metrics-overview"
|
||||||
|
import { SalesChart } from "./components/sales-chart"
|
||||||
|
import { RecentTransactions } from "./components/recent-transactions"
|
||||||
|
import { TopProducts } from "./components/top-products"
|
||||||
|
import { CustomerInsights } from "./components/customer-insights"
|
||||||
|
import { QuickActions } from "./components/quick-actions"
|
||||||
|
import { RevenueBreakdown } from "./components/revenue-breakdown"
|
||||||
|
|
||||||
|
export default function Dashboard2() {
|
||||||
|
return (
|
||||||
|
<BaseLayout>
|
||||||
|
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||||
|
{/* Enhanced Header */}
|
||||||
|
|
||||||
|
<div className="flex md:flex-row flex-col md:items-center justify-between gap-4 md:gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Business Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Monitor your business performance and key metrics in real-time
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<QuickActions />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Dashboard Grid */}
|
||||||
|
<div className="@container/main space-y-6">
|
||||||
|
{/* Top Row - Key Metrics */}
|
||||||
|
|
||||||
|
<MetricsOverview />
|
||||||
|
|
||||||
|
{/* Second Row - Charts in 6-6 columns */}
|
||||||
|
<div className="grid gap-6 grid-cols-1 @5xl:grid-cols-2">
|
||||||
|
<SalesChart />
|
||||||
|
<RevenueBreakdown />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Third Row - Two Column Layout */}
|
||||||
|
<div className="grid gap-6 grid-cols-1 @5xl:grid-cols-2">
|
||||||
|
<RecentTransactions />
|
||||||
|
<TopProducts />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fourth Row - Customer Insights and Team Performance */}
|
||||||
|
<CustomerInsights />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
291
src/app/dashboard/components/chart-area-interactive.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
type ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/components/ui/toggle-group"
|
||||||
|
|
||||||
|
export const description = "An interactive area chart"
|
||||||
|
|
||||||
|
const chartData = [
|
||||||
|
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||||
|
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||||
|
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||||
|
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||||
|
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||||
|
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||||
|
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||||
|
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||||
|
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||||
|
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||||
|
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||||
|
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||||
|
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||||
|
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||||
|
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||||
|
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||||
|
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||||
|
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||||
|
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||||
|
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||||
|
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||||
|
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||||
|
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||||
|
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||||
|
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||||
|
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||||
|
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||||
|
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||||
|
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||||
|
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||||
|
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||||
|
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||||
|
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||||
|
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||||
|
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||||
|
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||||
|
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||||
|
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||||
|
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||||
|
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||||
|
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||||
|
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||||
|
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||||
|
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||||
|
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||||
|
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||||
|
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||||
|
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||||
|
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||||
|
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||||
|
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||||
|
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||||
|
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||||
|
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||||
|
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||||
|
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||||
|
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||||
|
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||||
|
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||||
|
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||||
|
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||||
|
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||||
|
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||||
|
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||||
|
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||||
|
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||||
|
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||||
|
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||||
|
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||||
|
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||||
|
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||||
|
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||||
|
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||||
|
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||||
|
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||||
|
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||||
|
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||||
|
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||||
|
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||||
|
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||||
|
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||||
|
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||||
|
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||||
|
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||||
|
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||||
|
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||||
|
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||||
|
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||||
|
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||||
|
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||||
|
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
visitors: {
|
||||||
|
label: "Visitors",
|
||||||
|
},
|
||||||
|
desktop: {
|
||||||
|
label: "Desktop",
|
||||||
|
color: "var(--primary)",
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
label: "Mobile",
|
||||||
|
color: "var(--primary)",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
export function ChartAreaInteractive() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [timeRange, setTimeRange] = React.useState("90d")
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeRange("7d")
|
||||||
|
}
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
const filteredData = chartData.filter((item) => {
|
||||||
|
const date = new Date(item.date)
|
||||||
|
const referenceDate = new Date("2024-06-30")
|
||||||
|
let daysToSubtract = 90
|
||||||
|
if (timeRange === "30d") {
|
||||||
|
daysToSubtract = 30
|
||||||
|
} else if (timeRange === "7d") {
|
||||||
|
daysToSubtract = 7
|
||||||
|
}
|
||||||
|
const startDate = new Date(referenceDate)
|
||||||
|
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||||
|
return date >= startDate
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Total Visitors</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<span className="hidden @[540px]/card:block">
|
||||||
|
Total for the last 3 months
|
||||||
|
</span>
|
||||||
|
<span className="@[540px]/card:hidden">Last 3 months</span>
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={timeRange}
|
||||||
|
onValueChange={setTimeRange}
|
||||||
|
variant="outline"
|
||||||
|
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Select a value"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Last 3 months" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="90d" className="rounded-lg">
|
||||||
|
Last 3 months
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="30d" className="rounded-lg">
|
||||||
|
Last 30 days
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="7d" className="rounded-lg">
|
||||||
|
Last 7 days
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="aspect-auto h-[250px] w-full"
|
||||||
|
>
|
||||||
|
<AreaChart data={filteredData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="var(--color-desktop)"
|
||||||
|
stopOpacity={1.0}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="var(--color-desktop)"
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="var(--color-mobile)"
|
||||||
|
stopOpacity={0.8}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="var(--color-mobile)"
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={32}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(value) => {
|
||||||
|
return new Date(value).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
indicator="dot"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="mobile"
|
||||||
|
type="natural"
|
||||||
|
fill="url(#fillMobile)"
|
||||||
|
stroke="var(--color-mobile)"
|
||||||
|
stackId="a"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="desktop"
|
||||||
|
type="natural"
|
||||||
|
fill="url(#fillDesktop)"
|
||||||
|
stroke="var(--color-desktop)"
|
||||||
|
stackId="a"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
1088
src/app/dashboard/components/data-table.tsx
Normal file
102
src/app/dashboard/components/section-cards.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { TrendingDown, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
|
||||||
|
export function SectionCards() {
|
||||||
|
return (
|
||||||
|
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card className="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Total Revenue</CardDescription>
|
||||||
|
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
$1,250.00
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<TrendingUp />
|
||||||
|
+12.5%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Trending up this month <TrendingUp className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Visitors for the last 6 months
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card className="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>New Customers</CardDescription>
|
||||||
|
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
1,234
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<TrendingDown />
|
||||||
|
-20%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Down 20% this period <TrendingDown className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Acquisition needs attention
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card className="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Active Accounts</CardDescription>
|
||||||
|
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
45,678
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<TrendingUp />
|
||||||
|
+12.5%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Strong user retention <TrendingUp className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">Engagement exceed targets</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card className="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Growth Rate</CardDescription>
|
||||||
|
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
4.5%
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<TrendingUp />
|
||||||
|
+4.5%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Steady performance increase <TrendingUp className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">Meets growth projections</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
614
src/app/dashboard/data/data.json
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"header": "Cover page",
|
||||||
|
"type": "Cover page",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "18",
|
||||||
|
"limit": "5",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"header": "Table of contents",
|
||||||
|
"type": "Table of contents",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "29",
|
||||||
|
"limit": "24",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"header": "Executive summary",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "10",
|
||||||
|
"limit": "13",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"header": "Technical approach",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "27",
|
||||||
|
"limit": "23",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"header": "Design",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "2",
|
||||||
|
"limit": "16",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"header": "Capabilities",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "20",
|
||||||
|
"limit": "8",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"header": "Integration with existing systems",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "19",
|
||||||
|
"limit": "21",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"header": "Innovation and Advantages",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "25",
|
||||||
|
"limit": "26",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"header": "Overview of EMR's Innovative Solutions",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "7",
|
||||||
|
"limit": "23",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"header": "Advanced Algorithms and Machine Learning",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "30",
|
||||||
|
"limit": "28",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"header": "Adaptive Communication Protocols",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "9",
|
||||||
|
"limit": "31",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"header": "Advantages Over Current Technologies",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "12",
|
||||||
|
"limit": "0",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"header": "Past Performance",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "22",
|
||||||
|
"limit": "33",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"header": "Customer Feedback and Satisfaction Levels",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "15",
|
||||||
|
"limit": "34",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"header": "Implementation Challenges and Solutions",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "3",
|
||||||
|
"limit": "35",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"header": "Security Measures and Data Protection Policies",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "6",
|
||||||
|
"limit": "36",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"header": "Scalability and Future Proofing",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "4",
|
||||||
|
"limit": "37",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"header": "Cost-Benefit Analysis",
|
||||||
|
"type": "Plain language",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "14",
|
||||||
|
"limit": "38",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"header": "User Training and Onboarding Experience",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "17",
|
||||||
|
"limit": "39",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"header": "Future Development Roadmap",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "11",
|
||||||
|
"limit": "40",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"header": "System Architecture Overview",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "24",
|
||||||
|
"limit": "18",
|
||||||
|
"reviewer": "Maya Johnson"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"header": "Risk Management Plan",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "15",
|
||||||
|
"limit": "22",
|
||||||
|
"reviewer": "Carlos Rodriguez"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"header": "Compliance Documentation",
|
||||||
|
"type": "Legal",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "31",
|
||||||
|
"limit": "27",
|
||||||
|
"reviewer": "Sarah Chen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"header": "API Documentation",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "8",
|
||||||
|
"limit": "12",
|
||||||
|
"reviewer": "Raj Patel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"header": "User Interface Mockups",
|
||||||
|
"type": "Visual",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "19",
|
||||||
|
"limit": "25",
|
||||||
|
"reviewer": "Leila Ahmadi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"header": "Database Schema",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "22",
|
||||||
|
"limit": "20",
|
||||||
|
"reviewer": "Thomas Wilson"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"header": "Testing Methodology",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "17",
|
||||||
|
"limit": "14",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"header": "Deployment Strategy",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "26",
|
||||||
|
"limit": "30",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 29,
|
||||||
|
"header": "Budget Breakdown",
|
||||||
|
"type": "Financial",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "13",
|
||||||
|
"limit": "16",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 30,
|
||||||
|
"header": "Market Analysis",
|
||||||
|
"type": "Research",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "29",
|
||||||
|
"limit": "32",
|
||||||
|
"reviewer": "Sophia Martinez"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 31,
|
||||||
|
"header": "Competitor Comparison",
|
||||||
|
"type": "Research",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "21",
|
||||||
|
"limit": "19",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"header": "Maintenance Plan",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "16",
|
||||||
|
"limit": "23",
|
||||||
|
"reviewer": "Alex Thompson"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 33,
|
||||||
|
"header": "User Personas",
|
||||||
|
"type": "Research",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "27",
|
||||||
|
"limit": "24",
|
||||||
|
"reviewer": "Nina Patel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 34,
|
||||||
|
"header": "Accessibility Compliance",
|
||||||
|
"type": "Legal",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "18",
|
||||||
|
"limit": "21",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"header": "Performance Metrics",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "23",
|
||||||
|
"limit": "26",
|
||||||
|
"reviewer": "David Kim"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 36,
|
||||||
|
"header": "Disaster Recovery Plan",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "14",
|
||||||
|
"limit": "17",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"header": "Third-party Integrations",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "25",
|
||||||
|
"limit": "28",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 38,
|
||||||
|
"header": "User Feedback Summary",
|
||||||
|
"type": "Research",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "20",
|
||||||
|
"limit": "15",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 39,
|
||||||
|
"header": "Localization Strategy",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "12",
|
||||||
|
"limit": "19",
|
||||||
|
"reviewer": "Maria Garcia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 40,
|
||||||
|
"header": "Mobile Compatibility",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "28",
|
||||||
|
"limit": "31",
|
||||||
|
"reviewer": "James Wilson"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"header": "Data Migration Plan",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "19",
|
||||||
|
"limit": "22",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"header": "Quality Assurance Protocols",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "30",
|
||||||
|
"limit": "33",
|
||||||
|
"reviewer": "Priya Singh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"header": "Stakeholder Analysis",
|
||||||
|
"type": "Research",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "11",
|
||||||
|
"limit": "14",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 44,
|
||||||
|
"header": "Environmental Impact Assessment",
|
||||||
|
"type": "Research",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "24",
|
||||||
|
"limit": "27",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 45,
|
||||||
|
"header": "Intellectual Property Rights",
|
||||||
|
"type": "Legal",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "17",
|
||||||
|
"limit": "20",
|
||||||
|
"reviewer": "Sarah Johnson"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 46,
|
||||||
|
"header": "Customer Support Framework",
|
||||||
|
"type": "Narrative",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "22",
|
||||||
|
"limit": "25",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 47,
|
||||||
|
"header": "Version Control Strategy",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "15",
|
||||||
|
"limit": "18",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 48,
|
||||||
|
"header": "Continuous Integration Pipeline",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "26",
|
||||||
|
"limit": "29",
|
||||||
|
"reviewer": "Michael Chen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 49,
|
||||||
|
"header": "Regulatory Compliance",
|
||||||
|
"type": "Legal",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "13",
|
||||||
|
"limit": "16",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"header": "User Authentication System",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "28",
|
||||||
|
"limit": "31",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 51,
|
||||||
|
"header": "Data Analytics Framework",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "21",
|
||||||
|
"limit": "24",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"header": "Cloud Infrastructure",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "16",
|
||||||
|
"limit": "19",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 53,
|
||||||
|
"header": "Network Security Measures",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "29",
|
||||||
|
"limit": "32",
|
||||||
|
"reviewer": "Lisa Wong"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 54,
|
||||||
|
"header": "Project Timeline",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "14",
|
||||||
|
"limit": "17",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 55,
|
||||||
|
"header": "Resource Allocation",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "27",
|
||||||
|
"limit": "30",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 56,
|
||||||
|
"header": "Team Structure and Roles",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "20",
|
||||||
|
"limit": "23",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 57,
|
||||||
|
"header": "Communication Protocols",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "15",
|
||||||
|
"limit": "18",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 58,
|
||||||
|
"header": "Success Metrics",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "30",
|
||||||
|
"limit": "33",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 59,
|
||||||
|
"header": "Internationalization Support",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "23",
|
||||||
|
"limit": "26",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 60,
|
||||||
|
"header": "Backup and Recovery Procedures",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "18",
|
||||||
|
"limit": "21",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 61,
|
||||||
|
"header": "Monitoring and Alerting System",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "25",
|
||||||
|
"limit": "28",
|
||||||
|
"reviewer": "Daniel Park"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 62,
|
||||||
|
"header": "Code Review Guidelines",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "12",
|
||||||
|
"limit": "15",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 63,
|
||||||
|
"header": "Documentation Standards",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "27",
|
||||||
|
"limit": "30",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 64,
|
||||||
|
"header": "Release Management Process",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "22",
|
||||||
|
"limit": "25",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 65,
|
||||||
|
"header": "Feature Prioritization Matrix",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "19",
|
||||||
|
"limit": "22",
|
||||||
|
"reviewer": "Emma Davis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 66,
|
||||||
|
"header": "Technical Debt Assessment",
|
||||||
|
"type": "Technical content",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "24",
|
||||||
|
"limit": "27",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 67,
|
||||||
|
"header": "Capacity Planning",
|
||||||
|
"type": "Planning",
|
||||||
|
"status": "In Process",
|
||||||
|
"target": "21",
|
||||||
|
"limit": "24",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 68,
|
||||||
|
"header": "Service Level Agreements",
|
||||||
|
"type": "Legal",
|
||||||
|
"status": "Done",
|
||||||
|
"target": "26",
|
||||||
|
"limit": "29",
|
||||||
|
"reviewer": "Assign reviewer"
|
||||||
|
}
|
||||||
|
]
|
||||||
47
src/app/dashboard/data/focus-documents-data.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"header": "Technical Specifications Document v2.1",
|
||||||
|
"type": "Technical Document",
|
||||||
|
"status": "Final",
|
||||||
|
"target": "100%",
|
||||||
|
"limit": "100%",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"header": "Security Compliance Report Q4 2024",
|
||||||
|
"type": "Compliance Document",
|
||||||
|
"status": "Under Review",
|
||||||
|
"target": "95%",
|
||||||
|
"limit": "100%",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"header": "Project Management Plan v3.0",
|
||||||
|
"type": "Management Document",
|
||||||
|
"status": "Final",
|
||||||
|
"target": "100%",
|
||||||
|
"limit": "100%",
|
||||||
|
"reviewer": "Emily Whalen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"header": "Risk Assessment Matrix 2025",
|
||||||
|
"type": "Risk Document",
|
||||||
|
"status": "Draft",
|
||||||
|
"target": "80%",
|
||||||
|
"limit": "90%",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"header": "Quality Assurance Protocol v1.5",
|
||||||
|
"type": "QA Document",
|
||||||
|
"status": "Final",
|
||||||
|
"target": "100%",
|
||||||
|
"limit": "100%",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
}
|
||||||
|
]
|
||||||
47
src/app/dashboard/data/key-personnel-data.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"header": "Dr. Sarah Mitchell",
|
||||||
|
"type": "Project Manager",
|
||||||
|
"status": "Active",
|
||||||
|
"target": "15 years",
|
||||||
|
"limit": "20 years",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"header": "James Thompson",
|
||||||
|
"type": "Lead Engineer",
|
||||||
|
"status": "Active",
|
||||||
|
"target": "12 years",
|
||||||
|
"limit": "15 years",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"header": "Maria Rodriguez",
|
||||||
|
"type": "Security Specialist",
|
||||||
|
"status": "Active",
|
||||||
|
"target": "8 years",
|
||||||
|
"limit": "10 years",
|
||||||
|
"reviewer": "Emily Whalen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"header": "David Chen",
|
||||||
|
"type": "Systems Architect",
|
||||||
|
"status": "Active",
|
||||||
|
"target": "10 years",
|
||||||
|
"limit": "12 years",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"header": "Lisa Johnson",
|
||||||
|
"type": "Quality Assurance Lead",
|
||||||
|
"status": "Active",
|
||||||
|
"target": "6 years",
|
||||||
|
"limit": "8 years",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
}
|
||||||
|
]
|
||||||
47
src/app/dashboard/data/past-performance-data.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"header": "Federal Communications Commission - Network Infrastructure Modernization",
|
||||||
|
"type": "Government Contract",
|
||||||
|
"status": "Completed",
|
||||||
|
"target": "95%",
|
||||||
|
"limit": "100%",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"header": "Department of Defense - Cybersecurity Enhancement Program",
|
||||||
|
"type": "Defense Contract",
|
||||||
|
"status": "Completed",
|
||||||
|
"target": "98%",
|
||||||
|
"limit": "100%",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"header": "NASA - Satellite Communication System Upgrade",
|
||||||
|
"type": "Space Technology",
|
||||||
|
"status": "Completed",
|
||||||
|
"target": "92%",
|
||||||
|
"limit": "95%",
|
||||||
|
"reviewer": "Emily Whalen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"header": "Department of Homeland Security - Border Security Tech",
|
||||||
|
"type": "Security Contract",
|
||||||
|
"status": "In Progress",
|
||||||
|
"target": "85%",
|
||||||
|
"limit": "90%",
|
||||||
|
"reviewer": "Eddie Lake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"header": "GSA - Cloud Infrastructure Migration",
|
||||||
|
"type": "IT Services",
|
||||||
|
"status": "Completed",
|
||||||
|
"target": "96%",
|
||||||
|
"limit": "98%",
|
||||||
|
"reviewer": "Jamik Tashpulatov"
|
||||||
|
}
|
||||||
|
]
|
||||||
28
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { BaseLayout } from "@/components/layouts/base-layout"
|
||||||
|
import { ChartAreaInteractive } from "./components/chart-area-interactive"
|
||||||
|
import { DataTable } from "./components/data-table"
|
||||||
|
import { SectionCards } from "./components/section-cards"
|
||||||
|
|
||||||
|
import data from "./data/data.json"
|
||||||
|
import pastPerformanceData from "./data/past-performance-data.json"
|
||||||
|
import keyPersonnelData from "./data/key-personnel-data.json"
|
||||||
|
import focusDocumentsData from "./data/focus-documents-data.json"
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<BaseLayout title="Dashboard" description="Welcome to your admin dashboard">
|
||||||
|
<div className="@container/main px-4 lg:px-6 space-y-6">
|
||||||
|
<SectionCards />
|
||||||
|
<ChartAreaInteractive />
|
||||||
|
</div>
|
||||||
|
<div className="@container/main">
|
||||||
|
<DataTable
|
||||||
|
data={data}
|
||||||
|
pastPerformanceData={pastPerformanceData}
|
||||||
|
keyPersonnelData={keyPersonnelData}
|
||||||
|
focusDocumentsData={focusDocumentsData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/app/dashboard/schemas/task-schema.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
header: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
target: z.string(),
|
||||||
|
limit: z.string(),
|
||||||
|
reviewer: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Task = z.infer<typeof schema>
|
||||||
29
src/app/errors/forbidden/components/forbidden-error.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
export function ForbiddenError() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||||
|
<img
|
||||||
|
src='https://ui.shadcn.com/placeholder.svg'
|
||||||
|
alt='placeholder image'
|
||||||
|
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||||
|
/>
|
||||||
|
<div className='text-center'>
|
||||||
|
<h1 className='mb-4 text-3xl font-bold'>403</h1>
|
||||||
|
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
|
||||||
|
<p>Access to this resource is forbidden. You don't have the necessary permissions to view this page.</p>
|
||||||
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
|
<Button className='cursor-pointer' onClick={() => navigate('/dashboard')}>Go Back Home</Button>
|
||||||
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => navigate('#')}>
|
||||||
|
Contact Us
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/app/errors/forbidden/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ForbiddenError } from "./components/forbidden-error"
|
||||||
|
|
||||||
|
export default function ForbiddenPage() {
|
||||||
|
return <ForbiddenError />
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
export function InternalServerError() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||||
|
<img
|
||||||
|
src='https://ui.shadcn.com/placeholder.svg'
|
||||||
|
alt='placeholder image'
|
||||||
|
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||||
|
/>
|
||||||
|
<div className='text-center'>
|
||||||
|
<h1 className='mb-4 text-3xl font-bold'>500</h1>
|
||||||
|
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
|
||||||
|
<p>Something went wrong on our end. We're working to fix the issue. Please try again later.</p>
|
||||||
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
|
<Button className='cursor-pointer' onClick={() => navigate('/dashboard')}>Go Back Home</Button>
|
||||||
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => navigate('')}>
|
||||||
|
Contact Us
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/app/errors/internal-server-error/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InternalServerError } from "./components/internal-server-error"
|
||||||
|
|
||||||
|
export default function InternalServerErrorPage() {
|
||||||
|
return <InternalServerError />
|
||||||
|
}
|
||||||
29
src/app/errors/not-found/components/not-found-error.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
export function NotFoundError() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||||
|
<img
|
||||||
|
src='https://ui.shadcn.com/placeholder.svg'
|
||||||
|
alt='placeholder image'
|
||||||
|
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||||
|
/>
|
||||||
|
<div className='text-center'>
|
||||||
|
<h1 className='mb-4 text-3xl font-bold'>404</h1>
|
||||||
|
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
|
||||||
|
<p>The page you are looking for doesn't exist or has been moved to another location.</p>
|
||||||
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
|
<Button className='cursor-pointer' onClick={() => navigate('/dashboard')}>Go Back Home</Button>
|
||||||
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => navigate('#')}>
|
||||||
|
Contact Us
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/app/errors/not-found/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotFoundError } from "./components/not-found-error"
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return <NotFoundError />
|
||||||
|
}
|
||||||