Initial commit: setup project
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE/editor settings
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
11
README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
# (Success) v 02 KLC Website with Learner's Learning 0.1
|
||||||
|
|
||||||
|
This is a code bundle for (Success) v 02 KLC Website with Learner's Learning 0.1. The original project is available at https://www.figma.com/design/EsJMRo8s3zDQtHwPpj5TVk/-Success--v-02-KLC-Website-with-Learner-s-Learning-0.1.
|
||||||
|
|
||||||
|
## Running the code
|
||||||
|
|
||||||
|
Run `npm i` to install the dependencies.
|
||||||
|
|
||||||
|
Run `npm run dev` to start the development server.
|
||||||
|
|
||||||
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>(Success) v 02 KLC Website with Learner's Learning 0.1</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
4094
package-lock.json
generated
Normal file
62
package.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "(Success) v 02 KLC Website with Learner's Learning 0.1",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.3",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.6",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.6",
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.6",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||||
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
|
"@radix-ui/react-progress": "^1.1.2",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
"@radix-ui/react-slider": "^1.2.3",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "*",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.487.0",
|
||||||
|
"motion": "*",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.55.0",
|
||||||
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"recharts": "^2.15.2",
|
||||||
|
"sonner": "^2.0.3",
|
||||||
|
"tailwind-merge": "*",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
|
"vaul": "^1.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
|
"vite": "^5.4.19"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/.figma_internal/CurrentLearningHeaderIcon.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import svgPaths from "./svg-50ykfce76w";
|
||||||
|
|
||||||
|
export default function CurrentLearningHeaderIcon() {
|
||||||
|
return (
|
||||||
|
<div className="relative size-full" data-name="current learning header icon">
|
||||||
|
<svg className="block size-full" fill="none" preserveAspectRatio="none" viewBox="0 0 24 24">
|
||||||
|
<g id="current learning header icon">
|
||||||
|
<path d={svgPaths.p3fb52080} fill="var(--fill-0, black)" id="Vector" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/.figma_internal/svg-50ykfce76w.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
p3fb52080: "M12.4789 3.26119C12.1804 3.09839 11.8196 3.09839 11.5211 3.26119L2.61015 8.12174C1.91514 8.50083 1.91532 9.49885 2.61045 9.8777L4.47854 10.8958C4.79998 11.071 5 11.4078 5 11.7739V16.5865C5 16.9524 5.19981 17.289 5.52097 17.4643L11.521 20.7386C11.8195 20.9015 12.1805 20.9015 12.479 20.7386L18.479 17.4643C18.8002 17.289 19 16.9524 19 16.5865V11.7739C19 11.4078 19.2 11.071 19.5215 10.8958V10.8958C20.1878 10.5326 21 11.015 21 11.7739V16C21 16.5523 21.4477 17 22 17V17C22.5523 17 23 16.5523 23 16V9.59363C23 9.22769 22.8001 8.89097 22.4789 8.71574L12.4789 3.26119ZM17.2105 8.1221C17.9054 8.50112 17.9054 9.49888 17.2105 9.8779L12.4789 12.4588C12.1804 12.6216 11.8196 12.6216 11.5211 12.4588L6.78947 9.87789C6.09461 9.49888 6.09461 8.50112 6.78948 8.1221L11.5211 5.54119C11.8196 5.37839 12.1804 5.37839 12.4789 5.54119L17.2105 8.1221ZM17 15.4056C17 15.772 16.7997 16.109 16.4779 16.284L12.4779 18.46C12.1799 18.6221 11.8201 18.6221 11.5221 18.46L7.52213 16.284C7.20032 16.109 7 15.772 7 15.4056V13.9554C7 13.1961 7.81284 12.7138 8.47922 13.0777L11.5208 14.7383C11.8195 14.9014 12.1806 14.9014 12.4792 14.7383L15.5208 13.0777C16.1872 12.7138 17 13.1961 17 13.9553V15.4056Z",
|
||||||
|
}
|
||||||
1187
src/App.tsx
Normal file
3
src/Attributions.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
|
||||||
|
|
||||||
|
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
|
||||||
140
src/Guidelines.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# KLC Website Guidelines
|
||||||
|
|
||||||
|
## Query Parameter Dashboard System
|
||||||
|
|
||||||
|
### Unified Dashboard Routing
|
||||||
|
* **Primary Route**: `/dashboard` - Auto-detects user type or defaults to individual
|
||||||
|
* **Explicit Routes**:
|
||||||
|
- `/dashboard?view=individual` - Forces individual learner view
|
||||||
|
- `/dashboard?view=corporate` - Forces corporate learner view
|
||||||
|
* **All Learner Portal Pages** now support query parameters:
|
||||||
|
- `/library?view=individual` or `/library?view=corporate`
|
||||||
|
- `/settings?view=individual` or `/settings?view=corporate`
|
||||||
|
- `/surveys?view=individual` or `/surveys?view=corporate`
|
||||||
|
- `/webinars?view=individual` or `/webinars?view=corporate`
|
||||||
|
- `/leaderboard?view=individual` or `/leaderboard?view=corporate`
|
||||||
|
|
||||||
|
### Legacy Route Handling
|
||||||
|
* **Automatic Redirects**: Old `/corporate/*` routes redirect to query parameter equivalents
|
||||||
|
* **Backward Compatibility**: Legacy routes still work but redirect users to new URLs
|
||||||
|
* **URL Updates**: Browser history automatically updates to new query parameter format
|
||||||
|
|
||||||
|
## Page Header & Navigation Standards
|
||||||
|
|
||||||
|
### Universal Back Button Requirements
|
||||||
|
* **All internal pages must include a back button** in the header for consistent navigation
|
||||||
|
* **Header structure**: Use `pt-24 pb-8` to account for fixed navbar (70px + padding)
|
||||||
|
* **Back button placement**: Position in top-left of header with proper spacing and alignment
|
||||||
|
* **Navigation logic**: Back button should return users to their expected previous page:
|
||||||
|
- Individual learner pages → Individual dashboard (`/dashboard?view=individual`)
|
||||||
|
- Corporate learner pages → Corporate dashboard (`/dashboard?view=corporate`)
|
||||||
|
- Service pages → Services overview (`/services`) [Deprecated - redirects to home]
|
||||||
|
- About pages → About overview (`/about-us/our-vision`) [Deprecated - redirects to home]
|
||||||
|
- Learning pages → Learning hub (`/learning/articles`) [Deprecated - redirects to home]
|
||||||
|
- General pages → Home page (`/`)
|
||||||
|
|
||||||
|
### Header Component Pattern
|
||||||
|
```tsx
|
||||||
|
// Standard header pattern for all pages
|
||||||
|
<div className="bg-primary text-primary-foreground pt-24 pb-8">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
<div className="max-w-4xl mx-auto"> {/* Adjust max-width based on content */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleBackNavigation}
|
||||||
|
className="text-primary-foreground hover:bg-primary-foreground/10 min-h-[44px] min-w-[44px] mt-1"
|
||||||
|
aria-label="Go back to [appropriate page name]"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-4xl mb-3">[Page Title]</h1>
|
||||||
|
<p className="text-lg text-primary-foreground/90 leading-relaxed">
|
||||||
|
[Page description]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Imports for Back Navigation
|
||||||
|
```tsx
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { navigateTo } from '../components/Router';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard Navigation Handler
|
||||||
|
```tsx
|
||||||
|
const handleBackNavigation = () => {
|
||||||
|
// Choose appropriate navigation based on page context
|
||||||
|
navigateTo('/dashboard?view=individual'); // or /dashboard?view=corporate for corporate learners
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typography & Font Sizing
|
||||||
|
|
||||||
|
### Minimum Font Size Requirements
|
||||||
|
* **Absolute minimum font size**: 14px (0.875rem at 14px base)
|
||||||
|
* **Preferred minimum font size**: 16px (1rem at 14px base)
|
||||||
|
* **Never use font sizes smaller than 14px** for any text content
|
||||||
|
* Use 16px (1rem) for all body text, labels, buttons, and form inputs
|
||||||
|
* Use 14px (0.875rem) only for small text elements like captions, metadata, or legal text
|
||||||
|
|
||||||
|
### Typography Hierarchy
|
||||||
|
* **H1**: 36px (2.25rem) - Page titles
|
||||||
|
* **H2**: 30px (1.875rem) - Section headings
|
||||||
|
* **H3**: 24px (1.5rem) - Subsection headings
|
||||||
|
* **H4**: 20px (1.25rem) - Component titles
|
||||||
|
* **H5**: 18px (1.125rem) - Card titles
|
||||||
|
* **H6**: 16px (1rem) - Small headings
|
||||||
|
* **Body text**: 16px (1rem) - All paragraph text
|
||||||
|
* **Labels/Buttons**: 16px (1rem) - Form labels, button text
|
||||||
|
* **Small text**: 14px (0.875rem) - Captions, metadata, fine print
|
||||||
|
|
||||||
|
### Font Implementation Rules
|
||||||
|
* Always use explicit font sizes in Tailwind classes when overriding defaults
|
||||||
|
* Use `text-base` (16px) as the default for most text content
|
||||||
|
* Use `text-sm` (14px) sparingly for secondary information
|
||||||
|
* Never use `text-xs` (12px) - override with `text-sm` minimum
|
||||||
|
* Ensure good contrast ratios (minimum 4.5:1) with all font sizes
|
||||||
|
|
||||||
|
## Layout & Spacing
|
||||||
|
|
||||||
|
### Horizontal Padding
|
||||||
|
* Apply consistent horizontal padding to all page sections using the same values as the navigation bar
|
||||||
|
* Use `px-4 lg:px-8` pattern for consistent horizontal spacing
|
||||||
|
* Container sections should use `container mx-auto px-4 lg:px-8`
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
* Minimum 44×44px touch targets for buttons and interactive elements
|
||||||
|
* Respect `prefers-reduced-motion` for animations
|
||||||
|
* Maintain WCAG 2.1 AA compliance
|
||||||
|
* Test all font sizes for readability across devices
|
||||||
|
|
||||||
|
## Component Guidelines
|
||||||
|
|
||||||
|
### Text Elements
|
||||||
|
* Override default component font sizes if they fall below 14px
|
||||||
|
* Explicitly set typography classes on all text elements
|
||||||
|
* Use semantic HTML elements with appropriate font sizes
|
||||||
|
* Ensure form inputs and labels are minimum 16px for mobile usability
|
||||||
|
|
||||||
|
### Responsive Design
|
||||||
|
* Test font sizes across all breakpoints
|
||||||
|
* Ensure readability on mobile devices (minimum 16px for body text)
|
||||||
|
* Use responsive typography classes where appropriate
|
||||||
|
|
||||||
|
## Quality Checks
|
||||||
|
|
||||||
|
Before finalizing any page or component:
|
||||||
|
1. ✅ Verify no text is smaller than 14px
|
||||||
|
2. ✅ Confirm body text and interactive elements are 16px
|
||||||
|
3. ✅ Test readability across different screen sizes
|
||||||
|
4. ✅ Check contrast ratios meet accessibility standards
|
||||||
|
5. ✅ Ensure consistent horizontal padding with navigation
|
||||||
|
|
||||||
|
Some of the base components you are using may have styling (eg. gap/typography) baked in as defaults. Make sure you explicitly set any styling information from the guidelines in the generated React to override the defaults.
|
||||||
BIN
src/assets/037c4659b7b0bf15b1dfdcd4868cb42e8257e838.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/1b56e6afe31d5744d2e7a38d3e2f8c3ce78a90af.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
src/assets/1bb9c22c86c0892d4716564b7135835f04869298.png
Normal file
|
After Width: | Height: | Size: 899 KiB |
BIN
src/assets/209958db0c439ec78be82ab4f3e335a6aed5de89.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/2824e18f6ed39b8e82cf8a9fc215648cde48d2f4.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/3a97bc3c43824d72250953bd1d41ece20112a45a.png
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
src/assets/468d85c60825612022ad15f5afa770440bd885e1.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
src/assets/4833274f0a593cd31fdefe553b70bb016de281af.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/50c9ddeeb90128ebffbfbfe2dea36d09c03b5335.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/5fddc261d2e35ee810113f2537c5a59a97fd7fbd.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/624ce058c9c961b32643853cf5c692afe9d3ed60.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/6bdf8056f51bbdc6dd9dab9044a6579a254bd02c.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/assets/6cae567b6bf6a44cb03b767e4308c4c705340d08.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/75c17cf51744205abe7b7042766b8dfdb7b2e8a4.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
src/assets/9852710543a90e291ecb85d77ea02234139264c5.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
src/assets/9ebc01e8eb24f9d71683b2ee63d224583a979590.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/a224fb6efc954992c535e482fe88d93f1f4178d8.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src/assets/ae07aac2d7927002260d7261da0eee0c09a8352f.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/c2d0a01da274cef655bbdfb1b11ff3e9993ea278.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/c501c3d3f3a828828d4cb2dadb9558b43986718f.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
src/assets/c57ec1f4466f68e607139a3cd6d52f7e2f372408.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/d5bab6ea4f3d8cef3b0425c45cfee7faea19fdbc.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/e8fad960112d5eba554c3969d08891ebe4d4b9c7.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/f7fa2dab6765df7645e62459459afe9a6ff4959b.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/fc9194a6dac9bc6614c0646ed0b66177408ca5e6.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
337
src/components/AIChatbot.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import {
|
||||||
|
MessageCircle,
|
||||||
|
X,
|
||||||
|
Send,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
Minimize2,
|
||||||
|
Maximize2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
type: 'user' | 'bot';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMessage: Message = {
|
||||||
|
id: '1',
|
||||||
|
type: 'bot',
|
||||||
|
content: "Hi! I'm here to help you explore KLC's leadership programs and facilities. What are you looking for today?",
|
||||||
|
timestamp: new Date(),
|
||||||
|
suggestions: [
|
||||||
|
"Show me leadership programs",
|
||||||
|
"Book a facility tour",
|
||||||
|
"Upcoming webinars",
|
||||||
|
"Contact information"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const botResponses: Record<string, { content: string; suggestions?: string[] }> = {
|
||||||
|
"programs": {
|
||||||
|
content: "Great! We offer various leadership development programs including Executive Leadership, Strategic Management, and Team Building workshops. Would you like to explore specific programs or see our full catalog?",
|
||||||
|
suggestions: ["View all programs", "Executive programs", "Team building", "Custom corporate training"]
|
||||||
|
},
|
||||||
|
"facilities": {
|
||||||
|
content: "Our state-of-the-art facilities include modern conference rooms, training halls, and collaboration spaces. You can take a virtual tour or book a facility for your event.",
|
||||||
|
suggestions: ["Virtual tour", "Book conference room", "Training halls", "Facility pricing"]
|
||||||
|
},
|
||||||
|
"webinars": {
|
||||||
|
content: "We host regular webinars on leadership topics. You can view upcoming sessions, register for live events, or access our library of recorded sessions.",
|
||||||
|
suggestions: ["Upcoming webinars", "Recorded sessions", "Register for webinar", "Webinar schedule"]
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
content: "You can reach us at info@klc.edu.in or call +91 11 4567 8900. Our team is available Monday to Friday, 9 AM to 6 PM IST. You can also schedule a consultation.",
|
||||||
|
suggestions: ["Schedule consultation", "Email us", "Office locations", "Support hours"]
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
content: "I can help you with information about our programs, facilities, webinars, and more. What would you like to know?",
|
||||||
|
suggestions: ["Leadership programs", "Facility booking", "Webinars", "Contact us"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AIChatbot() {
|
||||||
|
const [isVisible, setIsVisible] = useState(true); // Show immediately for testing
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([initialMessage]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inactivityTimerRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
// Show chatbot after shorter delay for better UX
|
||||||
|
useEffect(() => {
|
||||||
|
const resetTimer = () => {
|
||||||
|
if (inactivityTimerRef.current) {
|
||||||
|
clearTimeout(inactivityTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
inactivityTimerRef.current = setTimeout(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}, 5000); // Reduced to 5 seconds for better visibility
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
||||||
|
|
||||||
|
const addEventListeners = () => {
|
||||||
|
events.forEach(event => {
|
||||||
|
document.addEventListener(event, resetTimer, true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEventListeners = () => {
|
||||||
|
events.forEach(event => {
|
||||||
|
document.removeEventListener(event, resetTimer, true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addEventListeners();
|
||||||
|
resetTimer();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeEventListeners();
|
||||||
|
if (inactivityTimerRef.current) {
|
||||||
|
clearTimeout(inactivityTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom of messages
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const getBotResponse = (userMessage: string): { content: string; suggestions?: string[] } => {
|
||||||
|
const message = userMessage.toLowerCase();
|
||||||
|
|
||||||
|
if (message.includes('program') || message.includes('course') || message.includes('training')) {
|
||||||
|
return botResponses.programs;
|
||||||
|
} else if (message.includes('facility') || message.includes('book') || message.includes('room')) {
|
||||||
|
return botResponses.facilities;
|
||||||
|
} else if (message.includes('webinar') || message.includes('session') || message.includes('online')) {
|
||||||
|
return botResponses.webinars;
|
||||||
|
} else if (message.includes('contact') || message.includes('phone') || message.includes('email')) {
|
||||||
|
return botResponses.contact;
|
||||||
|
} else {
|
||||||
|
return botResponses.default;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = async (content: string) => {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
|
||||||
|
// Add user message
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: 'user',
|
||||||
|
content: content.trim(),
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInputValue('');
|
||||||
|
setIsTyping(true);
|
||||||
|
|
||||||
|
// Simulate typing delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const response = getBotResponse(content);
|
||||||
|
const botMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
type: 'bot',
|
||||||
|
content: response.content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
suggestions: response.suggestions
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, botMessage]);
|
||||||
|
setIsTyping(false);
|
||||||
|
}, 1000 + Math.random() * 1000); // 1-2 second delay
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestionClick = (suggestion: string) => {
|
||||||
|
handleSendMessage(suggestion);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Chatbot Toggle Button */}
|
||||||
|
{!isOpen && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="fixed bottom-6 right-6 w-16 h-16 rounded-full shadow-lg z-50 bg-primary hover:bg-primary/90 transition-all duration-300 hover:scale-105 text-base"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-6 h-6 text-primary-foreground" />
|
||||||
|
<span className="sr-only">Open KLC Assistant chat</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat Window */}
|
||||||
|
{isOpen && (
|
||||||
|
<Card className={`fixed bottom-6 right-6 w-80 md:w-96 shadow-2xl z-50 transition-all duration-300 ${
|
||||||
|
isMinimized ? 'h-16' : 'h-[500px]'
|
||||||
|
}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3 bg-primary text-primary-foreground rounded-t-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-primary-foreground/20 rounded-full flex items-center justify-center">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-medium">KLC Assistant</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 text-sm opacity-90">
|
||||||
|
<div className="w-2 h-2 bg-success rounded-full animate-pulse"></div>
|
||||||
|
<span>Online & Ready to Help</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
|
className="w-10 h-10 p-0 hover:bg-primary-foreground/20 text-primary-foreground text-base"
|
||||||
|
aria-label={isMinimized ? "Maximize chat" : "Minimize chat"}
|
||||||
|
>
|
||||||
|
{isMinimized ? (
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="w-10 h-10 p-0 hover:bg-primary-foreground/20 text-primary-foreground text-base"
|
||||||
|
aria-label="Close chat"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* Chat Content */}
|
||||||
|
{!isMinimized && (
|
||||||
|
<CardContent className="p-0 flex flex-col h-[420px]">
|
||||||
|
{/* Messages */}
|
||||||
|
<ScrollArea className="flex-1 p-4 scrollbar-minimal">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div key={message.id} className={`flex ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`flex items-start gap-3 max-w-[85%] ${message.type === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
|
message.type === 'user'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{message.type === 'user' ? (
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className={`p-3 rounded-lg ${
|
||||||
|
message.type === 'user'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted'
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm leading-relaxed">{message.content}</p>
|
||||||
|
</div>
|
||||||
|
{message.suggestions && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{message.suggestions.map((suggestion, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="outline"
|
||||||
|
className="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors text-sm py-1 px-3 rounded-full font-normal"
|
||||||
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground px-1">
|
||||||
|
{formatTime(message.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isTyping && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted p-3 rounded-lg">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"></div>
|
||||||
|
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||||
|
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage(inputValue);
|
||||||
|
}}
|
||||||
|
className="flex gap-2"
|
||||||
|
role="form"
|
||||||
|
aria-label="Chat message form"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="flex-1 text-base min-h-[44px]"
|
||||||
|
disabled={isTyping}
|
||||||
|
aria-label="Chat message input"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!inputValue.trim() || isTyping}
|
||||||
|
className="px-4 min-h-[44px] min-w-[44px] text-base"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Send message</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/AuthContext.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
corporateName: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
signIn: (userData: User) => void;
|
||||||
|
signOut: () => void;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
// Check for existing session on app load
|
||||||
|
useEffect(() => {
|
||||||
|
const savedUser = localStorage.getItem('klc_user');
|
||||||
|
if (savedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('klc_user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signIn = (userData: User) => {
|
||||||
|
setUser(userData);
|
||||||
|
localStorage.setItem('klc_user', JSON.stringify(userData));
|
||||||
|
};
|
||||||
|
|
||||||
|
const signOut = () => {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('klc_user');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock login function that simulates authentication
|
||||||
|
const login = async (email: string, password: string): Promise<void> => {
|
||||||
|
// Simulate API call delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Create mock user data based on email to determine user type
|
||||||
|
const isCorporateUser = email.includes('corporate') || email.includes('@company') || email.includes('@corp');
|
||||||
|
|
||||||
|
const mockUser: User = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: isCorporateUser
|
||||||
|
? 'Priya Sharma'
|
||||||
|
: 'Priya Sharma',
|
||||||
|
email: email || 'demo@klc.edu',
|
||||||
|
role: isCorporateUser ? 'corporate' : 'individual',
|
||||||
|
corporateName: isCorporateUser ? 'Demo Corporation' : '',
|
||||||
|
avatar: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set user data
|
||||||
|
setUser(mockUser);
|
||||||
|
localStorage.setItem('klc_user', JSON.stringify(mockUser));
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
login
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
2
src/components/CTABanner.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// CTABanner component has been removed
|
||||||
|
// This file is left empty to prevent import errors
|
||||||
358
src/components/CartConfirmationModal.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
|
import { X, ShoppingCart, CheckCircle, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CartItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
price: string;
|
||||||
|
originalPrice?: string;
|
||||||
|
image: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartConfirmationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
item: CartItem | null;
|
||||||
|
onGoToCart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CartConfirmationModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
item,
|
||||||
|
onGoToCart
|
||||||
|
}: CartConfirmationModalProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsVisible(true);
|
||||||
|
// Load all cart items
|
||||||
|
const existingCart = JSON.parse(localStorage.getItem('cart') || '[]');
|
||||||
|
setCartItems(existingCart);
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
setIsVisible(false);
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 300); // Wait for animation to complete
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToCart = () => {
|
||||||
|
handleClose();
|
||||||
|
// Direct navigation to cart page
|
||||||
|
window.location.href = '/cart';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveItem = (itemId: string) => {
|
||||||
|
const updatedCart = cartItems.filter(item => item.id !== itemId);
|
||||||
|
setCartItems(updatedCart);
|
||||||
|
localStorage.setItem('cart', JSON.stringify(updatedCart));
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSubtotal = () => {
|
||||||
|
return cartItems.reduce((total, cartItem) => {
|
||||||
|
const price = parseInt(cartItem.price.replace(/[₹,]/g, ''));
|
||||||
|
return total + price;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: string) => {
|
||||||
|
const numericPrice = parseInt(price.replace(/[₹,]/g, ''));
|
||||||
|
return `₹${numericPrice.toLocaleString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !item) return null;
|
||||||
|
|
||||||
|
const subtotal = calculateSubtotal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center lg:justify-end lg:items-stretch">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-black transition-opacity duration-300 ${
|
||||||
|
isVisible ? 'opacity-50' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Content - Full Height */}
|
||||||
|
<div
|
||||||
|
className={`relative bg-white shadow-2xl transition-all duration-300 ease-out flex flex-col
|
||||||
|
${isVisible
|
||||||
|
? 'lg:translate-x-0 translate-y-0 opacity-100'
|
||||||
|
: 'lg:translate-x-full translate-y-full opacity-0'
|
||||||
|
}
|
||||||
|
/* Desktop: Slide from right, 30% width, full height */
|
||||||
|
lg:w-[30%] lg:h-full lg:max-w-md lg:min-w-[400px]
|
||||||
|
/* Mobile/Tablet: Slide from bottom, full width, full height */
|
||||||
|
w-full h-full
|
||||||
|
lg:rounded-none rounded-t-2xl
|
||||||
|
lg:border-l border-t lg:border-t-0
|
||||||
|
`}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="cart-modal-title"
|
||||||
|
aria-describedby="cart-modal-description"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border bg-white lg:sticky lg:top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-success/10 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 id="cart-modal-title" className="text-lg font-semibold text-foreground">
|
||||||
|
Added to Cart
|
||||||
|
</h3>
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
{cartItems.length} programme{cartItems.length !== 1 ? 's' : ''} in your cart
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="h-10 w-10 p-0 hover:bg-muted text-base"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - Scrollable */}
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto scrollbar-minimal">
|
||||||
|
<div id="cart-modal-description" className="space-y-6">
|
||||||
|
{/* Success Message for newly added item */}
|
||||||
|
<div className="bg-success/5 border border-success/20 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold text-success mb-1">
|
||||||
|
"{item.title}" added successfully!
|
||||||
|
</p>
|
||||||
|
<p className="text-base text-success/80">
|
||||||
|
You can continue browsing or proceed to checkout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Cart Items */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-lg font-semibold text-foreground">Your Cart ({cartItems.length})</h4>
|
||||||
|
<span className="text-base text-muted-foreground">Total: {formatPrice(subtotal.toString())}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{cartItems.map((cartItem, index) => (
|
||||||
|
<div key={cartItem.id} className="bg-muted/20 rounded-lg p-4 border border-border">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Product Image */}
|
||||||
|
<div className="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
|
<ImageWithFallback
|
||||||
|
src={cartItem.image}
|
||||||
|
alt={cartItem.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h5 className="font-semibold text-foreground text-base leading-tight mb-2 line-clamp-2">
|
||||||
|
{cartItem.title}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base text-muted-foreground">Type:</span>
|
||||||
|
<span className="text-base font-medium capitalize">{cartItem.type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base text-muted-foreground">Price:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base font-bold text-primary">{formatPrice(cartItem.price)}</span>
|
||||||
|
{cartItem.originalPrice && cartItem.originalPrice !== cartItem.price && (
|
||||||
|
<span className="text-base text-muted-foreground line-through">
|
||||||
|
{formatPrice(cartItem.originalPrice)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveItem(cartItem.id)}
|
||||||
|
className="h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
aria-label={`Remove ${cartItem.title} from cart`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlight newly added item */}
|
||||||
|
{cartItem.id === item.id && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-success rounded-full animate-pulse"></div>
|
||||||
|
<span className="text-base text-success font-medium">Just added</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cart Summary */}
|
||||||
|
<div className="bg-primary/5 border border-primary/20 rounded-lg p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<ShoppingCart className="w-6 h-6 text-primary" />
|
||||||
|
<span className="text-lg font-semibold text-primary">Cart Summary</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-base text-muted-foreground">Total Items:</span>
|
||||||
|
<span className="text-base font-medium">
|
||||||
|
{cartItems.length} programme{cartItems.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-base text-muted-foreground">Subtotal:</span>
|
||||||
|
<span className="text-lg font-bold text-primary">
|
||||||
|
{formatPrice(subtotal.toString())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-base font-medium text-foreground">Estimated Total:</span>
|
||||||
|
<span className="text-xl font-bold text-primary">
|
||||||
|
{formatPrice(subtotal.toString())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-base text-muted-foreground mt-1">
|
||||||
|
Final total calculated at checkout
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits */}
|
||||||
|
<div className="bg-muted/20 border border-border rounded-lg p-6">
|
||||||
|
<h4 className="text-lg font-semibold text-foreground mb-4">What's Next?</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">Review Your Selection</p>
|
||||||
|
<p className="text-base text-muted-foreground">Modify quantities and remove items in your cart</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">Secure Checkout</p>
|
||||||
|
<p className="text-base text-muted-foreground">Complete enrollment with secure payment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">Instant Confirmation</p>
|
||||||
|
<p className="text-base text-muted-foreground">Receive program details and access information</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty Cart State */}
|
||||||
|
{cartItems.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<ShoppingCart className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-lg font-medium text-foreground mb-2">Your cart is empty</p>
|
||||||
|
<p className="text-base text-muted-foreground">Browse our programmes to get started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer - Sticky at bottom */}
|
||||||
|
<div className="p-6 border-t border-border bg-white lg:sticky lg:bottom-0">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{cartItems.length > 0 && (
|
||||||
|
<div className="bg-primary/10 rounded-lg p-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-base text-muted-foreground">Total Value</div>
|
||||||
|
<div className="text-2xl font-bold text-primary">
|
||||||
|
{formatPrice(subtotal.toString())}
|
||||||
|
</div>
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
{cartItems.length} programme{cartItems.length !== 1 ? 's' : ''} selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGoToCart}
|
||||||
|
className="w-full h-14 text-base font-medium"
|
||||||
|
size="lg"
|
||||||
|
disabled={cartItems.length === 0}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-5 h-5 mr-2" />
|
||||||
|
{cartItems.length > 0 ? 'Proceed to Checkout' : 'Browse Programmes'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-full h-14 text-base font-medium"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Continue Shopping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-base text-muted-foreground mt-4 leading-relaxed">
|
||||||
|
Secure checkout • 30-day money-back guarantee
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/FacilityShowcaseCard.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
|
import { navigateTo } from './Router';
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
ArrowRight,
|
||||||
|
TrendingUp
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface FacilityShowcaseCardProps {
|
||||||
|
facility: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
capacity: string;
|
||||||
|
area: string;
|
||||||
|
hourlyPrice: number;
|
||||||
|
dayPrice: number;
|
||||||
|
rating: number;
|
||||||
|
bookings: number;
|
||||||
|
availability: 'available' | 'limited' | 'unavailable';
|
||||||
|
category: string;
|
||||||
|
features: string[];
|
||||||
|
image: string;
|
||||||
|
analyticsData?: {
|
||||||
|
occupancy: string;
|
||||||
|
satisfaction: string;
|
||||||
|
rebookRate: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FacilityShowcaseCard({ facility }: FacilityShowcaseCardProps) {
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('en-IN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'INR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailabilityColor = (availability: string) => {
|
||||||
|
switch (availability) {
|
||||||
|
case 'available': return 'text-success';
|
||||||
|
case 'limited': return 'text-yellow-600';
|
||||||
|
case 'unavailable': return 'text-destructive';
|
||||||
|
default: return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailabilityText = (availability: string) => {
|
||||||
|
switch (availability) {
|
||||||
|
case 'available': return 'Available';
|
||||||
|
case 'limited': return 'Limited';
|
||||||
|
case 'unavailable': return 'Fully Booked';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyticsData = facility.analyticsData || {
|
||||||
|
occupancy: '17.8K',
|
||||||
|
satisfaction: '1.3%',
|
||||||
|
rebookRate: '25.4'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-sm overflow-hidden hover:shadow-lg transition-all duration-300 group bg-white">
|
||||||
|
{/* Header with badges and rating */}
|
||||||
|
<div className="relative p-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{facility.category}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${getAvailabilityColor(facility.availability)}`}
|
||||||
|
>
|
||||||
|
{getAvailabilityText(facility.availability)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-amber-50 px-2 py-1 rounded-full">
|
||||||
|
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||||
|
<span className="text-xs font-medium text-amber-700">{facility.rating}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analytics Chart Placeholder */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3 mb-4">
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-3">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Total Book</div>
|
||||||
|
<div className="text-sm font-semibold text-blue-600">223</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">This Month</div>
|
||||||
|
<div className="text-sm font-semibold text-blue-600">{analyticsData.occupancy}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Analytics</div>
|
||||||
|
<div className="text-sm font-semibold text-green-600">{analyticsData.satisfaction}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Client Sat</div>
|
||||||
|
<div className="text-sm font-semibold text-purple-600">{analyticsData.rebookRate}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart visualization */}
|
||||||
|
<div className="h-16 bg-gradient-to-r from-blue-100 to-purple-100 rounded flex items-end justify-center relative overflow-hidden">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 200 40">
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="2"
|
||||||
|
points="10,30 30,25 50,28 70,20 90,22 110,15 130,18 150,12 170,16 190,10"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth="2"
|
||||||
|
points="10,35 30,32 50,35 70,28 90,30 110,25 130,28 150,22 170,25 190,20"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute bottom-1 left-2 text-xs text-muted-foreground">Jan 23</div>
|
||||||
|
<div className="absolute bottom-1 right-2 text-xs text-muted-foreground">Dec 24</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Facility Information */}
|
||||||
|
<CardHeader className="pt-0 pb-3">
|
||||||
|
<CardTitle className="text-xl font-bold group-hover:text-primary transition-colors">
|
||||||
|
{facility.name}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{facility.description}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{facility.capacity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<MapPin className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{facility.area}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{facility.bookings} bookings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>Hourly/Daily</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Features */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2 text-sm">Key Features</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{facility.features.slice(0, 3).map((feature, idx) => (
|
||||||
|
<Badge key={idx} variant="secondary" className="text-xs">
|
||||||
|
{feature}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{facility.features.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{facility.features.length - 3} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Starting from</div>
|
||||||
|
<div className="text-lg font-bold text-primary">
|
||||||
|
{formatPrice(facility.hourlyPrice)}/hour
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatPrice(facility.dayPrice)}/day
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
onClick={() => navigateTo(`/facility/${facility.id}`)}
|
||||||
|
disabled={facility.availability === 'unavailable'}
|
||||||
|
>
|
||||||
|
{facility.availability === 'unavailable' ? 'Fully Booked' : 'View Details'}
|
||||||
|
{facility.availability !== 'unavailable' && (
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/components/Footer.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import klcLogo from 'figma:asset/c2d0a01da274cef655bbdfb1b11ff3e9993ea278.png';
|
||||||
|
import { navigateTo } from './Router';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Linkedin,
|
||||||
|
Instagram
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Custom X (formerly Twitter) icon component
|
||||||
|
const XIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={className}
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footerSections = [
|
||||||
|
{
|
||||||
|
title: 'Services',
|
||||||
|
links: [
|
||||||
|
{ name: 'All Services', href: '/services' },
|
||||||
|
{ name: 'Leadership Development', href: '/services/leadership-development' },
|
||||||
|
{ name: 'Management Development', href: '/services/management-development' },
|
||||||
|
{ name: 'Executive Coaching', href: '/services/executive-coaching' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Learning',
|
||||||
|
links: [
|
||||||
|
{ name: 'Articles', href: '/learning/articles' },
|
||||||
|
{ name: 'Blogs', href: '/learning/blogs' },
|
||||||
|
{ name: 'Webcasts', href: '/learning/webcasts' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Facilities',
|
||||||
|
links: [
|
||||||
|
{ name: 'Virtual Tour', href: '/facility-tour' },
|
||||||
|
{ name: 'Learning Facility', href: '/services/learning-facility' },
|
||||||
|
{ name: 'Boarding & Lodging Facility', href: '/facility/boarding-lodging' },
|
||||||
|
{ name: 'Recreation Facility', href: '/facility/recreation' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
links: [
|
||||||
|
{ name: 'Contact Us', href: '/contact' },
|
||||||
|
{ name: 'Login', href: '/login' },
|
||||||
|
{ name: 'Sign Up', href: '/signup' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ name: 'LinkedIn', href: '#', icon: Linkedin },
|
||||||
|
{ name: 'X', href: '#', icon: XIcon },
|
||||||
|
{ name: 'Instagram', href: '#', icon: Instagram }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-background border-t">
|
||||||
|
{/* Main Footer Content */}
|
||||||
|
<div className="container mx-auto px-4 lg:px-8 py-16">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-8 lg:gap-12">
|
||||||
|
{/* Brand Section */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<img
|
||||||
|
src={klcLogo}
|
||||||
|
alt="Kautilya Leadership Centre"
|
||||||
|
className="h-16 w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Empowering leaders and organizations worldwide through innovative leadership
|
||||||
|
development programs, cutting-edge research, and transformational learning experiences.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
|
<span>123 Leadership Avenue, New Delhi, India 110001</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Phone className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
|
<span>+91 11 4567 8900</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Mail className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
|
<span>info@klc.edu.in</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{socialLinks.map((social) => {
|
||||||
|
const Icon = social.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={social.name}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-10 h-10 p-0"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={social.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Follow us on ${social.name}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Links */}
|
||||||
|
{footerSections.map((section) => (
|
||||||
|
<div key={section.title} className="space-y-4">
|
||||||
|
<h4 className="font-semibold text-foreground">{section.title}</h4>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{section.links.map((link) => (
|
||||||
|
<li key={link.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigateTo(link.href)}
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary transition-colors text-left"
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Section */}
|
||||||
|
<div className="border-t">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8 py-6">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>© 2025 Kautilya Leadership Centre. All rights reserved.</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigateTo('/privacy')}
|
||||||
|
className="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigateTo('/terms')}
|
||||||
|
className="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Accredited by:</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-6 bg-muted rounded flex items-center justify-center">
|
||||||
|
<span className="text-xs font-bold">ISO</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-6 bg-muted rounded flex items-center justify-center">
|
||||||
|
<span className="text-xs font-bold">NAAC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/components/HeroBanner.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
|
import heroBannerImage from 'figma:asset/1bb9c22c86c0892d4716564b7135835f04869298.png';
|
||||||
|
|
||||||
|
export function HeroBanner() {
|
||||||
|
const handleEnrollNow = () => {
|
||||||
|
// Navigate to webinars page
|
||||||
|
window.location.href = '/webinars?view=individual';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuildPipeline = () => {
|
||||||
|
// Navigate to programs catalogue or individual dashboard
|
||||||
|
window.location.href = '/dashboard?view=individual';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
{/* Top Announcement Bar */}
|
||||||
|
<div className="bg-[#F8C301] text-[#26231A] py-3 px-4">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
<div className="flex items-center justify-center gap-4 text-center">
|
||||||
|
<span className="text-base font-medium">
|
||||||
|
Join Our Upcoming Leadership Webinars - Transform Your Leadership Journey
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleEnrollNow}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#26231A] hover:bg-[#26231A]/10 font-medium text-base h-auto py-1 px-3"
|
||||||
|
>
|
||||||
|
Enroll Now
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Hero Section */}
|
||||||
|
<div className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<ImageWithFallback
|
||||||
|
src={heroBannerImage}
|
||||||
|
alt="Leadership workshop with diverse team members collaborating with colorful sticky notes on a wall"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Overlay for better text readability */}
|
||||||
|
<div className="absolute inset-0 bg-black/40"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Content */}
|
||||||
|
<div className="relative z-10 container mx-auto px-4 lg:px-8 pt-20 pb-32">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="text-white space-y-8">
|
||||||
|
{/* Main Heading */}
|
||||||
|
<h1 className="text-5xl lg:text-6xl font-bold leading-tight">
|
||||||
|
Empowering Future-Ready Leaders
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Subtext */}
|
||||||
|
<p className="text-xl lg:text-2xl text-white/90 leading-relaxed max-w-2xl">
|
||||||
|
Build confidence, agility, and clarity for today's complex challenges.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleBuildPipeline}
|
||||||
|
size="lg"
|
||||||
|
className="bg-[#04045B] hover:bg-[#04045B]/90 text-white text-lg px-8 py-4 min-h-[60px] font-medium"
|
||||||
|
>
|
||||||
|
Build Your Leadership Pipeline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Feature Navigation */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 py-12">
|
||||||
|
{/* Feature 01 */}
|
||||||
|
<div className="text-white space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-[#F8C301] text-xl font-bold">01</div>
|
||||||
|
<div className="h-px bg-[#F8C301] flex-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Leadership Is Learning. We Teach It Right.</h3>
|
||||||
|
<p className="text-white/80 text-base leading-relaxed">
|
||||||
|
Master proven methodologies and frameworks that transform managers into exceptional leaders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 02 */}
|
||||||
|
<div className="text-white space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-[#F8C301] text-xl font-bold">02</div>
|
||||||
|
<div className="h-px bg-[#F8C301] flex-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Turn Managers Into Impactful Leaders</h3>
|
||||||
|
<p className="text-white/80 text-base leading-relaxed">
|
||||||
|
Develop strategic thinking, emotional intelligence, and decision-making capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 03 */}
|
||||||
|
<div className="text-white space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-[#F8C301] text-xl font-bold">03</div>
|
||||||
|
<div className="h-px bg-[#F8C301] flex-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Struggling with Managerial Gaps?</h3>
|
||||||
|
<p className="text-white/80 text-base leading-relaxed">
|
||||||
|
Bridge the gap between individual contribution and effective team leadership.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Controls */}
|
||||||
|
<div className="absolute bottom-6 right-6 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 text-white border border-white/20"
|
||||||
|
aria-label="Previous slide"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 text-white border border-white/20"
|
||||||
|
aria-label="Next slide"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/components/HeroSection.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
|
import heroBannerImage from 'figma:asset/1bb9c22c86c0892d4716564b7135835f04869298.png';
|
||||||
|
|
||||||
|
interface HeroSectionProps {
|
||||||
|
showAnnouncementBar?: boolean;
|
||||||
|
announcementText?: string;
|
||||||
|
announcementCTA?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
ctaText?: string;
|
||||||
|
onAnnouncementClick?: () => void;
|
||||||
|
onCTAClick?: () => void;
|
||||||
|
showFeatures?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroSection({
|
||||||
|
showAnnouncementBar = true,
|
||||||
|
announcementText = "Join Our Upcoming Leadership Webinars - Transform Your Leadership Journey",
|
||||||
|
announcementCTA = "Enroll Now",
|
||||||
|
title = "Empowering Future-Ready Leaders",
|
||||||
|
subtitle = "Build confidence, agility, and clarity for today's complex challenges.",
|
||||||
|
ctaText = "Build Your Leadership Pipeline",
|
||||||
|
onAnnouncementClick,
|
||||||
|
onCTAClick,
|
||||||
|
showFeatures = true
|
||||||
|
}: HeroSectionProps) {
|
||||||
|
|
||||||
|
const handleAnnouncementClick = () => {
|
||||||
|
if (onAnnouncementClick) {
|
||||||
|
onAnnouncementClick();
|
||||||
|
} else {
|
||||||
|
window.location.href = '/webinars?view=individual';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCTAClick = () => {
|
||||||
|
if (onCTAClick) {
|
||||||
|
onCTAClick();
|
||||||
|
} else {
|
||||||
|
window.location.href = '/dashboard?view=individual';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
{/* Top Announcement Bar */}
|
||||||
|
{showAnnouncementBar && (
|
||||||
|
<div className="bg-[#F8C301] text-[#26231A] py-3 px-4">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
<div className="flex items-center justify-center gap-4 text-center">
|
||||||
|
<span className="text-base font-medium">
|
||||||
|
{announcementText}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleAnnouncementClick}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#26231A] hover:bg-[#26231A]/10 font-medium text-base h-auto py-1 px-3"
|
||||||
|
>
|
||||||
|
{announcementCTA}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Hero Section */}
|
||||||
|
<div className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<ImageWithFallback
|
||||||
|
src={heroBannerImage}
|
||||||
|
alt="Leadership workshop with diverse team members collaborating with colorful sticky notes on a wall"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Overlay for better text readability */}
|
||||||
|
<div className="absolute inset-0 bg-black/40"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Content */}
|
||||||
|
<div className="relative z-10 container mx-auto px-4 lg:px-8 pt-20 pb-32">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="text-white space-y-8">
|
||||||
|
{/* Main Heading */}
|
||||||
|
<h1 className="text-5xl lg:text-6xl font-bold leading-tight">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Subtext */}
|
||||||
|
<p className="text-xl lg:text-2xl text-white/90 leading-relaxed max-w-2xl">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleCTAClick}
|
||||||
|
size="lg"
|
||||||
|
className="bg-[#04045B] hover:bg-[#04045B]/90 text-white text-lg px-8 py-4 min-h-[60px] font-medium"
|
||||||
|
>
|
||||||
|
{ctaText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Feature Navigation */}
|
||||||
|
{showFeatures && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 py-12">
|
||||||
|
{/* Feature 01 */}
|
||||||
|
<div className="text-white space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-[#F8C301] text-xl font-bold">01</div>
|
||||||
|
<div className="h-px bg-[#F8C301] flex-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Leadership Is Learning. We Teach It Right.</h3>
|
||||||
|
<p className="text-white/80 text-base leading-relaxed">
|
||||||
|
Master proven methodologies and frameworks that transform managers into exceptional leaders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 02 */}
|
||||||
|
<div className="text-white space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-[#F8C301] text-xl font-bold">02</div>
|
||||||
|
<div className="h-px bg-[#F8C301] flex-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Turn Managers Into Impactful Leaders</h3>
|
||||||
|
<p className="text-white/80 text-base leading-relaxed">
|
||||||
|
Develop strategic thinking, emotional intelligence, and decision-making capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 03 */}
|
||||||
|
<div className="text-white space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-[#F8C301] text-xl font-bold">03</div>
|
||||||
|
<div className="h-px bg-[#F8C301] flex-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Struggling with Managerial Gaps?</h3>
|
||||||
|
<p className="text-white/80 text-base leading-relaxed">
|
||||||
|
Bridge the gap between individual contribution and effective team leadership.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Controls */}
|
||||||
|
<div className="absolute bottom-6 right-6 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 text-white border border-white/20"
|
||||||
|
aria-label="Previous slide"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 text-white border border-white/20"
|
||||||
|
aria-label="Next slide"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
709
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from './ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from './ui/sheet';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||||
|
import { navigateTo } from './Router';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import klcLogo from 'figma:asset/209958db0c439ec78be82ab4f3e335a6aed5de89.png';
|
||||||
|
import exampleImage from 'figma:asset/6cae567b6bf6a44cb03b767e4308c4c705340d08.png';
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ShoppingCart,
|
||||||
|
Search,
|
||||||
|
Building2,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Target,
|
||||||
|
Award,
|
||||||
|
Lightbulb,
|
||||||
|
GraduationCap,
|
||||||
|
BookOpen,
|
||||||
|
Video,
|
||||||
|
FileText,
|
||||||
|
Eye,
|
||||||
|
Heart,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
Play,
|
||||||
|
Home,
|
||||||
|
Check,
|
||||||
|
ArrowRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface NavigationProps {
|
||||||
|
currentPage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navigation({ currentPage }: NavigationProps) {
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [expandedMobileSection, setExpandedMobileSection] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { user, login, signOut, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// Determine user type from URL or user data
|
||||||
|
const getQueryParam = (param: string) => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get(param);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIndividualUser = getQueryParam('view') === 'individual' ||
|
||||||
|
(!getQueryParam('view') && currentPage?.includes('/dashboard')) ||
|
||||||
|
(!getQueryParam('view') && currentPage?.includes('/library')) ||
|
||||||
|
(!getQueryParam('view') && currentPage?.includes('/course')) ||
|
||||||
|
(!getQueryParam('view') && currentPage?.includes('/settings')) ||
|
||||||
|
(!getQueryParam('view') && currentPage?.includes('/surveys')) ||
|
||||||
|
(!getQueryParam('view') && currentPage?.includes('/webinars')) ||
|
||||||
|
(!getQueryParam('view') && currentPage?.includes('/leaderboard'));
|
||||||
|
|
||||||
|
const isCorporateUser = getQueryParam('view') === 'corporate';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (!target.closest('[data-dropdown]')) {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDropdownToggle = (dropdown: string) => {
|
||||||
|
setActiveDropdown(activeDropdown === dropdown ? null : dropdown);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileToggle = (section: string) => {
|
||||||
|
setExpandedMobileSection(expandedMobileSection === section ? null : section);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
navigateTo('/auth'); // Route to login selection page
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignup = () => {
|
||||||
|
navigateTo('/signup');
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
signOut();
|
||||||
|
navigateTo('/');
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountSignIn = (accountType: 'individual' | 'corporate') => {
|
||||||
|
// Navigate to appropriate sign-in page for the account type
|
||||||
|
if (accountType === 'individual') {
|
||||||
|
navigateTo('/login');
|
||||||
|
} else {
|
||||||
|
navigateTo('/corporate/login');
|
||||||
|
}
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
title: 'About Us',
|
||||||
|
href: '/about-us/our-vision',
|
||||||
|
items: [
|
||||||
|
{ title: 'Our Vision', href: '/about-us/our-vision', icon: Eye },
|
||||||
|
{ title: 'Our Team', href: '/about-us/our-team', icon: Users },
|
||||||
|
{ title: 'Our Impact', href: '/about-us/our-impact', icon: Target },
|
||||||
|
{ title: 'Our Expertise', href: '/about-us/our-expertise', icon: Award }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Programmes',
|
||||||
|
href: '/programmes',
|
||||||
|
items: [
|
||||||
|
{ title: 'Programme Catalogue', href: '/programmes', icon: BookOpen },
|
||||||
|
{ title: 'Executive Leadership', href: '/programmes/executive-leadership', icon: Award },
|
||||||
|
{ title: 'Team Leadership', href: '/programmes/team-leadership', icon: Users },
|
||||||
|
{ title: 'Innovation Leadership', href: '/programmes/innovation-leadership', icon: Lightbulb },
|
||||||
|
{ title: 'Leadership Online', href: '/programmes/leadership-online', icon: Play }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Services',
|
||||||
|
href: '/services/leadership-development',
|
||||||
|
items: [
|
||||||
|
{ title: 'Leadership Development', href: '/services/leadership-development', icon: Target },
|
||||||
|
{ title: 'Management Development', href: '/services/management-development', icon: Users },
|
||||||
|
{ title: 'Executive Coaching', href: '/services/executive-coaching', icon: Award },
|
||||||
|
{ title: 'Culture & Competence', href: '/services/culture-competence', icon: Heart },
|
||||||
|
{ title: 'Consulting', href: '/services/consulting', icon: Lightbulb },
|
||||||
|
{ title: 'Learning Facility', href: '/services/learning-facility', icon: MapPin }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Learning',
|
||||||
|
href: '/learning/articles',
|
||||||
|
items: [
|
||||||
|
{ title: 'Articles', href: '/learning/articles', icon: FileText },
|
||||||
|
{ title: 'Blog', href: '/learning/blog', icon: BookOpen },
|
||||||
|
{ title: 'Resources', href: '/learning/resources', icon: BookOpen },
|
||||||
|
{ title: 'Webinars', href: '/individual-webinars', icon: Video }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const learnerMenuItems = [
|
||||||
|
{
|
||||||
|
title: 'Dashboard',
|
||||||
|
href: isIndividualUser ? '/dashboard?view=individual' : '/dashboard?view=corporate',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
description: isIndividualUser ? 'Your learning overview' : 'Team management hub'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Library',
|
||||||
|
href: isIndividualUser ? '/library?view=individual' : '/library?view=corporate',
|
||||||
|
icon: BookOpen,
|
||||||
|
description: isIndividualUser ? 'Browse courses' : 'Assigned courses'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Course Timeline',
|
||||||
|
href: isIndividualUser ? '/course?view=individual' : '/course?view=corporate',
|
||||||
|
icon: Calendar,
|
||||||
|
description: isIndividualUser ? 'Your learning path' : 'Team progress'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Surveys & Assessments',
|
||||||
|
href: isIndividualUser ? '/surveys?view=individual' : '/surveys?view=corporate',
|
||||||
|
icon: FileText,
|
||||||
|
description: isIndividualUser ? 'Complete assessments' : 'Team evaluations'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live Webinars',
|
||||||
|
href: isIndividualUser ? '/webinars?view=individual' : '/webinars?view=corporate',
|
||||||
|
icon: Video,
|
||||||
|
description: isIndividualUser ? 'Join sessions' : 'Corporate events'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Leaderboard',
|
||||||
|
href: isIndividualUser ? '/leaderboard?view=individual' : '/leaderboard?view=corporate',
|
||||||
|
icon: Award,
|
||||||
|
description: isIndividualUser ? 'Your achievements' : 'Team rankings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings',
|
||||||
|
href: isIndividualUser ? '/settings?view=individual' : '/settings?view=corporate',
|
||||||
|
icon: Settings,
|
||||||
|
description: isIndividualUser ? 'Account preferences' : 'Admin settings'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock data for demonstration - replace with actual user data
|
||||||
|
const currentUser = {
|
||||||
|
name: 'Priya Sharma',
|
||||||
|
email: 'priya.sharma@example.com',
|
||||||
|
avatar: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face",
|
||||||
|
organization: 'TechCorp Inc.',
|
||||||
|
role: 'Marketing Team Member'
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableAccounts = [
|
||||||
|
{
|
||||||
|
type: 'individual',
|
||||||
|
isActive: isIndividualUser,
|
||||||
|
title: 'Personal Learning',
|
||||||
|
subtitle: 'Access your individual learning portal',
|
||||||
|
icon: User,
|
||||||
|
user: currentUser
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'corporate',
|
||||||
|
isActive: isCorporateUser,
|
||||||
|
title: 'Corporate Learning',
|
||||||
|
subtitle: 'Enterprise team development portal',
|
||||||
|
icon: Building2,
|
||||||
|
user: currentUser,
|
||||||
|
organization: 'TechCorp Inc.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||||
|
isScrolled ? 'bg-white/95 backdrop-blur-md shadow-sm' : 'bg-white'
|
||||||
|
}`}>
|
||||||
|
<div className="w-full px-4 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-[70px]">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => navigateTo('/')}
|
||||||
|
className="flex items-center space-x-2 focus:outline-none focus:ring-2 focus:ring-primary rounded-lg p-1 hover:bg-gray-50 transition-colors"
|
||||||
|
aria-label="Go to KLC homepage"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={klcLogo}
|
||||||
|
alt="Kautilya Leadership Centre"
|
||||||
|
className="h-12 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden lg:flex items-center space-x-8">
|
||||||
|
{navigationItems.map((item) => (
|
||||||
|
<div key={item.title} className="relative" data-dropdown>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDropdownToggle(item.title)}
|
||||||
|
className="flex items-center space-x-1 text-[16px] text-foreground hover:text-primary transition-colors py-2 px-3 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
aria-expanded={activeDropdown === item.title}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${
|
||||||
|
activeDropdown === item.title ? 'rotate-180' : ''
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeDropdown === item.title && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
||||||
|
{item.items.map((subItem) => {
|
||||||
|
const IconComponent = subItem.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={subItem.title}
|
||||||
|
onClick={() => {
|
||||||
|
navigateTo(subItem.href);
|
||||||
|
setActiveDropdown(null);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center space-x-3 px-4 py-3 text-[16px] text-gray-700 hover:bg-gray-50 hover:text-primary transition-colors text-left focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<IconComponent className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigateTo('/contact')}
|
||||||
|
className="text-[16px] text-foreground hover:text-primary transition-colors py-2 px-3 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - Desktop */}
|
||||||
|
<div className="hidden lg:flex items-center space-x-4">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleLogin}
|
||||||
|
className="text-[16px] min-h-[44px]"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSignup}
|
||||||
|
className="text-[16px] min-h-[44px] bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Redesigned User Profile Dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex items-center gap-3 h-auto p-2 hover:bg-gray-50 transition-all duration-200 rounded-lg min-h-[44px]"
|
||||||
|
aria-label="Open user menu"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage
|
||||||
|
src={currentUser.avatar}
|
||||||
|
alt={currentUser.name}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-sm">
|
||||||
|
{currentUser.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col items-start min-w-0">
|
||||||
|
<span className="text-[16px] font-medium text-gray-900 truncate">
|
||||||
|
{currentUser.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] text-gray-600 truncate">
|
||||||
|
{isIndividualUser ? 'Individual Account' : 'Corporate Account'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-80 p-0" align="end" forceMount>
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="p-4 border-b border-gray-100 bg-gray-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-12 w-12">
|
||||||
|
<AvatarImage
|
||||||
|
src={currentUser.avatar}
|
||||||
|
alt={currentUser.name}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-lg">
|
||||||
|
{currentUser.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-[16px] font-semibold text-gray-900 truncate">
|
||||||
|
{currentUser.name}
|
||||||
|
</p>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[14px] text-gray-600 truncate">
|
||||||
|
{isIndividualUser ? 'Individual Account' : 'Corporate Account'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Switching Section */}
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<h4 className="text-[14px] font-medium text-gray-900 mb-3">Switch Account</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableAccounts.map((account) => {
|
||||||
|
const IconComponent = account.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={account.type}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-200 ${
|
||||||
|
account.isActive
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-gray-50 border-gray-200 hover:bg-gray-100 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
onClick={() => !account.isActive && handleAccountSignIn(account.type as 'individual' | 'corporate')}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{account.type === 'individual' ? (
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage
|
||||||
|
src={currentUser.avatar}
|
||||||
|
alt={currentUser.name}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="bg-blue-100 text-blue-700 text-sm">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Building2 className="h-4 w-4 text-purple-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{account.isActive && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<Check className="h-2.5 w-2.5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[14px] font-medium text-gray-900 truncate">
|
||||||
|
{account.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] text-gray-600 truncate">
|
||||||
|
{account.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!account.isActive && (
|
||||||
|
<ArrowRight className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Settings and Logout */}
|
||||||
|
<div className="p-2">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex items-center gap-3 px-3 py-2 cursor-pointer rounded-md hover:bg-gray-50 focus:bg-gray-50 min-h-[44px]"
|
||||||
|
onClick={() => navigateTo(learnerMenuItems[learnerMenuItems.length - 1].href)}
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5 text-gray-500" />
|
||||||
|
<span className="text-[16px] font-medium text-gray-900">Settings</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex items-center gap-3 px-3 py-2 cursor-pointer rounded-md text-red-600 hover:bg-red-50 focus:bg-red-50 min-h-[44px]"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5" />
|
||||||
|
<span className="text-[16px] font-medium">Sign Out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
aria-label="Open mobile menu"
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-full sm:w-96 p-0">
|
||||||
|
<SheetHeader className="p-6 border-b border-gray-200">
|
||||||
|
<SheetTitle className="text-left flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={klcLogo}
|
||||||
|
alt="KLC"
|
||||||
|
className="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-semibold">Menu</span>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* User Section for Mobile */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Avatar className="h-12 w-12">
|
||||||
|
<AvatarImage
|
||||||
|
src={currentUser.avatar}
|
||||||
|
alt={currentUser.name}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary">
|
||||||
|
{currentUser.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[16px] font-semibold text-gray-900 truncate">
|
||||||
|
{currentUser.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] text-gray-600 truncate">
|
||||||
|
{isIndividualUser ? 'Individual Account' : 'Corporate Account'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Account Switching */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-[14px] font-medium text-gray-900">Switch Account</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableAccounts.map((account) => (
|
||||||
|
<button
|
||||||
|
key={account.type}
|
||||||
|
onClick={() => !account.isActive && handleAccountSignIn(account.type as 'individual' | 'corporate')}
|
||||||
|
disabled={account.isActive}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 rounded-lg border text-left transition-all duration-200 ${
|
||||||
|
account.isActive
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{account.type === 'individual' ? (
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage
|
||||||
|
src={currentUser.avatar}
|
||||||
|
alt={currentUser.name}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="bg-blue-100 text-blue-700 text-sm">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Building2 className="h-4 w-4 text-purple-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{account.isActive && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<Check className="h-2.5 w-2.5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[14px] font-medium text-gray-900 truncate">
|
||||||
|
{account.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] text-gray-600 truncate">
|
||||||
|
{account.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!account.isActive && (
|
||||||
|
<ArrowRight className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{/* Learner Portal Items (if authenticated) */}
|
||||||
|
{isAuthenticated && (isIndividualUser || isCorporateUser) && (
|
||||||
|
<div className="px-4 mb-6">
|
||||||
|
<h3 className="text-[14px] font-medium text-gray-900 mb-3">
|
||||||
|
{isIndividualUser ? 'Personal Learning' : 'Corporate Learning'}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{learnerMenuItems.map((item) => {
|
||||||
|
const IconComponent = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.title}
|
||||||
|
onClick={() => {
|
||||||
|
navigateTo(item.href);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-left text-[16px] text-gray-700 hover:bg-gray-50 hover:text-primary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px]"
|
||||||
|
>
|
||||||
|
<IconComponent className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{item.title}</div>
|
||||||
|
<div className="text-[14px] text-gray-500 truncate">{item.description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Public Navigation Items */}
|
||||||
|
<div className="px-4">
|
||||||
|
{navigationItems.map((item) => (
|
||||||
|
<Collapsible key={item.title}>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
onClick={() => handleMobileToggle(item.title)}
|
||||||
|
className="w-full flex items-center justify-between p-3 text-[16px] text-gray-900 hover:bg-gray-50 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px]"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{item.title}</span>
|
||||||
|
<ChevronRight className={`h-4 w-4 transition-transform ${
|
||||||
|
expandedMobileSection === item.title ? 'rotate-90' : ''
|
||||||
|
}`} />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="px-4 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{item.items.map((subItem) => {
|
||||||
|
const IconComponent = subItem.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={subItem.title}
|
||||||
|
onClick={() => {
|
||||||
|
navigateTo(subItem.href);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-left text-[16px] text-gray-600 hover:bg-gray-50 hover:text-primary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px]"
|
||||||
|
>
|
||||||
|
<IconComponent className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigateTo('/contact');
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center p-3 text-[16px] text-gray-900 hover:bg-gray-50 rounded-lg transition-colors text-left focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px]"
|
||||||
|
>
|
||||||
|
<span className="font-medium">Contact</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Authentication Actions */}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className="p-4 border-t border-gray-200 space-y-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full text-[16px] min-h-[44px]"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSignup}
|
||||||
|
className="w-full text-[16px] min-h-[44px] bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Logout */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className="p-4 border-t border-gray-200">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full text-[16px] min-h-[44px] text-red-600 border-red-200 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/components/ProgrammeWizard.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { ChevronRight, RotateCcw, Wand2 } from "lucide-react";
|
||||||
|
import { navigateTo } from './Router';
|
||||||
|
|
||||||
|
const wizardSteps = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
question: "What's your current role?",
|
||||||
|
options: ["Individual Contributor", "Team Lead", "Manager", "Senior Manager", "Director", "VP/C-Level"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
question: "Which industry describes your organization?",
|
||||||
|
options: ["Technology", "Financial Services", "Healthcare", "Manufacturing", "Consulting", "Other"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
question: "What's your primary development goal?",
|
||||||
|
options: ["Strategic Thinking", "Team Leadership", "Communication", "Digital Transformation", "Innovation", "Change Management"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
question: "How much leadership experience do you have?",
|
||||||
|
options: ["0-2 years", "3-5 years", "6-10 years", "10+ years"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProgrammeWizard() {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState<string[]>([]);
|
||||||
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
|
|
||||||
|
const handleAnswer = (answer: string) => {
|
||||||
|
const newAnswers = [...answers, answer];
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
if (currentStep < wizardSteps.length - 1) {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
} else {
|
||||||
|
setIsCompleted(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setCurrentStep(0);
|
||||||
|
setAnswers([]);
|
||||||
|
setIsCompleted(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecommendation = () => {
|
||||||
|
// Simple recommendation logic based on answers
|
||||||
|
const [role, industry, goal, experience] = answers;
|
||||||
|
if (goal === "Strategic Thinking" && experience === "10+ years") {
|
||||||
|
return {
|
||||||
|
title: "Strategic Leadership Mastery",
|
||||||
|
reason: "Perfect for senior leaders focused on strategic thinking",
|
||||||
|
slug: "strategic-leadership"
|
||||||
|
};
|
||||||
|
} else if (goal === "Communication") {
|
||||||
|
return {
|
||||||
|
title: "Executive Leadership Program",
|
||||||
|
reason: "Ideal for developing communication and executive presence",
|
||||||
|
slug: "exec-leadership-program"
|
||||||
|
};
|
||||||
|
} else if (role === "Individual Contributor" || role === "Team Lead") {
|
||||||
|
return {
|
||||||
|
title: "Emerging Leaders Program",
|
||||||
|
reason: "Great foundation for emerging leaders",
|
||||||
|
slug: "emerging-leaders"
|
||||||
|
};
|
||||||
|
} else if (goal === "Innovation" || goal === "Digital Transformation") {
|
||||||
|
return {
|
||||||
|
title: "Innovation Leadership",
|
||||||
|
reason: "Perfect for driving innovation and digital transformation",
|
||||||
|
slug: "innovation-leadership"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
title: "Team Leadership Intensive",
|
||||||
|
reason: "Comprehensive programme for effective team leadership",
|
||||||
|
slug: "team-leadership-intensive"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
const recommendation = getRecommendation();
|
||||||
|
return (
|
||||||
|
<Card className="bg-primary/5 border-primary">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||||
|
<Wand2 className="w-5 h-5 text-primary" />
|
||||||
|
Your Recommended Programme
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="bg-white p-6 rounded-lg border shadow-sm">
|
||||||
|
<h3 className="text-xl text-foreground mb-2">{recommendation.title}</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">{recommendation.reason}</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<Button
|
||||||
|
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
|
onClick={() => navigateTo(`/programme/${recommendation.slug}`)}
|
||||||
|
>
|
||||||
|
View Programme Details
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={reset}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white border border-primary/20 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||||
|
<Wand2 className="w-5 h-5 text-primary" />
|
||||||
|
Need Help Choosing?
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-1 mt-4">
|
||||||
|
{wizardSteps.map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`h-2 flex-1 rounded ${
|
||||||
|
index <= currentStep ? "bg-primary" : "bg-secondary/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg mb-6 text-foreground">
|
||||||
|
{wizardSteps[currentStep].question}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{wizardSteps[currentStep].options.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
variant="outline"
|
||||||
|
className="justify-between hover:bg-primary hover:text-primary-foreground hover:border-primary text-left p-4 h-auto"
|
||||||
|
onClick={() => handleAnswer(option)}
|
||||||
|
>
|
||||||
|
<span>{option}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{answers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">Your answers:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{answers.map((answer, index) => (
|
||||||
|
<Badge key={index} variant="secondary" className="bg-primary/10 text-foreground">
|
||||||
|
{answer}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
306
src/components/Router.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Import all page components
|
||||||
|
import { HomePage } from '../pages/HomePage';
|
||||||
|
import Dashboard from '../pages/learner/Dashboard';
|
||||||
|
import { CorporateDashboard } from '../pages/learner/CorporateDashboard';
|
||||||
|
import { Library } from '../pages/learner/Library';
|
||||||
|
import { CourseTimeline } from '../pages/learner/CourseTimeline';
|
||||||
|
import { Settings } from '../pages/Settings';
|
||||||
|
import { Surveys } from '../pages/Surveys';
|
||||||
|
import { CorporateWebinars } from '../pages/CorporateWebinars';
|
||||||
|
import { IndividualWebinars } from '../pages/IndividualWebinars';
|
||||||
|
import { Leaderboard } from '../pages/Leaderboard';
|
||||||
|
import { CorporateLeaderboard } from '../pages/CorporateLeaderboard';
|
||||||
|
import { LoginSelection } from '../pages/LoginSelection';
|
||||||
|
import { Login } from '../pages/Login';
|
||||||
|
import { Signup } from '../pages/Signup';
|
||||||
|
import { CorporateAuth } from '../pages/CorporateAuth';
|
||||||
|
import { CorporateLogin } from '../pages/CorporateLogin';
|
||||||
|
import { CorporateSignup } from '../pages/CorporateSignup';
|
||||||
|
import { ForgotPassword } from '../pages/ForgotPassword';
|
||||||
|
import { EmailVerification } from '../pages/EmailVerification';
|
||||||
|
import { Contact } from '../pages/Contact';
|
||||||
|
import { AboutKLC } from '../pages/AboutKLC';
|
||||||
|
import { OurVision } from '../pages/OurVision';
|
||||||
|
import { OurTeam } from '../pages/OurTeam';
|
||||||
|
import { OurImpact } from '../pages/OurImpact';
|
||||||
|
import { OurExpertise } from '../pages/OurExpertise';
|
||||||
|
import { ProgrammeCatalogue } from '../pages/ProgrammeCatalogue';
|
||||||
|
import { ProgrammeDetail } from '../pages/ProgrammeDetail';
|
||||||
|
import { ExecutiveLeadership } from '../pages/ExecutiveLeadership';
|
||||||
|
import { TeamLeadership } from '../pages/TeamLeadership';
|
||||||
|
import { InnovationLeadership } from '../pages/InnovationLeadership';
|
||||||
|
import { LeadershipOnline } from '../pages/LeadershipOnline';
|
||||||
|
import { LeadershipDevelopment } from '../pages/services/LeadershipDevelopment';
|
||||||
|
import { ManagementDevelopment } from '../pages/services/ManagementDevelopment';
|
||||||
|
import { ExecutiveCoaching } from '../pages/services/ExecutiveCoaching';
|
||||||
|
import { CultureCompetence } from '../pages/services/CultureCompetence';
|
||||||
|
import { Consulting } from '../pages/services/Consulting';
|
||||||
|
import { LearningFacility } from '../pages/services/LearningFacility';
|
||||||
|
import { Articles } from '../pages/Articles';
|
||||||
|
import { BlogListing } from '../pages/BlogListing';
|
||||||
|
import { BlogDetail } from '../pages/BlogDetail';
|
||||||
|
import { Resources } from '../pages/Resources';
|
||||||
|
import { WebinarListing } from '../pages/WebinarListing';
|
||||||
|
import { WebinarDetail } from '../pages/WebinarDetail';
|
||||||
|
import { FacilityDetail } from '../pages/FacilityDetail';
|
||||||
|
import { FacilityBooking } from '../pages/FacilityBooking';
|
||||||
|
import { FacilityTour } from '../pages/FacilityTour';
|
||||||
|
import { Cart } from '../pages/Cart';
|
||||||
|
import { Checkout } from '../pages/Checkout';
|
||||||
|
import { OrderConfirmation } from '../pages/OrderConfirmation';
|
||||||
|
import { OrderFailed } from '../pages/OrderFailed';
|
||||||
|
import { MyCohort } from '../pages/MyCohort';
|
||||||
|
import { FAQ } from '../pages/FAQ';
|
||||||
|
import { Privacy } from '../pages/Privacy';
|
||||||
|
import { Terms } from '../pages/Terms';
|
||||||
|
import { NotFound } from '../pages/NotFound';
|
||||||
|
|
||||||
|
// Router function to navigate programmatically
|
||||||
|
export function navigateTo(path: string) {
|
||||||
|
window.history.pushState({}, '', path);
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get query parameter helper
|
||||||
|
function getQueryParam(param: string): string | null {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router component
|
||||||
|
export function Router() {
|
||||||
|
const [currentPath, setCurrentPath] = React.useState(window.location.pathname);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handlePopState = () => {
|
||||||
|
setCurrentPath(window.location.pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle legacy route redirects
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Redirect old corporate routes to new query parameter format
|
||||||
|
const legacyRedirects: Record<string, string> = {
|
||||||
|
'/corporate/dashboard': '/dashboard?view=corporate',
|
||||||
|
'/corporate/library': '/library?view=corporate',
|
||||||
|
'/corporate/course': '/course?view=corporate',
|
||||||
|
'/corporate/settings': '/settings?view=corporate',
|
||||||
|
'/corporate/surveys': '/surveys?view=corporate',
|
||||||
|
'/corporate/webinars': '/webinars?view=corporate',
|
||||||
|
'/corporate/leaderboard': '/leaderboard?view=corporate'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (legacyRedirects[currentPath]) {
|
||||||
|
navigateTo(legacyRedirects[currentPath]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect deprecated service pages to home
|
||||||
|
const deprecatedServiceRoutes = [
|
||||||
|
'/services',
|
||||||
|
'/services/leadership-development',
|
||||||
|
'/services/management-development',
|
||||||
|
'/services/executive-coaching',
|
||||||
|
'/services/culture-competence',
|
||||||
|
'/services/consulting',
|
||||||
|
'/services/learning-facility'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (deprecatedServiceRoutes.includes(currentPath)) {
|
||||||
|
navigateTo('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect deprecated about pages to home
|
||||||
|
const deprecatedAboutRoutes = [
|
||||||
|
'/about-us/our-vision',
|
||||||
|
'/about-us/our-team',
|
||||||
|
'/about-us/our-impact',
|
||||||
|
'/about-us/our-expertise'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (deprecatedAboutRoutes.includes(currentPath)) {
|
||||||
|
navigateTo('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect deprecated learning pages to home
|
||||||
|
const deprecatedLearningRoutes = [
|
||||||
|
'/learning/articles',
|
||||||
|
'/learning/blog',
|
||||||
|
'/learning/resources'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (deprecatedLearningRoutes.includes(currentPath)) {
|
||||||
|
navigateTo('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [currentPath]);
|
||||||
|
|
||||||
|
// Route matching logic
|
||||||
|
const renderRoute = () => {
|
||||||
|
const path = currentPath;
|
||||||
|
const viewParam = getQueryParam('view');
|
||||||
|
|
||||||
|
switch (path) {
|
||||||
|
case '/':
|
||||||
|
return <HomePage />;
|
||||||
|
|
||||||
|
// Authentication routes
|
||||||
|
case '/auth':
|
||||||
|
case '/login-selection':
|
||||||
|
return <LoginSelection />;
|
||||||
|
case '/login':
|
||||||
|
return <Login />;
|
||||||
|
case '/signup':
|
||||||
|
return <Signup />;
|
||||||
|
case '/corporate/auth':
|
||||||
|
return <CorporateAuth />;
|
||||||
|
case '/corporate/login':
|
||||||
|
return <CorporateLogin />;
|
||||||
|
case '/corporate/signup':
|
||||||
|
return <CorporateSignup />;
|
||||||
|
case '/forgot-password':
|
||||||
|
return <ForgotPassword />;
|
||||||
|
case '/email-verification':
|
||||||
|
return <EmailVerification />;
|
||||||
|
|
||||||
|
// Learner portal routes with query parameter support
|
||||||
|
case '/dashboard':
|
||||||
|
// Route to appropriate dashboard based on view parameter
|
||||||
|
if (viewParam === 'corporate') {
|
||||||
|
return <CorporateDashboard />;
|
||||||
|
}
|
||||||
|
return <Dashboard userType="individual" />;
|
||||||
|
case '/library':
|
||||||
|
return <Library />;
|
||||||
|
case '/course':
|
||||||
|
return <CourseTimeline />;
|
||||||
|
case '/settings':
|
||||||
|
return <Settings />;
|
||||||
|
case '/surveys':
|
||||||
|
return <Surveys />;
|
||||||
|
case '/webinars':
|
||||||
|
// Route to appropriate webinar page based on view parameter
|
||||||
|
if (viewParam === 'corporate') {
|
||||||
|
return <CorporateWebinars />;
|
||||||
|
}
|
||||||
|
return <IndividualWebinars />;
|
||||||
|
case '/leaderboard':
|
||||||
|
// Route to appropriate leaderboard based on view parameter
|
||||||
|
if (viewParam === 'corporate') {
|
||||||
|
return <CorporateLeaderboard />;
|
||||||
|
}
|
||||||
|
return <Leaderboard />;
|
||||||
|
|
||||||
|
// Individual webinars (accessible without authentication)
|
||||||
|
case '/individual-webinars':
|
||||||
|
return <IndividualWebinars />;
|
||||||
|
|
||||||
|
// Contact and support
|
||||||
|
case '/contact':
|
||||||
|
return <Contact />;
|
||||||
|
|
||||||
|
// About pages (deprecated - redirect to home)
|
||||||
|
case '/about-klc':
|
||||||
|
return <AboutKLC />;
|
||||||
|
case '/about-us/our-vision':
|
||||||
|
return <OurVision />;
|
||||||
|
case '/about-us/our-team':
|
||||||
|
return <OurTeam />;
|
||||||
|
case '/about-us/our-impact':
|
||||||
|
return <OurImpact />;
|
||||||
|
case '/about-us/our-expertise':
|
||||||
|
return <OurExpertise />;
|
||||||
|
|
||||||
|
// Programme pages (deprecated - redirect to home)
|
||||||
|
case '/programmes':
|
||||||
|
return <ProgrammeCatalogue />;
|
||||||
|
case '/programmes/detail':
|
||||||
|
return <ProgrammeDetail />;
|
||||||
|
case '/programmes/executive-leadership':
|
||||||
|
return <ExecutiveLeadership />;
|
||||||
|
case '/programmes/team-leadership':
|
||||||
|
return <TeamLeadership />;
|
||||||
|
case '/programmes/innovation-leadership':
|
||||||
|
return <InnovationLeadership />;
|
||||||
|
case '/programmes/leadership-online':
|
||||||
|
return <LeadershipOnline />;
|
||||||
|
|
||||||
|
// Service pages (deprecated - redirect to home)
|
||||||
|
case '/services/leadership-development':
|
||||||
|
return <LeadershipDevelopment />;
|
||||||
|
case '/services/management-development':
|
||||||
|
return <ManagementDevelopment />;
|
||||||
|
case '/services/executive-coaching':
|
||||||
|
return <ExecutiveCoaching />;
|
||||||
|
case '/services/culture-competence':
|
||||||
|
return <CultureCompetence />;
|
||||||
|
case '/services/consulting':
|
||||||
|
return <Consulting />;
|
||||||
|
case '/services/learning-facility':
|
||||||
|
return <LearningFacility />;
|
||||||
|
|
||||||
|
// Learning pages (deprecated - redirect to home)
|
||||||
|
case '/learning/articles':
|
||||||
|
return <Articles />;
|
||||||
|
case '/learning/blog':
|
||||||
|
return <BlogListing />;
|
||||||
|
case '/learning/blog/detail':
|
||||||
|
return <BlogDetail />;
|
||||||
|
case '/learning/resources':
|
||||||
|
return <Resources />;
|
||||||
|
|
||||||
|
// Webinar pages (deprecated - redirect to home)
|
||||||
|
case '/webinars/listing':
|
||||||
|
return <WebinarListing />;
|
||||||
|
case '/webinars/detail':
|
||||||
|
return <WebinarDetail />;
|
||||||
|
|
||||||
|
// Facility pages (deprecated - redirect to home)
|
||||||
|
case '/facilities/detail':
|
||||||
|
return <FacilityDetail />;
|
||||||
|
case '/facilities/booking':
|
||||||
|
return <FacilityBooking />;
|
||||||
|
case '/facilities/tour':
|
||||||
|
return <FacilityTour />;
|
||||||
|
|
||||||
|
// E-commerce pages (deprecated - redirect to home)
|
||||||
|
case '/cart':
|
||||||
|
return <Cart />;
|
||||||
|
case '/checkout':
|
||||||
|
return <Checkout />;
|
||||||
|
case '/order-confirmation':
|
||||||
|
return <OrderConfirmation />;
|
||||||
|
case '/order-failed':
|
||||||
|
return <OrderFailed />;
|
||||||
|
|
||||||
|
// Additional learner features (deprecated - redirect to home)
|
||||||
|
case '/my-cohort':
|
||||||
|
return <MyCohort />;
|
||||||
|
|
||||||
|
// Legal and support pages
|
||||||
|
case '/faq':
|
||||||
|
return <FAQ />;
|
||||||
|
case '/privacy':
|
||||||
|
return <Privacy />;
|
||||||
|
case '/terms':
|
||||||
|
return <Terms />;
|
||||||
|
|
||||||
|
// 404 page
|
||||||
|
default:
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{renderRoute()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/figma/ImageWithFallback.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
const ERROR_IMG_SRC =
|
||||||
|
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
|
||||||
|
|
||||||
|
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||||
|
const [didError, setDidError] = useState(false)
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
setDidError(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { src, alt, style, className, ...rest } = props
|
||||||
|
|
||||||
|
return didError ? (
|
||||||
|
<div
|
||||||
|
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
|
||||||
|
)
|
||||||
|
}
|
||||||
331
src/components/learner/CourseCard.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Progress } from '../ui/progress';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
Calendar,
|
||||||
|
Award,
|
||||||
|
Eye,
|
||||||
|
Heart,
|
||||||
|
Share2,
|
||||||
|
Video,
|
||||||
|
FileText,
|
||||||
|
Headphones,
|
||||||
|
Monitor,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Course } from '../../pages/learner/data/libraryData';
|
||||||
|
import { navigateTo } from '../Router';
|
||||||
|
|
||||||
|
interface CourseCardProps {
|
||||||
|
course: Course;
|
||||||
|
userType: 'individual' | 'corporate';
|
||||||
|
onEnroll?: (courseId: string) => void;
|
||||||
|
onContinue?: (courseId: string) => void;
|
||||||
|
onBookmark?: (courseId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'video': return Video;
|
||||||
|
case 'article': return FileText;
|
||||||
|
case 'audio': return Headphones;
|
||||||
|
case 'interactive': return Monitor;
|
||||||
|
default: return Video;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority?: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': return 'text-destructive bg-destructive/10';
|
||||||
|
case 'medium': return 'text-orange-600 bg-orange-100';
|
||||||
|
case 'low': return 'text-success bg-success/10';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'Beginner': return 'text-success bg-success/10';
|
||||||
|
case 'Intermediate': return 'text-primary bg-primary/10';
|
||||||
|
case 'Advanced': return 'text-destructive bg-destructive/10';
|
||||||
|
default: return 'text-muted-foreground bg-muted';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CourseCard({ course, userType, onEnroll, onContinue, onBookmark }: CourseCardProps) {
|
||||||
|
const TypeIcon = getTypeIcon(course.type);
|
||||||
|
const isOverdue = course.deadline && new Date(course.deadline) < new Date();
|
||||||
|
|
||||||
|
// Navigate to course details page with proper query parameters
|
||||||
|
const handleCourseNavigation = () => {
|
||||||
|
navigateTo(`/course?view=${userType}&courseId=${course.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`group hover:shadow-lg transition-all duration-300 ${
|
||||||
|
course.isFeatured ? 'ring-2 ring-primary/20' : ''
|
||||||
|
} ${isOverdue ? 'border-destructive/20 bg-destructive/5' : ''}`}>
|
||||||
|
{/* Course Thumbnail */}
|
||||||
|
<div className="relative overflow-hidden rounded-t-lg">
|
||||||
|
<img
|
||||||
|
src={course.thumbnail}
|
||||||
|
alt={course.title}
|
||||||
|
className="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay Badges */}
|
||||||
|
<div className="absolute top-3 left-3 flex flex-wrap gap-2">
|
||||||
|
{course.isFeatured && (
|
||||||
|
<Badge className="bg-primary text-primary-foreground text-xs">
|
||||||
|
Featured
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{course.isPremium && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{course.organizationAssigned && userType === 'corporate' && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-background/90">
|
||||||
|
Assigned
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority Badge for Corporate */}
|
||||||
|
{userType === 'corporate' && course.priority && (
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<Badge variant="outline" className={`text-xs ${getPriorityColor(course.priority)}`}>
|
||||||
|
{course.priority.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type Icon */}
|
||||||
|
<div className="absolute bottom-3 left-3">
|
||||||
|
<div className="bg-background/90 backdrop-blur-sm p-2 rounded-full">
|
||||||
|
<TypeIcon className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Status */}
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
{course.status === 'completed' && (
|
||||||
|
<div className="bg-success/90 backdrop-blur-sm p-2 rounded-full">
|
||||||
|
<CheckCircle className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.status === 'bookmarked' && (
|
||||||
|
<div className="bg-orange-500/90 backdrop-blur-sm p-2 rounded-full">
|
||||||
|
<Heart className="h-4 w-4 text-white fill-current" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-base text-muted-foreground mt-2 line-clamp-2">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructor Info */}
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={course.instructor.avatar} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{course.instructor.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-base font-medium">{course.instructor.name}</p>
|
||||||
|
<p className="text-base text-muted-foreground">{course.instructor.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Metadata */}
|
||||||
|
<div className="flex items-center gap-4 mt-3 text-base text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>{course.duration}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
<span>{course.lessonsCount} lessons</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{course.enrolledCount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`h-4 w-4 ${
|
||||||
|
i < Math.floor(course.rating)
|
||||||
|
? 'text-yellow-500 fill-current'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-medium">{course.rating}</span>
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
({course.completionRate}% completion rate)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level and Category */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge variant="outline" className={`text-xs ${getLevelColor(course.level)}`}>
|
||||||
|
{course.level}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{course.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{course.tags.slice(0, 3).map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{course.tags.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{course.tags.length - 3} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{/* Progress Bar for In-Progress Courses */}
|
||||||
|
{course.status === 'in-progress' && course.progress !== undefined && (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between text-base">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{course.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={course.progress} className="h-2" />
|
||||||
|
{course.lastAccessed && (
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
Last accessed: {course.lastAccessed}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deadline Warning for Corporate */}
|
||||||
|
{userType === 'corporate' && course.deadline && (
|
||||||
|
<div className={`p-3 rounded-lg mb-4 ${
|
||||||
|
isOverdue
|
||||||
|
? 'bg-destructive/5 border border-destructive/20'
|
||||||
|
: 'bg-orange-50 border border-orange-200'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className={`h-4 w-4 ${isOverdue ? 'text-destructive' : 'text-orange-600'}`} />
|
||||||
|
<span className={`text-base font-medium ${isOverdue ? 'text-destructive' : 'text-orange-600'}`}>
|
||||||
|
{isOverdue ? 'Overdue' : 'Due'}: {new Date(course.deadline).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{course.status === 'not-started' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
className="flex-1 text-base min-h-[44px]"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Start Course
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'in-progress' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
className="flex-1 text-base min-h-[44px]"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Continue Learning
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'completed' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
className="flex-1 text-base min-h-[44px]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Review Course
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'bookmarked' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
className="flex-1 text-base min-h-[44px]"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Start Course
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary Actions */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onBookmark?.(course.id)}
|
||||||
|
className="min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="Bookmark course"
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${course.status === 'bookmarked' ? 'fill-current text-red-500' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="Share course"
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{course.certificate && course.status === 'completed' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="Download certificate"
|
||||||
|
>
|
||||||
|
<Award className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/components/learner/CourseListItem.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
Heart,
|
||||||
|
MoreHorizontal,
|
||||||
|
Award,
|
||||||
|
BookOpen,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
Target
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Course } from '../../pages/learner/data/libraryData';
|
||||||
|
import { navigateTo } from '../Router';
|
||||||
|
|
||||||
|
interface CourseListItemProps {
|
||||||
|
course: Course;
|
||||||
|
userType: 'individual' | 'corporate';
|
||||||
|
onEnroll?: (courseId: string) => void;
|
||||||
|
onContinue?: (courseId: string) => void;
|
||||||
|
onBookmark?: (courseId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'text-success bg-success/10';
|
||||||
|
case 'in-progress': return 'text-primary bg-primary/10';
|
||||||
|
case 'bookmarked': return 'text-orange-600 bg-orange-100';
|
||||||
|
case 'not-started': return 'text-muted-foreground bg-muted/50';
|
||||||
|
default: return 'text-muted-foreground bg-muted/50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCourseIcon = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case 'Leadership': return '👑';
|
||||||
|
case 'Personal Development': return '🧠';
|
||||||
|
case 'Team Management': return '👥';
|
||||||
|
case 'Digital Leadership': return '💻';
|
||||||
|
case 'Communication': return '💬';
|
||||||
|
case 'Crisis Management': return '⚡';
|
||||||
|
default: return '📚';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCourseGradient = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case 'Leadership': return 'bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500';
|
||||||
|
case 'Personal Development': return 'bg-gradient-to-br from-green-400 via-blue-500 to-purple-600';
|
||||||
|
case 'Team Management': return 'bg-gradient-to-br from-orange-400 via-pink-500 to-red-500';
|
||||||
|
case 'Digital Leadership': return 'bg-gradient-to-br from-cyan-400 via-blue-500 to-indigo-600';
|
||||||
|
case 'Communication': return 'bg-gradient-to-br from-yellow-400 via-orange-500 to-red-500';
|
||||||
|
case 'Crisis Management': return 'bg-gradient-to-br from-red-400 via-pink-500 to-purple-600';
|
||||||
|
default: return 'bg-gradient-to-br from-gray-400 via-gray-500 to-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CourseListItem({ course, userType, onEnroll, onContinue, onBookmark }: CourseListItemProps) {
|
||||||
|
const isOverdue = course.deadline && new Date(course.deadline) < new Date();
|
||||||
|
|
||||||
|
// Navigate to course details page with proper query parameters
|
||||||
|
const handleCourseNavigation = () => {
|
||||||
|
navigateTo(`/course?view=${userType}&courseId=${course.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-4 p-4 bg-background border border-border rounded-lg hover:shadow-md transition-all duration-200">
|
||||||
|
{/* Course Thumbnail */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className={`w-16 h-16 rounded-lg ${getCourseGradient(course.category)} flex items-center justify-center text-2xl relative overflow-hidden`}>
|
||||||
|
<span className="relative z-10">{getCourseIcon(course.category)}</span>
|
||||||
|
<div className="absolute inset-0 bg-black/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Content */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
{/* Title and Badges */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3
|
||||||
|
className="text-lg font-semibold text-foreground line-clamp-1 hover:text-primary transition-colors cursor-pointer"
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
>
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{course.isPremium && (
|
||||||
|
<Badge variant="secondary" className="text-xs bg-yellow-100 text-yellow-800">
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{course.isFeatured && (
|
||||||
|
<Badge className="text-xs bg-primary text-primary-foreground">
|
||||||
|
Featured
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className={`text-xs ${getStatusColor(course.status)}`}>
|
||||||
|
{course.status === 'not-started' ? 'Available' : course.status.replace('-', ' ').toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-base text-muted-foreground line-clamp-2 leading-relaxed">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Course Metadata */}
|
||||||
|
<div className="flex items-center gap-4 text-base text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>{course.duration}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
<span>{course.lessonsCount} lessons</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{course.enrolledCount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{course.level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for In-Progress Courses */}
|
||||||
|
{course.status === 'in-progress' && course.progress !== undefined && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-base">
|
||||||
|
<span className="text-muted-foreground">Progress</span>
|
||||||
|
<span className="font-medium">{course.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${course.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deadline Warning for Corporate */}
|
||||||
|
{userType === 'corporate' && course.deadline && (
|
||||||
|
<div className={`p-2 rounded-md text-base flex items-center gap-2 ${
|
||||||
|
isOverdue
|
||||||
|
? 'bg-destructive/10 text-destructive'
|
||||||
|
: 'bg-orange-50 text-orange-700'
|
||||||
|
}`}>
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{isOverdue ? 'Overdue' : 'Due'}: {new Date(course.deadline).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instructor and Rating Row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base text-muted-foreground">Created by:</span>
|
||||||
|
<Avatar className="w-6 h-6">
|
||||||
|
<AvatarImage src={course.instructor.avatar} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{course.instructor.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-base font-medium text-foreground">{course.instructor.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Rating */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`h-4 w-4 ${
|
||||||
|
i < Math.floor(course.rating)
|
||||||
|
? 'text-yellow-500 fill-current'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-base font-medium ml-1">{course.rating}</span>
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
({course.completionRate}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{course.status === 'not-started' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="text-base min-h-[36px]"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'in-progress' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="text-base min-h-[36px]"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'completed' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="text-base min-h-[36px]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'bookmarked' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="text-base min-h-[36px]"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onBookmark?.(course.id)}
|
||||||
|
className="min-h-[36px] min-w-[36px] hover:bg-muted"
|
||||||
|
aria-label="Bookmark course"
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${course.status === 'bookmarked' ? 'fill-current text-red-500' : 'text-muted-foreground'}`} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="min-h-[36px] min-w-[36px] hover:bg-muted"
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
src/components/learner/HorizontalCourseCard.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
Heart,
|
||||||
|
BookOpen,
|
||||||
|
ChevronRight,
|
||||||
|
MoreHorizontal
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Course } from '../../pages/learner/data/libraryData';
|
||||||
|
import { navigateTo } from '../Router';
|
||||||
|
import { ImageWithFallback } from '../figma/ImageWithFallback';
|
||||||
|
|
||||||
|
interface HorizontalCourseCardProps {
|
||||||
|
course: Course;
|
||||||
|
userType: 'individual' | 'corporate';
|
||||||
|
onEnroll?: (courseId: string) => void;
|
||||||
|
onContinue?: (courseId: string) => void;
|
||||||
|
onBookmark?: (courseId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Course images based on course category
|
||||||
|
const getCourseImage = (category: string) => {
|
||||||
|
const patterns = {
|
||||||
|
'Leadership': (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 opacity-100">
|
||||||
|
<ImageWithFallback
|
||||||
|
src="https://images.unsplash.com/photo-1658198420916-951923730cdd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxsZWFkZXJzaGlwJTIwYm9va3N8ZW58MXx8fHwxNzU1ODQzNDIxfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
'Personal Development': (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 opacity-100">
|
||||||
|
<ImageWithFallback
|
||||||
|
src="https://images.unsplash.com/photo-1668092547893-6402c0387885?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxidXNpbmVzcyUyMGVkdWNhdGlvbiUyMGxlYXJuaW5nfGVufDF8fHx8MTc1NTg0MzQwOHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
'Team Management': (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 opacity-100">
|
||||||
|
<ImageWithFallback
|
||||||
|
src="https://images.unsplash.com/photo-1668092547893-6402c0387885?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHhidXNpbmVzcyUyMGVkdWNhdGlvbiUyMGxlYXJuaW5nfGVufDF8fHx8MTc1NTg0MzQwOHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
'Digital Leadership': (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 opacity-100">
|
||||||
|
<ImageWithFallback
|
||||||
|
src="https://images.unsplash.com/photo-1588912914078-2fe5224fd8b8?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxvbmxpbmUlMjBjb3Vyc2UlMjBsYXB0b3B8ZW58MXx8fHwxNzU1NzIwMTYyfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
'Communication': (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 opacity-100">
|
||||||
|
<ImageWithFallback
|
||||||
|
src="https://images.unsplash.com/photo-1668092547893-6402c0387885?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxidXNpbmVzcyUyMGVkdWNhdGlvbiUyMGxlYXJuaW5nfGVufDF8fHx8MTc1NTg0MzQwOHww&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return patterns[category as keyof typeof patterns] || patterns.Leadership;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HorizontalCourseCard({ course, userType, onEnroll, onContinue, onBookmark }: HorizontalCourseCardProps) {
|
||||||
|
const courseImage = getCourseImage(course.category);
|
||||||
|
|
||||||
|
// Navigate to course details page with proper query parameters
|
||||||
|
const handleCourseNavigation = () => {
|
||||||
|
navigateTo(`/course?view=${userType}&courseId=${course.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-border rounded-lg overflow-hidden hover:shadow-lg transition-all duration-200"
|
||||||
|
style={{ height: '260px', minHeight: '260px' }}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Left Image Section - 50% width, full height */}
|
||||||
|
<div className="w-1/2 relative overflow-hidden">
|
||||||
|
{/* Course image overlay */}
|
||||||
|
{courseImage}
|
||||||
|
|
||||||
|
{/* Status indicators - Positioned better for larger image area */}
|
||||||
|
<div className="absolute top-3 left-3 flex flex-wrap gap-1.5 z-10 max-w-[70%]">
|
||||||
|
{course.isFeatured && (
|
||||||
|
<Badge className="text-sm bg-white/90 text-foreground hover:bg-white font-medium">
|
||||||
|
Featured
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{course.isPremium && (
|
||||||
|
<Badge variant="secondary" className="text-sm bg-[#F8C301]/90 text-[#26231A] font-medium">
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course status indicator */}
|
||||||
|
<div className="absolute top-3 right-3 z-10">
|
||||||
|
{course.status === 'completed' && (
|
||||||
|
<div className="bg-success/90 backdrop-blur-sm p-1.5 rounded-full">
|
||||||
|
<CheckCircle className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.status === 'bookmarked' && (
|
||||||
|
<div className="bg-[#F8C301]/90 backdrop-blur-sm p-1.5 rounded-full">
|
||||||
|
<Heart className="h-4 w-4 text-[#26231A] fill-current" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Content Section - 50% width with increased spacing */}
|
||||||
|
<div className="w-1/2 p-5 flex flex-col justify-between">
|
||||||
|
{/* Top Content - Title and Description stay with original spacing */}
|
||||||
|
<div>
|
||||||
|
{/* Course Title - Increased to text-xl (20px) as requested */}
|
||||||
|
<h3 className="text-xl font-semibold text-foreground line-clamp-2 leading-tight mb-2">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description with 8px spacing from title as requested */}
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed mb-4">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Elements after description - Increased vertical spacing to 12px (space-y-3) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Created by section - Updated font size for Avatar text */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="w-5 h-5 flex-shrink-0">
|
||||||
|
<AvatarImage src={course.instructor.avatar} />
|
||||||
|
<AvatarFallback className="text-sm">
|
||||||
|
{course.instructor.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm text-muted-foreground">Created by:</span>
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{course.instructor.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges below created by - Updated to text-sm (14px minimum) with brand colors */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
course.level === 'Beginner' ? 'border-success text-success bg-success/5' :
|
||||||
|
course.level === 'Intermediate' ? 'border-primary text-primary bg-primary/5' :
|
||||||
|
course.level === 'Advanced' ? 'border-[#26231A] text-[#26231A] bg-[#26231A]/5' :
|
||||||
|
'border-[#F8C301] text-[#26231A] bg-[#F8C301]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{course.level}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
course.category === 'Leadership' ? 'border-primary text-primary bg-primary/5' :
|
||||||
|
course.category === 'Digital Leadership' ? 'border-[#F8C301] text-[#26231A] bg-[#F8C301]/10' :
|
||||||
|
course.category === 'Strategy' ? 'border-[#26231A] text-[#26231A] bg-[#26231A]/5' :
|
||||||
|
'border-[#F8C301] text-[#26231A] bg-[#F8C301]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{course.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Combined lessons, duration, and rating section - All in one container */}
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
{/* Left side: Duration and Lessons */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>{course.duration}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<BookOpen className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>{course.lessonsCount} lessons</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Rating stars */}
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`h-3.5 w-3.5 ${
|
||||||
|
i < Math.floor(course.rating)
|
||||||
|
? 'text-[#F8C301] fill-current'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-sm font-medium ml-1 text-foreground">{course.rating}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Section - CTA buttons only with reduced spacing */}
|
||||||
|
<div className="mt-auto">
|
||||||
|
{/* CTA buttons below everything else - wider for consistency */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{course.status === 'not-started' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-base font-medium min-h-[36px]"
|
||||||
|
>
|
||||||
|
Start Course
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'in-progress' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-base font-medium min-h-[36px]"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<Play className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'completed' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-base font-medium min-h-[36px]"
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course.status === 'bookmarked' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCourseNavigation}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-base font-medium min-h-[36px]"
|
||||||
|
>
|
||||||
|
Start Course
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary action buttons - optimized sizing */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onBookmark?.(course.id)}
|
||||||
|
className="min-h-[36px] min-w-[36px] hover:bg-muted flex-shrink-0"
|
||||||
|
aria-label="Bookmark course"
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${course.status === 'bookmarked' ? 'fill-current text-[#F8C301]' : 'text-muted-foreground'}`} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="min-h-[36px] min-w-[36px] hover:bg-muted flex-shrink-0"
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
541
src/components/learner/LearnerLayout.tsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '../ui/sheet';
|
||||||
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
|
import { Separator } from '../ui/separator';
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
Search,
|
||||||
|
Bell,
|
||||||
|
ChevronDown,
|
||||||
|
Home,
|
||||||
|
BookOpen,
|
||||||
|
User,
|
||||||
|
BarChart3,
|
||||||
|
MessageSquare,
|
||||||
|
Calendar,
|
||||||
|
Trophy,
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
Settings
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { navigateTo } from '../Router';
|
||||||
|
|
||||||
|
interface LearnerLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
currentPage?: string;
|
||||||
|
userType?: 'individual' | 'corporate';
|
||||||
|
user?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
organization?: string;
|
||||||
|
orgLogo?: string;
|
||||||
|
role?: string;
|
||||||
|
cohort?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
notifications: Array<{
|
||||||
|
id: string;
|
||||||
|
type: 'info' | 'warning' | 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationPanel({ isOpen, onClose, notifications }: NotificationPanelProps) {
|
||||||
|
const unreadCount = notifications.filter(n => !n.read).length;
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 lg:relative lg:inset-auto">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
<div className="lg:hidden fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="fixed right-0 top-0 h-full w-80 bg-card border-l border-border shadow-xl lg:absolute lg:top-full lg:right-0 lg:h-auto lg:max-h-96 lg:rounded-lg lg:border lg:shadow-lg">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
Notifications {unreadCount > 0 && <Badge variant="secondary" className="ml-2">{unreadCount}</Badge>}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-80 lg:h-64">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Bell className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No notifications</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={`p-3 rounded-lg border ${notification.read ? 'bg-muted/50' : 'bg-background'} hover:bg-muted/70 transition-colors cursor-pointer`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full mt-2 flex-shrink-0 ${
|
||||||
|
notification.type === 'info' ? 'bg-blue-500' :
|
||||||
|
notification.type === 'success' ? 'bg-success' :
|
||||||
|
notification.type === 'warning' ? 'bg-yellow-500' :
|
||||||
|
'bg-destructive'
|
||||||
|
}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-lg text-foreground">{notification.title}</p>
|
||||||
|
<p className="text-lg text-muted-foreground mt-1">{notification.message}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">{notification.time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<div className="p-4 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-lg min-h-[44px]"
|
||||||
|
onClick={() => navigateTo('/notifications')}
|
||||||
|
>
|
||||||
|
View All Notifications
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function LearnerLayout({ children, currentPage, userType = 'individual', user }: LearnerLayoutProps) {
|
||||||
|
// Get current path if not provided
|
||||||
|
const currentPath = currentPage || window.location.pathname;
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
// Get current view parameter directly from URL
|
||||||
|
const getCurrentView = () => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
return searchParams.get('view');
|
||||||
|
};
|
||||||
|
|
||||||
|
const [currentView, setCurrentView] = useState(getCurrentView);
|
||||||
|
|
||||||
|
// Update URL params when location changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLocationChange = () => {
|
||||||
|
setCurrentView(getCurrentView());
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handleLocationChange);
|
||||||
|
|
||||||
|
// Listen for programmatic navigation changes
|
||||||
|
const originalPushState = window.history.pushState;
|
||||||
|
const originalReplaceState = window.history.replaceState;
|
||||||
|
|
||||||
|
window.history.pushState = function(...args) {
|
||||||
|
originalPushState.apply(window.history, args);
|
||||||
|
setTimeout(handleLocationChange, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.history.replaceState = function(...args) {
|
||||||
|
originalReplaceState.apply(window.history, args);
|
||||||
|
setTimeout(handleLocationChange, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handleLocationChange);
|
||||||
|
window.history.pushState = originalPushState;
|
||||||
|
window.history.replaceState = originalReplaceState;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
const notifications = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'info' as const,
|
||||||
|
title: 'New Course Available',
|
||||||
|
message: 'Strategic Leadership Foundations is now available in your library',
|
||||||
|
time: '2 hours ago',
|
||||||
|
read: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'warning' as const,
|
||||||
|
title: 'Assignment Due Soon',
|
||||||
|
message: 'Leadership Assessment due in 3 days',
|
||||||
|
time: '1 day ago',
|
||||||
|
read: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'success' as const,
|
||||||
|
title: 'Certificate Earned',
|
||||||
|
message: 'Congratulations! You completed Management Essentials',
|
||||||
|
time: '3 days ago',
|
||||||
|
read: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter(n => !n.read).length;
|
||||||
|
|
||||||
|
// Navigation items with simplified active state logic
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
icon: Home,
|
||||||
|
href: `/dashboard?view=${userType}`,
|
||||||
|
active: currentPath === '/dashboard' && (currentView === userType || (!currentView && userType === 'individual'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Library',
|
||||||
|
icon: BookOpen,
|
||||||
|
href: `/library?view=${userType}`,
|
||||||
|
active: currentPath === '/library' && (currentView === userType || (!currentView && userType === 'individual'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Surveys',
|
||||||
|
icon: MessageSquare,
|
||||||
|
href: `/surveys?view=${userType}`,
|
||||||
|
active: currentPath === '/surveys' && (currentView === userType || (!currentView && userType === 'individual'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Webinars',
|
||||||
|
icon: Calendar,
|
||||||
|
href: `/webinars?view=${userType}`,
|
||||||
|
active: (currentPath === '/webinars' || currentPath === '/individual-webinars') && (currentView === userType || (!currentView && userType === 'individual'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Leaderboard',
|
||||||
|
icon: Trophy,
|
||||||
|
href: `/leaderboard?view=${userType}`,
|
||||||
|
active: currentPath === '/leaderboard' && (currentView === userType || (!currentView && userType === 'individual'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings',
|
||||||
|
icon: Settings,
|
||||||
|
href: `/settings?view=${userType}`,
|
||||||
|
active: currentPath?.startsWith('/settings') && (currentView === userType || (!currentView && userType === 'individual'))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const Sidebar = ({ className = "" }: { className?: string }) => (
|
||||||
|
<div className={`flex flex-col h-full bg-brand-navy ${className}`}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="p-6 border-b border-white/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-brand-gold rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-brand-gold-foreground font-bold text-sm">KLC</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-white text-lg">Learning Portal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<ScrollArea className="flex-1 px-4 py-6">
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.name}
|
||||||
|
variant={item.active ? "secondary" : "ghost"}
|
||||||
|
className={`w-full justify-start text-lg h-10 min-h-[44px] ${
|
||||||
|
item.active
|
||||||
|
? 'bg-brand-gold text-brand-gold-foreground hover:bg-brand-gold/90'
|
||||||
|
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
navigateTo(item.href);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="mr-3 h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Mobile Header */}
|
||||||
|
<header className="lg:hidden border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/60">
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="p-0 w-80">
|
||||||
|
<Sidebar />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Corporate org badge */}
|
||||||
|
{userType === 'corporate' && user?.organization && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-muted rounded-full">
|
||||||
|
{user.orgLogo && (
|
||||||
|
<img src={user.orgLogo} alt={user.organization} className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{user.organization}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 text-xs flex items-center justify-center">
|
||||||
|
{unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<NotificationPanel
|
||||||
|
isOpen={notificationsOpen}
|
||||||
|
onClose={() => setNotificationsOpen(false)}
|
||||||
|
notifications={notifications}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={user?.avatar} />
|
||||||
|
<AvatarFallback className="text-sm">
|
||||||
|
{user?.name?.split(' ').map(n => n[0]).join('') || 'U'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
{/* Desktop Sidebar - Reduced width from 256px to 240px */}
|
||||||
|
<div className="hidden lg:block w-60 border-r border-border">
|
||||||
|
<div className="fixed w-60 h-full">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area - Optimized for wider content */}
|
||||||
|
<div className="flex-1 lg:ml-0">
|
||||||
|
{/* Desktop Header */}
|
||||||
|
<header className="hidden lg:block border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/60">
|
||||||
|
<div className="flex items-center justify-between px-4 py-4">
|
||||||
|
{/* Search - Reduced max width for more space */}
|
||||||
|
<div className="flex-1 max-w-sm">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search courses, resources..."
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 text-lg border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Corporate features */}
|
||||||
|
{userType === 'corporate' && (
|
||||||
|
<>
|
||||||
|
{/* Organization badge */}
|
||||||
|
{user?.organization && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-muted rounded-full">
|
||||||
|
{user.orgLogo && (
|
||||||
|
<img src={user.orgLogo} alt={user.organization} className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-lg font-medium">{user.organization}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cohort reminder */}
|
||||||
|
{user?.cohort && (
|
||||||
|
<Badge variant="outline" className="text-sm">
|
||||||
|
<Users className="w-3 h-3 mr-1" />
|
||||||
|
{user.cohort}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 text-xs flex items-center justify-center">
|
||||||
|
{unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<NotificationPanel
|
||||||
|
isOpen={notificationsOpen}
|
||||||
|
onClose={() => setNotificationsOpen(false)}
|
||||||
|
notifications={notifications}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={user?.avatar} />
|
||||||
|
<AvatarFallback className="text-base">
|
||||||
|
{user?.name?.split(' ').map(n => n[0]).join('') || 'U'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="hidden xl:block">
|
||||||
|
<p className="text-lg font-medium">{user?.name || 'Priya Sharma'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{user?.email || 'priya.sharma@example.com'}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page Content - Remove default padding, let pages control their own spacing */}
|
||||||
|
<main
|
||||||
|
className="flex-1 min-h-screen bg-background"
|
||||||
|
role="main"
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{/* Content wrapper with consistent spacing and accessibility */}
|
||||||
|
<div className="w-full min-h-full">
|
||||||
|
{/* Skip to main content anchor for screen readers */}
|
||||||
|
<a
|
||||||
|
href="#learner-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 bg-[#04045B] text-white px-4 py-2 rounded-lg text-base font-medium focus:outline-none focus:ring-2 focus:ring-[#F8C301] focus:ring-offset-2 transition-all duration-200"
|
||||||
|
>
|
||||||
|
Skip to learner content
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Main learner content area */}
|
||||||
|
<div
|
||||||
|
id="learner-content"
|
||||||
|
className="w-full"
|
||||||
|
role="region"
|
||||||
|
aria-label="Learner portal content"
|
||||||
|
>
|
||||||
|
{/* Content with proper spacing and structure */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Background pattern for visual enhancement */}
|
||||||
|
<div className="absolute inset-0 opacity-[0.02] pointer-events-none">
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-[#04045B]/5 via-transparent to-[#F8C301]/5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content with proper spacing */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live region for dynamic content announcements */}
|
||||||
|
<div
|
||||||
|
id="learner-live-region"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
className="sr-only"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Status region for form validation and success messages */}
|
||||||
|
<div
|
||||||
|
id="learner-status-region"
|
||||||
|
aria-live="assertive"
|
||||||
|
aria-atomic="true"
|
||||||
|
className="sr-only"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back to top functionality for long content */}
|
||||||
|
<div className="fixed bottom-6 left-6 z-40">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
document.getElementById('main-content')?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="sr-only focus:not-sr-only bg-[#04045B] hover:bg-[#04045B]/90 text-white p-3 rounded-full shadow-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#F8C301] focus:ring-offset-2"
|
||||||
|
aria-label="Back to top of page"
|
||||||
|
title="Back to top"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress indicator for course content */}
|
||||||
|
<div
|
||||||
|
id="learner-progress-indicator"
|
||||||
|
className="fixed top-[70px] left-0 right-0 h-1 bg-gray-200 opacity-0 transition-opacity duration-200 z-40"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Page loading progress"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-[#04045B] to-[#F8C301] transition-all duration-300 ease-out"
|
||||||
|
style={{ width: '0%' }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
src/components/learner/LibraryFilters.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
X,
|
||||||
|
Globe,
|
||||||
|
Video,
|
||||||
|
FileText,
|
||||||
|
Headphones,
|
||||||
|
Monitor
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { categories, levels, courseTypes, sortOptions } from '../../pages/learner/data/libraryData';
|
||||||
|
|
||||||
|
interface LibraryFiltersProps {
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
selectedCategory: string;
|
||||||
|
setSelectedCategory: (category: string) => void;
|
||||||
|
selectedLevel: string;
|
||||||
|
setSelectedLevel: (level: string) => void;
|
||||||
|
selectedType: string;
|
||||||
|
setSelectedType: (type: string) => void;
|
||||||
|
sortBy: string;
|
||||||
|
setSortBy: (sort: string) => void;
|
||||||
|
showBookmarkedOnly: boolean;
|
||||||
|
setShowBookmarkedOnly: (show: boolean) => void;
|
||||||
|
showInProgressOnly: boolean;
|
||||||
|
setShowInProgressOnly: (show: boolean) => void;
|
||||||
|
userType: 'individual' | 'corporate';
|
||||||
|
totalCourses: number;
|
||||||
|
filteredCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeIcon = (iconName: string) => {
|
||||||
|
switch (iconName) {
|
||||||
|
case 'Globe': return Globe;
|
||||||
|
case 'Video': return Video;
|
||||||
|
case 'FileText': return FileText;
|
||||||
|
case 'Headphones': return Headphones;
|
||||||
|
case 'Monitor': return Monitor;
|
||||||
|
default: return Globe;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LibraryFilters({
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
selectedCategory,
|
||||||
|
setSelectedCategory,
|
||||||
|
selectedLevel,
|
||||||
|
setSelectedLevel,
|
||||||
|
selectedType,
|
||||||
|
setSelectedType,
|
||||||
|
sortBy,
|
||||||
|
setSortBy,
|
||||||
|
showBookmarkedOnly,
|
||||||
|
setShowBookmarkedOnly,
|
||||||
|
showInProgressOnly,
|
||||||
|
setShowInProgressOnly,
|
||||||
|
userType,
|
||||||
|
totalCourses,
|
||||||
|
filteredCount
|
||||||
|
}: LibraryFiltersProps) {
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedCategory !== 'All Categories' ||
|
||||||
|
selectedLevel !== 'All Levels' ||
|
||||||
|
selectedType !== 'all' ||
|
||||||
|
showBookmarkedOnly ||
|
||||||
|
showInProgressOnly ||
|
||||||
|
searchQuery.length > 0;
|
||||||
|
|
||||||
|
const clearAllFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedCategory('All Categories');
|
||||||
|
setSelectedLevel('All Levels');
|
||||||
|
setSelectedType('all');
|
||||||
|
setShowBookmarkedOnly(false);
|
||||||
|
setShowInProgressOnly(false);
|
||||||
|
setSortBy('featured');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-5 w-5" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search courses, instructors, topics..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 text-base min-h-[44px]"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Controls */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-base font-medium">Category</label>
|
||||||
|
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||||
|
<SelectTrigger className="text-base min-h-[44px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category} value={category} className="text-base">
|
||||||
|
{category}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-base font-medium">Level</label>
|
||||||
|
<Select value={selectedLevel} onValueChange={setSelectedLevel}>
|
||||||
|
<SelectTrigger className="text-base min-h-[44px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<SelectItem key={level} value={level} className="text-base">
|
||||||
|
{level}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-base font-medium">Content Type</label>
|
||||||
|
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||||
|
<SelectTrigger className="text-base min-h-[44px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{courseTypes.map((type) => {
|
||||||
|
const Icon = getTypeIcon(type.icon);
|
||||||
|
return (
|
||||||
|
<SelectItem key={type.value} value={type.value} className="text-base">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{type.label}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Options */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-base font-medium">Sort By</label>
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger className="text-base min-h-[44px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="text-base">
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Filter Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-6">
|
||||||
|
<Button
|
||||||
|
variant={showInProgressOnly ? "default" : "outline"}
|
||||||
|
onClick={() => setShowInProgressOnly(!showInProgressOnly)}
|
||||||
|
className={`text-base min-h-[40px] ${
|
||||||
|
showInProgressOnly
|
||||||
|
? 'bg-[#26231A] hover:bg-[#26231A]/90 text-white border-[#26231A]'
|
||||||
|
: 'border-[#26231A]/30 text-[#26231A] hover:bg-[#26231A]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Continue Learning
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={showBookmarkedOnly ? "default" : "outline"}
|
||||||
|
onClick={() => setShowBookmarkedOnly(!showBookmarkedOnly)}
|
||||||
|
className={`text-base min-h-[40px] ${
|
||||||
|
showBookmarkedOnly
|
||||||
|
? 'bg-[#F8C301] hover:bg-[#F8C301]/90 text-[#26231A] border-[#F8C301]'
|
||||||
|
: 'border-[#F8C301]/30 text-[#26231A] hover:bg-[#F8C301]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Bookmarked
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{userType === 'corporate' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="text-base min-h-[40px] border-primary/30 text-primary hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Assigned Courses
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base font-medium">Active Filters:</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
className="text-base h-auto p-1"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{searchQuery && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1 text-base px-3 py-1">
|
||||||
|
Search: "{searchQuery}"
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCategory !== 'All Categories' && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1 text-base px-3 py-1">
|
||||||
|
{selectedCategory}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCategory('All Categories')}
|
||||||
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLevel !== 'All Levels' && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1 text-base px-3 py-1">
|
||||||
|
{selectedLevel}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedLevel('All Levels')}
|
||||||
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedType !== 'all' && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1 text-base px-3 py-1">
|
||||||
|
{courseTypes.find(t => t.value === selectedType)?.label}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedType('all')}
|
||||||
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBookmarkedOnly && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1 text-base px-3 py-1">
|
||||||
|
Bookmarked Only
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowBookmarkedOnly(false)}
|
||||||
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showInProgressOnly && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1 text-base px-3 py-1">
|
||||||
|
In Progress Only
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowInProgressOnly(false)}
|
||||||
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Summary */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-border">
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
Showing {filteredCount} of {totalCourses} courses
|
||||||
|
{hasActiveFilters && filteredCount !== totalCourses && (
|
||||||
|
<span className="ml-2 text-primary font-medium">
|
||||||
|
({totalCourses - filteredCount} filtered out)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
595
src/components/learner/LibraryStats.tsx
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Progress } from '../ui/progress';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
Trophy,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
Award,
|
||||||
|
CheckCircle,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
Star,
|
||||||
|
Play,
|
||||||
|
Bookmark,
|
||||||
|
Search,
|
||||||
|
Zap,
|
||||||
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
Brain,
|
||||||
|
Sparkles,
|
||||||
|
Flame,
|
||||||
|
ChevronRight,
|
||||||
|
Gauge,
|
||||||
|
Activity,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronDown,
|
||||||
|
MoreHorizontal
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Legend
|
||||||
|
} from 'recharts';
|
||||||
|
import { Course } from '../../pages/learner/data/libraryData';
|
||||||
|
|
||||||
|
interface LibraryStatsProps {
|
||||||
|
courses: Course[];
|
||||||
|
userType: 'individual' | 'corporate';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function LibraryStats({ courses, userType }: LibraryStatsProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [selectedTimeRange, setSelectedTimeRange] = useState('December');
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const stats = {
|
||||||
|
totalCourses: courses.length,
|
||||||
|
completedCourses: courses.filter(c => c.status === 'completed').length,
|
||||||
|
inProgressCourses: courses.filter(c => c.status === 'in-progress').length,
|
||||||
|
bookmarkedCourses: courses.filter(c => c.status === 'bookmarked').length,
|
||||||
|
assignedCourses: userType === 'corporate' ? courses.filter(c => c.organizationAssigned).length : 0,
|
||||||
|
overdueCourses: userType === 'corporate' ? courses.filter(c => c.status === 'not-started').length : undefined,
|
||||||
|
completionRate: courses.length > 0 ? (courses.filter(c => c.status === 'completed').length / courses.length) * 100 : 0,
|
||||||
|
averageRating: courses.length > 0 ? courses.reduce((sum, c) => sum + c.rating, 0) / courses.length : 0,
|
||||||
|
totalHours: courses.reduce((sum, c) => sum + parseFloat(c.duration.replace(/[^\d.]/g, '')), 0),
|
||||||
|
certificatesEarned: courses.filter(c => c.status === 'completed').length
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setIsVisible(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Performance chart data (bar chart showing progress over time)
|
||||||
|
const performanceData = [
|
||||||
|
{ name: 'Jan', value: 1, color: '#04045B' },
|
||||||
|
{ name: 'Feb', value: 2, color: '#04045B' },
|
||||||
|
{ name: 'Mar', value: 4, color: '#04045B' },
|
||||||
|
{ name: 'Apr', value: 6, color: '#04045B' },
|
||||||
|
{ name: 'May', value: 7, color: '#04045B' },
|
||||||
|
{ name: 'Jun', value: 8, color: '#04045B' }
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Time learning data (stacked bar chart)
|
||||||
|
const timeLearningData = [
|
||||||
|
{ date: 'Apr 18', performance: 2, consistency: 1, unknown: 0.5 },
|
||||||
|
{ date: 'Apr 19', performance: 3, consistency: 2, unknown: 1 },
|
||||||
|
{ date: 'Apr 20', performance: 1.5, consistency: 2.5, unknown: 0.5 },
|
||||||
|
{ date: 'Apr 21', performance: 4, consistency: 1, unknown: 1 },
|
||||||
|
{ date: 'Apr 22', performance: 2, consistency: 3, unknown: 0.5 },
|
||||||
|
{ date: 'Apr 23', performance: 3.5, consistency: 1.5, unknown: 1 },
|
||||||
|
{ date: 'Apr 24', performance: 2.5, consistency: 2, unknown: 1.5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Course list with progress
|
||||||
|
const courseList = [
|
||||||
|
{ name: 'Introduction to Strategic Leadership', lessons: '24/30 Lessons', progress: 80, color: '#04045B' },
|
||||||
|
{ name: 'English for Effective Communication', lessons: '18/25 Lessons', progress: 72, color: '#10B981' },
|
||||||
|
{ name: 'Introduction to Team Management', lessons: '14/20 Lessons', progress: 70, color: '#F8C301' },
|
||||||
|
{ name: 'Introduction to Digital Leadership', lessons: '8/15 Lessons', progress: 53, color: '#6366F1' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Daily activity timeline
|
||||||
|
const dailyActivities = [
|
||||||
|
{ time: '07:00', course: 'Introduction to Strategic Leadership', type: 'Google Meeting', color: '#F8C301', instructor: 'A' },
|
||||||
|
{ time: '08:00', course: '', type: '', color: '', instructor: '' },
|
||||||
|
{ time: '09:00', course: 'English for Effective Communication', type: 'Google Meeting', color: '#3B82F6', instructor: 'E' },
|
||||||
|
{ time: '10:00', course: '', type: '', color: '', instructor: '' },
|
||||||
|
{ time: '11:00', course: '', type: '', color: '', instructor: '' },
|
||||||
|
{ time: '12:00', course: 'Introduction to Digital Leadership', type: 'Google Meeting', color: '#6366F1', instructor: 'I' },
|
||||||
|
{ time: '01:00', course: '', type: '', color: '', instructor: '' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Month navigation functionality
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
|
||||||
|
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||||
|
const currentIndex = months.findIndex(month => month === selectedTimeRange);
|
||||||
|
let newIndex;
|
||||||
|
|
||||||
|
if (direction === 'prev') {
|
||||||
|
newIndex = currentIndex <= 0 ? months.length - 1 : currentIndex - 1;
|
||||||
|
} else {
|
||||||
|
newIndex = currentIndex >= months.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTimeRange(months[newIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Throttle wheel events to prevent rapid scrolling
|
||||||
|
const now = Date.now();
|
||||||
|
const lastWheelTime = (e.currentTarget as any)._lastWheelTime || 0;
|
||||||
|
|
||||||
|
if (now - lastWheelTime < 200) return; // 200ms throttle
|
||||||
|
|
||||||
|
(e.currentTarget as any)._lastWheelTime = now;
|
||||||
|
|
||||||
|
if (e.deltaY > 0) {
|
||||||
|
navigateMonth('next');
|
||||||
|
} else {
|
||||||
|
navigateMonth('prev');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 transition-all duration-500 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
|
{/* Dashboard Grid Layout - Matching Reference Image */}
|
||||||
|
<div className="grid grid-cols-12 gap-4">
|
||||||
|
|
||||||
|
{/* Top Row */}
|
||||||
|
|
||||||
|
{/* Simplified Performance Chart */}
|
||||||
|
<div className="col-span-12 lg:col-span-6">
|
||||||
|
<Card className="border-0 shadow-md bg-white h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-[16px] font-semibold text-[#111827]">
|
||||||
|
Performance
|
||||||
|
</CardTitle>
|
||||||
|
<MoreHorizontal className="h-4 w-4 text-[#6B7280]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[14px] text-[#6B7280]">
|
||||||
|
{stats.completedCourses} Course Completed
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0 pb-4">
|
||||||
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
|
<BarChart data={performanceData} margin={{ top: 15, right: 15, left: 15, bottom: 15 }}>
|
||||||
|
<defs>
|
||||||
|
<filter id="performanceShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2" floodColor="#04045B" floodOpacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" opacity={0.4} />
|
||||||
|
<Bar
|
||||||
|
dataKey="value"
|
||||||
|
fill="#04045B"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={24}
|
||||||
|
filter="url(#performanceShadow)"
|
||||||
|
className="hover:opacity-80 transition-opacity duration-200"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 13, fill: '#6B7280', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 11, fill: '#9CA3AF' }}
|
||||||
|
width={25}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={false}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid #E5E7EB',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
padding: '12px'
|
||||||
|
}}
|
||||||
|
formatter={(value) => [`${value} courses`, 'Completed']}
|
||||||
|
labelStyle={{ color: '#111827', fontWeight: 600, marginBottom: '4px' }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simplified Time Learning Chart */}
|
||||||
|
<div className="col-span-12 lg:col-span-6">
|
||||||
|
<Card className="border-0 shadow-md bg-white h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-[16px] font-semibold text-[#111827] mb-1">
|
||||||
|
Time spend on learning
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-[14px] text-[#6B7280]">
|
||||||
|
4 Course Completed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer select-none hover:bg-gray-50 rounded-lg px-3 py-2 transition-colors duration-200"
|
||||||
|
onWheel={handleWheel}
|
||||||
|
title="Scroll to change month or click arrows"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => navigateMonth('prev')}
|
||||||
|
className="h-4 w-4 text-[#6B7280] hover:text-[#04045B] transition-colors duration-200 flex items-center justify-center"
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-[14px] font-medium text-[#111827] min-w-[70px] text-center transition-all duration-200">
|
||||||
|
{selectedTimeRange}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigateMonth('next')}
|
||||||
|
className="h-4 w-4 text-[#6B7280] hover:text-[#04045B] transition-colors duration-200 flex items-center justify-center"
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0 pb-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<BarChart data={timeLearningData} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
|
<defs>
|
||||||
|
<filter id="stackShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000000" floodOpacity="0.2"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" opacity={0.3} />
|
||||||
|
<Bar
|
||||||
|
dataKey="performance"
|
||||||
|
stackId="a"
|
||||||
|
fill="#04045B"
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
maxBarSize={28}
|
||||||
|
filter="url(#stackShadow)"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="consistency"
|
||||||
|
stackId="a"
|
||||||
|
fill="#6366F1"
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
maxBarSize={28}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="unknown"
|
||||||
|
stackId="a"
|
||||||
|
fill="#E5E7EB"
|
||||||
|
radius={[3, 3, 0, 0]}
|
||||||
|
maxBarSize={28}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: '#6B7280', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 10, fill: '#9CA3AF' }}
|
||||||
|
width={25}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={false}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid #E5E7EB',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
padding: '12px'
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#111827', fontWeight: 600, marginBottom: '4px' }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enhanced Legend */}
|
||||||
|
<div className="flex items-center justify-center gap-6 text-[14px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-sm bg-[#04045B] shadow-sm"></div>
|
||||||
|
<span className="text-[#6B7280] font-medium">Performance</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-sm bg-[#6366F1] shadow-sm"></div>
|
||||||
|
<span className="text-[#6B7280] font-medium">Consistency</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-sm bg-[#E5E7EB] shadow-sm"></div>
|
||||||
|
<span className="text-[#6B7280] font-medium">Other</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Row */}
|
||||||
|
|
||||||
|
{/* Enhanced Your Courses (7 columns) */}
|
||||||
|
<div className="col-span-12 lg:col-span-7">
|
||||||
|
<Card className="border-0 shadow-md bg-white h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-[16px] font-semibold text-[#111827]">
|
||||||
|
Your Courses
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-[14px] text-[#6B7280]">
|
||||||
|
{courseList.length} Course{courseList.length !== 1 ? 's' : ''} • {courseList.filter(c => c.progress === 100).length} Completed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-[14px] text-[#6B7280] hover:text-[#04045B] h-auto p-2 rounded-lg transition-colors"
|
||||||
|
onClick={() => window.location.href = '/library?view=individual'}
|
||||||
|
>
|
||||||
|
<span className="mr-1">View All</span>
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0 pb-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{courseList.map((course, index) => {
|
||||||
|
const isCompleted = course.progress === 100;
|
||||||
|
const isInProgress = course.progress > 0 && course.progress < 100;
|
||||||
|
const isNotStarted = course.progress === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="group relative flex items-center gap-4 p-3 hover:bg-gray-50 hover:shadow-sm rounded-lg transition-all duration-200 cursor-pointer border border-transparent hover:border-gray-200"
|
||||||
|
onClick={() => window.location.href = '/course?view=individual'}
|
||||||
|
>
|
||||||
|
{/* Enhanced Course Avatar */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-[14px] font-semibold shadow-sm"
|
||||||
|
style={{ backgroundColor: course.color }}
|
||||||
|
>
|
||||||
|
{course.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
{/* Status Indicator */}
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-[#21A36A] rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-2 h-2 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isInProgress && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-[#F8C301] rounded-full animate-pulse"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Information */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<h4 className="text-[16px] font-medium text-[#111827] truncate group-hover:text-[#04045B] transition-colors">
|
||||||
|
{course.name}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<span className="text-[14px] font-medium text-[#111827]">
|
||||||
|
{course.progress}%
|
||||||
|
</span>
|
||||||
|
{isCompleted && (
|
||||||
|
<Badge variant="outline" className="text-[12px] bg-[#21A36A]/10 text-[#21A36A] border-[#21A36A]/20 px-2 py-0.5">
|
||||||
|
Complete
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-2">
|
||||||
|
<span className="text-[14px] text-[#6B7280]">
|
||||||
|
{course.lessons}
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] text-[#6B7280]">
|
||||||
|
• {isCompleted ? 'Completed' : isInProgress ? 'In Progress' : 'Not Started'}
|
||||||
|
</span>
|
||||||
|
{isInProgress && (
|
||||||
|
<span className="text-[14px] text-[#F8C301] font-medium">
|
||||||
|
• Continue Learning
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enhanced Progress Bar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all duration-700 ease-out relative"
|
||||||
|
style={{
|
||||||
|
width: `${course.progress}%`,
|
||||||
|
backgroundColor: course.color
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Progress bar shine effect */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{isCompleted ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-[14px] border-[#21A36A]/20 text-[#21A36A] hover:bg-[#21A36A]/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.location.href = '/course?view=individual';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Award className="w-3 h-3 mr-1" />
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
) : isInProgress ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-[14px] border-[#F8C301]/30 text-[#04045B] hover:bg-[#F8C301]/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.location.href = '/course?view=individual';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3 mr-1 stroke-[#04045B]" />
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-[14px] border-[#04045B]/20 text-[#04045B] hover:bg-[#04045B]/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.location.href = '/course?view=individual';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookOpen className="w-3 h-3 mr-1" />
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Arrow */}
|
||||||
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<ArrowRight className="h-4 w-4 text-[#6B7280]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Quick Action Footer */}
|
||||||
|
<div className="pt-2 mt-4 border-t border-gray-100">
|
||||||
|
<div className="flex items-center justify-between text-[14px]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-[#21A36A] rounded-full"></div>
|
||||||
|
<span className="text-[#6B7280]">Completed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-[#F8C301] rounded-full"></div>
|
||||||
|
<span className="text-[#6B7280]">In Progress</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-gray-300 rounded-full"></div>
|
||||||
|
<span className="text-[#6B7280]">Not Started</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[14px] text-[#6B7280] hover:text-[#04045B] h-auto p-1"
|
||||||
|
onClick={() => window.location.href = '/library?view=individual'}
|
||||||
|
>
|
||||||
|
<Target className="w-3 h-3 mr-1" />
|
||||||
|
Browse Library
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Activity (5 columns) */}
|
||||||
|
<div className="col-span-12 lg:col-span-5">
|
||||||
|
<Card className="border-0 shadow-md bg-white h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-[16px] font-semibold text-[#111827]">
|
||||||
|
Daily activity
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-[14px] text-[#6B7280]">
|
||||||
|
Today Apr 24
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0 pb-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dailyActivities.map((activity, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-3">
|
||||||
|
<div className="text-[12px] text-[#6B7280] w-10 text-right">
|
||||||
|
{activity.time}
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-gray-200 relative">
|
||||||
|
{activity.course && (
|
||||||
|
<div className="absolute -left-1 top-1/2 transform -translate-y-1/2 w-2 h-2 rounded-full" style={{ backgroundColor: activity.color }}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{activity.course ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[14px] font-medium text-[#111827] truncate">
|
||||||
|
{activity.course}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-[#6B7280]">
|
||||||
|
{activity.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-[10px] font-semibold flex-shrink-0" style={{ backgroundColor: activity.color }}>
|
||||||
|
{activity.instructor}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-8"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
417
src/components/learner/UpcomingEvents.tsx
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Video,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
CalendarDays,
|
||||||
|
MapPin,
|
||||||
|
Globe,
|
||||||
|
ExternalLink
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { navigateTo } from '../Router';
|
||||||
|
|
||||||
|
interface UpcomingEventsProps {
|
||||||
|
userType: 'individual' | 'corporate';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
duration: string;
|
||||||
|
type: 'webinar' | 'workshop' | 'assessment' | 'meeting';
|
||||||
|
status: 'upcoming' | 'today' | 'live';
|
||||||
|
instructor: string;
|
||||||
|
category: string;
|
||||||
|
location: string;
|
||||||
|
attendees?: number;
|
||||||
|
maxAttendees?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpcomingEvents({ userType }: UpcomingEventsProps) {
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
|
||||||
|
// Sample events data
|
||||||
|
const events: Event[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Strategic Leadership in Digital Transformation',
|
||||||
|
date: '2024-12-28',
|
||||||
|
time: '2:00 PM',
|
||||||
|
duration: '90 min',
|
||||||
|
type: 'webinar',
|
||||||
|
status: 'live',
|
||||||
|
instructor: 'Dr. Rajesh Kumar',
|
||||||
|
category: 'Leadership',
|
||||||
|
location: 'online',
|
||||||
|
attendees: 145,
|
||||||
|
maxAttendees: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Team Management Best Practices',
|
||||||
|
date: '2024-12-28',
|
||||||
|
time: '4:00 PM',
|
||||||
|
duration: '60 min',
|
||||||
|
type: 'workshop',
|
||||||
|
status: 'today',
|
||||||
|
instructor: 'Sarah Mitchell',
|
||||||
|
category: 'Team Management',
|
||||||
|
location: 'online',
|
||||||
|
attendees: 89,
|
||||||
|
maxAttendees: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Leadership Assessment Review',
|
||||||
|
date: '2024-12-30',
|
||||||
|
time: '10:00 AM',
|
||||||
|
duration: '45 min',
|
||||||
|
type: 'assessment',
|
||||||
|
status: 'upcoming',
|
||||||
|
instructor: 'Prof. Michael Chen',
|
||||||
|
category: 'Assessment',
|
||||||
|
location: 'Conference Room A',
|
||||||
|
attendees: 12,
|
||||||
|
maxAttendees: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Digital Leadership Fundamentals',
|
||||||
|
date: '2025-01-02',
|
||||||
|
time: '11:00 AM',
|
||||||
|
duration: '2 hours',
|
||||||
|
type: 'webinar',
|
||||||
|
status: 'upcoming',
|
||||||
|
instructor: 'Dr. Lisa Anderson',
|
||||||
|
category: 'Digital Leadership',
|
||||||
|
location: 'online',
|
||||||
|
attendees: 78,
|
||||||
|
maxAttendees: 200
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
|
||||||
|
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||||
|
setCurrentMonth(prevDate => {
|
||||||
|
const newDate = new Date(prevDate);
|
||||||
|
if (direction === 'prev') {
|
||||||
|
newDate.setMonth(newDate.getMonth() - 1);
|
||||||
|
} else {
|
||||||
|
newDate.setMonth(newDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
return newDate;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date) => {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (date: Date) => {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (date: Date, day: number) => {
|
||||||
|
const today = new Date();
|
||||||
|
return date.getFullYear() === today.getFullYear() &&
|
||||||
|
date.getMonth() === today.getMonth() &&
|
||||||
|
day === today.getDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasEvent = (date: Date, day: number) => {
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return events.some(event => event.date === dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCalendar = () => {
|
||||||
|
const daysInMonth = getDaysInMonth(currentMonth);
|
||||||
|
const firstDay = getFirstDayOfMonth(currentMonth);
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// Empty cells for days before the first day of the month
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(
|
||||||
|
<div key={`empty-${i}`} className="h-8"></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days of the month
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const today = isToday(currentMonth, day);
|
||||||
|
const eventDay = hasEvent(currentMonth, day);
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={`h-8 flex items-center justify-center text-[14px] font-medium rounded-md cursor-pointer transition-colors ${
|
||||||
|
today
|
||||||
|
? 'bg-[#F8C301] text-[#26231A] font-semibold'
|
||||||
|
: eventDay
|
||||||
|
? 'bg-[#04045B]/10 text-[#04045B] hover:bg-[#04045B]/20'
|
||||||
|
: 'text-[#6B7280] hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter events for current month
|
||||||
|
const currentMonthEvents = events.filter(event => {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
return eventDate.getMonth() === currentMonth.getMonth() &&
|
||||||
|
eventDate.getFullYear() === currentMonth.getFullYear();
|
||||||
|
}).slice(0, 3); // Show only first 3 events
|
||||||
|
|
||||||
|
const getEventIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'webinar':
|
||||||
|
return Video;
|
||||||
|
case 'workshop':
|
||||||
|
return Users;
|
||||||
|
case 'assessment':
|
||||||
|
return CalendarDays;
|
||||||
|
case 'meeting':
|
||||||
|
return Calendar;
|
||||||
|
default:
|
||||||
|
return Calendar;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'live':
|
||||||
|
return 'bg-red-50 border-red-200 hover:bg-red-100';
|
||||||
|
case 'today':
|
||||||
|
return 'bg-amber-50 border-amber-200 hover:bg-amber-100';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-50 border-gray-200 hover:bg-gray-100';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Mini Calendar - Distinct White Card */}
|
||||||
|
<Card className="bg-white border-gray-200 shadow-lg hover:shadow-xl transition-all duration-300 rounded-lg">
|
||||||
|
<CardHeader className="pb-4 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-[16px] font-semibold text-[#111827]">
|
||||||
|
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigateMonth('prev')}
|
||||||
|
className="h-8 w-8 text-[#6B7280] hover:text-[#111827] hover:bg-gray-100 rounded-md"
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigateMonth('next')}
|
||||||
|
className="h-8 w-8 text-[#6B7280] hover:text-[#111827] hover:bg-gray-100 rounded-md"
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="grid grid-cols-7 gap-1 mb-3">
|
||||||
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||||
|
<div key={day} className="h-8 flex items-center justify-center text-[12px] font-medium text-[#6B7280]">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{renderCalendar()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Upcoming Events - Distinct White Card */}
|
||||||
|
<Card className="bg-white border-gray-200 shadow-lg hover:shadow-xl transition-all duration-300 rounded-lg">
|
||||||
|
<CardHeader className="pb-4 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-[16px] font-semibold flex items-center gap-2 text-[#111827]">
|
||||||
|
<div className="w-5 h-5 rounded-md bg-[#04045B] flex items-center justify-center">
|
||||||
|
<Calendar className="h-3 w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
Upcoming Events
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigateTo(`/webinars?view=${userType}`)}
|
||||||
|
className="text-[14px] text-[#6B7280] hover:text-[#111827] hover:bg-gray-100 rounded-md h-8 px-2"
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
<ExternalLink className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
{currentMonthEvents.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-gray-100 flex items-center justify-center mx-auto mb-3">
|
||||||
|
<CalendarDays className="h-6 w-6 text-[#6B7280]" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[14px] text-[#6B7280] font-medium">No events scheduled for this month</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
currentMonthEvents.map((event) => {
|
||||||
|
const Icon = getEventIcon(event.type);
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
const isToday = eventDate.toDateString() === new Date().toDateString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className={`p-3 rounded-lg border transition-all duration-300 cursor-pointer hover:shadow-md ${getEventColor(event.status)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`p-2 rounded-full flex-shrink-0 ${
|
||||||
|
event.status === 'live'
|
||||||
|
? 'bg-red-100 text-red-600'
|
||||||
|
: event.status === 'today'
|
||||||
|
? 'bg-amber-100 text-amber-700'
|
||||||
|
: 'bg-[#04045B]/10 text-[#04045B]'
|
||||||
|
}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<h4 className="text-[14px] font-semibold line-clamp-2 text-[#111827]">{event.title}</h4>
|
||||||
|
{event.status === 'live' && (
|
||||||
|
<Badge variant="destructive" className="text-[10px] flex-shrink-0 font-semibold">
|
||||||
|
LIVE
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isToday && event.status !== 'live' && (
|
||||||
|
<Badge className="text-[10px] flex-shrink-0 bg-[#F8C301] text-[#26231A] hover:bg-[#F8C301]/90 font-semibold">
|
||||||
|
Today
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-[12px] text-[#6B7280]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span className="font-medium">{event.time} • {event.duration}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{event.location === 'online' ? (
|
||||||
|
<>
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
<span className="font-medium">Online</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span className="line-clamp-1 font-medium">{event.location}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.attendees && event.maxAttendees && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
<span className="font-medium">{event.attendees}/{event.maxAttendees} attending</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-[12px]">
|
||||||
|
<span className="font-semibold text-[#111827]">{event.instructor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[10px] font-medium border ${
|
||||||
|
event.category === 'Leadership' ? 'border-[#04045B]/30 text-[#04045B] bg-[#04045B]/5' :
|
||||||
|
event.category === 'Digital Leadership' ? 'border-[#F8C301]/30 text-[#8A6A00] bg-[#F8C301]/10' :
|
||||||
|
event.category === 'Team Management' ? 'border-[#10B981]/30 text-[#10B981] bg-[#10B981]/5' :
|
||||||
|
'border-[#6B7280]/30 text-[#6B7280] bg-[#6B7280]/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.category}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={event.status === 'live' ? 'destructive' : event.status === 'today' ? 'default' : 'outline'}
|
||||||
|
className="text-[12px] font-semibold min-h-[28px] px-3 rounded-md"
|
||||||
|
onClick={() => navigateTo(`/webinars?view=${userType}`)}
|
||||||
|
>
|
||||||
|
{event.status === 'live' ? 'Join Now' : 'View Details'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Actions - Distinct White Card */}
|
||||||
|
<Card className="bg-white border-gray-200 shadow-lg hover:shadow-xl transition-all duration-300 rounded-lg">
|
||||||
|
<CardHeader className="pb-4 border-b border-gray-100">
|
||||||
|
<CardTitle className="text-[16px] font-semibold text-[#111827]">Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-[14px] font-medium min-h-[40px] bg-gray-50 border-gray-200 hover:bg-gray-100 transition-colors duration-200 rounded-md"
|
||||||
|
onClick={() => navigateTo(`/webinars?view=${userType}`)}
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4 mr-3" />
|
||||||
|
Browse All Webinars
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-[14px] font-medium min-h-[40px] bg-gray-50 border-gray-200 hover:bg-gray-100 transition-colors duration-200 rounded-md"
|
||||||
|
onClick={() => navigateTo(`/surveys?view=${userType}`)}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-4 w-4 mr-3" />
|
||||||
|
Schedule Assessment
|
||||||
|
</Button>
|
||||||
|
{userType === 'corporate' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-[14px] font-medium min-h-[40px] bg-gray-50 border-gray-200 hover:bg-gray-100 transition-colors duration-200 rounded-md"
|
||||||
|
onClick={() => navigateTo(`/dashboard?view=${userType}`)}
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4 mr-3" />
|
||||||
|
Team Calendar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
src/components/services/EnhancedServiceSections.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent } from '../ui/card';
|
||||||
|
import { TrendingUp, Target, Users, Brain, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
// Icon mapping for dynamic rendering
|
||||||
|
const iconMap = {
|
||||||
|
Target, Users, CheckCircle, Brain, TrendingUp
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// What We Offer Section - Card-based Layout
|
||||||
|
interface OfferingItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: keyof typeof iconMap;
|
||||||
|
features: string[];
|
||||||
|
highlighted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnhancedOfferingsProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
offerings: OfferingItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnhancedOfferings({ title, subtitle, offerings }: EnhancedOfferingsProps) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 lg:py-24 bg-background">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="text-center mb-16 lg:mb-20 max-w-4xl mx-auto">
|
||||||
|
<div className="text-base font-medium text-muted-foreground uppercase tracking-wider mb-4">
|
||||||
|
SERVICES
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl lg:text-4xl xl:text-5xl font-bold text-foreground mb-6 leading-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Grid Layout */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 lg:gap-8 max-w-7xl mx-auto">
|
||||||
|
{offerings.map((offering, index) => {
|
||||||
|
const Icon = iconMap[offering.icon];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className={`group h-full border transition-all duration-300 hover:shadow-lg ${
|
||||||
|
offering.highlighted
|
||||||
|
? 'border-primary/30 bg-primary/[0.02] hover:border-primary/50'
|
||||||
|
: 'border-border hover:border-primary/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 h-full flex flex-col">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary/15 transition-colors duration-300">
|
||||||
|
<Icon className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-lg lg:text-xl font-bold text-foreground mb-3 leading-tight group-hover:text-primary transition-colors duration-300">
|
||||||
|
{offering.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-base text-muted-foreground leading-relaxed mb-6 flex-grow">
|
||||||
|
{offering.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{offering.features.slice(0, 3).map((feature, featureIndex) => (
|
||||||
|
<div key={featureIndex} className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-base text-muted-foreground leading-relaxed">
|
||||||
|
{feature}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Who It's For Section - Card-based Layout
|
||||||
|
interface AudienceItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: keyof typeof iconMap;
|
||||||
|
characteristics: string[];
|
||||||
|
highlight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnhancedAudienceProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
audiences: AudienceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnhancedAudience({ title, subtitle, audiences }: EnhancedAudienceProps) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 lg:py-24 bg-muted/30">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="text-center mb-16 lg:mb-20 max-w-4xl mx-auto">
|
||||||
|
<div className="text-base font-medium text-muted-foreground uppercase tracking-wider mb-4">
|
||||||
|
TARGET AUDIENCE
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl lg:text-4xl xl:text-5xl font-bold text-foreground mb-6 leading-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Grid Layout */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6 lg:gap-8 max-w-6xl mx-auto">
|
||||||
|
{audiences.map((audience, index) => {
|
||||||
|
const Icon = iconMap[audience.icon];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className="group h-full border border-border hover:border-primary/30 hover:shadow-lg transition-all duration-300 bg-background"
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 h-full flex flex-col">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center mb-6 group-hover:bg-primary/15 transition-colors duration-300">
|
||||||
|
<Icon className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl lg:text-2xl font-bold text-foreground mb-4 leading-tight group-hover:text-primary transition-colors duration-300">
|
||||||
|
{audience.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-base text-muted-foreground leading-relaxed mb-6 flex-grow">
|
||||||
|
{audience.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Highlight */}
|
||||||
|
{audience.highlight && (
|
||||||
|
<div className="bg-secondary/10 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-base font-medium text-secondary-foreground">
|
||||||
|
{audience.highlight}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Characteristics */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{audience.characteristics.map((characteristic, charIndex) => (
|
||||||
|
<div key={charIndex} className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-base text-muted-foreground leading-relaxed">
|
||||||
|
{characteristic}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected Outcomes Section - Card-based Layout
|
||||||
|
interface OutcomeItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: keyof typeof iconMap;
|
||||||
|
metrics: string;
|
||||||
|
percentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnhancedOutcomesProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
outcomes: OutcomeItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnhancedOutcomes({ title, subtitle, outcomes }: EnhancedOutcomesProps) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 lg:py-24 bg-background">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="text-center mb-16 lg:mb-20 max-w-4xl mx-auto">
|
||||||
|
<div className="text-base font-medium text-muted-foreground uppercase tracking-wider mb-4">
|
||||||
|
EXPECTED RESULTS
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl lg:text-4xl xl:text-5xl font-bold text-foreground mb-6 leading-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Grid Layout */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6 lg:gap-8 max-w-6xl mx-auto">
|
||||||
|
{outcomes.map((outcome, index) => {
|
||||||
|
const Icon = iconMap[outcome.icon];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className="group h-full border border-border hover:border-primary/30 hover:shadow-lg transition-all duration-300"
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 h-full flex flex-col">
|
||||||
|
{/* Header with Icon and Percentage */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center group-hover:bg-primary/15 transition-colors duration-300">
|
||||||
|
<Icon className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{outcome.percentage && (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl lg:text-3xl font-bold text-success">
|
||||||
|
{outcome.percentage}%
|
||||||
|
</div>
|
||||||
|
<div className="text-base text-muted-foreground">Success Rate</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl lg:text-2xl font-bold text-foreground mb-4 leading-tight group-hover:text-primary transition-colors duration-300">
|
||||||
|
{outcome.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-base text-muted-foreground leading-relaxed mb-6 flex-grow">
|
||||||
|
{outcome.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Key Metric */}
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-success" />
|
||||||
|
<span className="text-base font-medium text-success">Key Metric</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-semibold text-foreground">
|
||||||
|
{outcome.metrics}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our Approach Section - Card-based Layout
|
||||||
|
interface ApproachStep {
|
||||||
|
step: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
details: string[];
|
||||||
|
icon: keyof typeof iconMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnhancedApproachProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
methodologyNote: string;
|
||||||
|
methodologyIcon: keyof typeof iconMap;
|
||||||
|
steps: ApproachStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnhancedApproach({ title, subtitle, methodologyNote, methodologyIcon, steps }: EnhancedApproachProps) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 lg:py-24 bg-muted/30">
|
||||||
|
<div className="container mx-auto px-4 lg:px-8">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="text-center mb-16 lg:mb-20 max-w-4xl mx-auto">
|
||||||
|
<div className="text-base font-medium text-muted-foreground uppercase tracking-wider mb-4">
|
||||||
|
METHODOLOGY
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl lg:text-4xl xl:text-5xl font-bold text-foreground mb-6 leading-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto mb-8">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Methodology Note */}
|
||||||
|
<div className="inline-flex items-center gap-3 px-6 py-3 bg-primary/5 rounded-lg border border-primary/10">
|
||||||
|
<span className="text-base font-medium text-primary">{methodologyNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Grid Layout */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8 max-w-7xl mx-auto">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const Icon = iconMap[step.icon];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className="group h-full border border-border hover:border-primary/30 hover:shadow-lg transition-all duration-300 bg-background"
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 h-full flex flex-col">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center mb-6 group-hover:bg-primary/15 transition-colors duration-300">
|
||||||
|
<Icon className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl lg:text-2xl font-bold text-foreground mb-4 leading-tight group-hover:text-primary transition-colors duration-300">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-base text-muted-foreground leading-relaxed mb-6 flex-grow">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Details List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{step.details.map((detail, detailIndex) => (
|
||||||
|
<div key={detailIndex} className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-base text-muted-foreground leading-relaxed">
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion@1.2.3";
|
||||||
|
import { ChevronDownIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
157
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog@1.1.6";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { buttonVariants } from "./button";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
11
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio@1.1.2";
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio };
|
||||||
53
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar@1.1.3";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
109
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9 rounded-md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Button = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
75
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react@0.487.0";
|
||||||
|
import { DayPicker } from "react-day-picker@8.10.1";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { buttonVariants } from "./button";
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker>) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row gap-2",
|
||||||
|
month: "flex flex-col gap-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "flex items-center gap-1",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-x-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: cn(
|
||||||
|
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||||
|
props.mode === "range"
|
||||||
|
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||||
|
: "[&:has([aria-selected])]:rounded-md",
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"size-8 p-0 font-normal aria-selected:opacity-100",
|
||||||
|
),
|
||||||
|
day_range_start:
|
||||||
|
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_range_end:
|
||||||
|
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ className, ...props }) => (
|
||||||
|
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
IconRight: ({ className, ...props }) => (
|
||||||
|
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-lg border",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<h4
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6 [&:last-child]:pb-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
241
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react@8.6.0";
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
scrollPrev: () => void;
|
||||||
|
scrollNext: () => void;
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
canScrollNext: boolean;
|
||||||
|
} & CarouselProps;
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return;
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return;
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
onSelect(api);
|
||||||
|
api.on("reInit", onSelect);
|
||||||
|
api.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
};
|
||||||
353
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts@2.15.2";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"];
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
32
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox@1.1.4";
|
||||||
|
import { CheckIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
33
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible@1.1.3";
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
177
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk@1.1.1";
|
||||||
|
import { SearchIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "./dialog";
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
252
src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu@2.2.6";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
138
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog@1.1.6";
|
||||||
|
import { XIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||||
|
>((props, ref) => (
|
||||||
|
<DialogPrimitive.Trigger ref={ref} data-slot="dialog-trigger" {...props} />
|
||||||
|
))
|
||||||
|
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||||
|
>((props, ref) => (
|
||||||
|
<DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />
|
||||||
|
))
|
||||||
|
DialogClose.displayName = DialogPrimitive.Close.displayName
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-xl leading-tight font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-base", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
132
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul@1.1.2";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
};
|
||||||
258
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu@2.1.6";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||||
|
React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>
|
||||||
|
>(({ ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
DropdownMenuTrigger.displayName = "DropdownMenuTrigger";
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}
|
||||||
|
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
168
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
|
||||||
|
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form@7.55.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { Label } from "./label";
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState } = useFormContext();
|
||||||
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
};
|
||||||
44
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card@1.1.6";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||||
77
src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp@1.4.2";
|
||||||
|
import { MinusIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
276
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as MenubarPrimitive from "@radix-ui/react-menubar@1.1.6";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Menubar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn(
|
||||||
|
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||||
|
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||||
|
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||||
|
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<MenubarPortal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||||
|
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
};
|
||||||
168
src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu@1.2.5";
|
||||||
|
import { cva } from "class-variance-authority@0.7.1";
|
||||||
|
import { ChevronDownIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
};
|
||||||
127
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { Button, buttonVariants } from "./button";
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
};
|
||||||
48
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover@1.1.6";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress@1.1.2";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
45
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group@1.2.3";
|
||||||
|
import { CircleIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
56
src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { GripVerticalIcon } from "lucide-react@0.487.0";
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels@2.1.7";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function ResizablePanelGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizablePanel({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||||
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||||
|
<GripVerticalIcon className="size-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
58
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area@1.2.3";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
189
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select@2.1.6";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
} from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator@1.1.2";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator-root"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
142
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog@1.1.6";
|
||||||
|
import { XIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SheetTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Trigger>,
|
||||||
|
React.ComponentProps<typeof SheetPrimitive.Trigger>
|
||||||
|
>(({ ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />
|
||||||
|
));
|
||||||
|
|
||||||
|
SheetTrigger.displayName = "SheetTrigger";
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
726
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot@1.1.2";
|
||||||
|
import { VariantProps, cva } from "class-variance-authority@0.7.1";
|
||||||
|
import { PanelLeftIcon } from "lucide-react@0.487.0";
|
||||||
|
|
||||||
|
import { useIsMobile } from "./use-mobile";
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { Input } from "./input";
|
||||||
|
import { Separator } from "./separator";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "./sheet";
|
||||||
|
import { Skeleton } from "./skeleton";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "./tooltip";
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
|
const SIDEBAR_WIDTH = "16rem";
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed";
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
openMobile: boolean;
|
||||||
|
setOpenMobile: (open: boolean) => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||||
|
const open = openProp ?? _open;
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value;
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState);
|
||||||
|
} else {
|
||||||
|
_setOpen(openState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
|
},
|
||||||
|
[setOpenProp, open],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||||
|
}, [isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed";
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right";
|
||||||
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("size-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
toggleSidebar();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
showOnHover?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean;
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
isActive?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
};
|
||||||
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
63
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider@1.2.3";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
25
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes@0.4.6";
|
||||||
|
import { Toaster as Sonner, ToasterProps } from "sonner@2.0.3";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
31
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch@1.1.3";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
116
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
66
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs@1.1.3";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
73
src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group@1.1.2";
|
||||||
|
import { type VariantProps } from "class-variance-authority@0.7.1";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { toggleVariants } from "./toggle";
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem };
|
||||||