Initial import

This commit is contained in:
Flatlogic Bot 2026-05-26 10:54:22 +00:00
commit 44de1ad005
91 changed files with 17577 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

22
components.json Normal file
View File

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

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BeautyHub - لوحة إدارة الصالون</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Tajawal:wght@200;300;400;500;700;800;900&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31
info.md Normal file
View File

@ -0,0 +1,31 @@
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
Tailwind CSS has been set up with the shadcn theme
Setup complete: /mnt/agents/output/app
Components (40+):
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
navigation-menu, pagination, popover, progress, radio-group, resizable,
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
Usage:
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
Structure:
src/sections/ Page sections
src/hooks/ Custom hooks
src/types/ Type definitions
src/App.css Styles specific to the Webapp
src/App.tsx Root React component
src/index.css Global styles
src/main.tsx Entry point for rendering the Webapp
index.html Entry point for the Webapp
tailwind.config.js Configures Tailwind's theme, plugins, etc.
vite.config.ts Main build and dev server settings for Vite
postcss.config.js Config file for CSS post-processing tools

8326
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

82
package.json Normal file
View File

@ -0,0 +1,82 @@
{
"name": "my-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@faker-js/faker": "^10.4.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.3.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"react-router": "^7.6.1",
"react-router-dom": "^7.15.1",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

6
postcss.config.js Normal file
View File

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

42
src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

30
src/App.tsx Normal file
View File

@ -0,0 +1,30 @@
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import Bookings from './pages/Bookings';
import Services from './pages/Services';
import Clients from './pages/Clients';
import Staff from './pages/Staff';
import Inventory from './pages/Inventory';
import Store from './pages/Store';
import Engagement from './pages/Engagement';
import HealthRecords from './pages/HealthRecords';
import Reports from './pages/Reports';
import Settings from './pages/Settings';
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/services" element={<Services />} />
<Route path="/clients" element={<Clients />} />
<Route path="/staff" element={<Staff />} />
<Route path="/inventory" element={<Inventory />} />
<Route path="/store" element={<Store />} />
<Route path="/engagement" element={<Engagement />} />
<Route path="/health" element={<HealthRecords />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
);
}

View File

@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import Sidebar from './Sidebar';
import TopBar from './TopBar';
interface PageLayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
export default function PageLayout({ children, title, subtitle }: PageLayoutProps) {
return (
<div className="min-h-screen bg-cream" dir="rtl">
<Sidebar />
<div className="mr-[260px] min-h-screen flex flex-col">
<TopBar title={title} subtitle={subtitle} />
<main className="flex-1 p-6">
<div className="animate-fade-in-up">
{children}
</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard, CalendarDays, Sparkles, Users, UserCog,
Package, ShoppingBag, MessageCircle, BarChart3,
Settings, LogOut, Scissors, Heart
} from 'lucide-react';
const navItems = [
{ path: '/', label: 'الرئيسية', icon: LayoutDashboard },
{ path: '/bookings', label: 'المواعيد', icon: CalendarDays },
{ path: '/services', label: 'الخدمات', icon: Sparkles },
{ path: '/clients', label: 'العملاء', icon: Users },
{ path: '/staff', label: 'الموظفون', icon: UserCog },
{ path: '/inventory', label: 'المخزون', icon: Package },
{ path: '/store', label: 'المتجر', icon: ShoppingBag },
{ path: '/engagement', label: 'التفاعل', icon: MessageCircle },
{ path: '/health', label: 'السجل الصحي', icon: Heart },
{ path: '/reports', label: 'التقارير', icon: BarChart3 },
{ path: '/settings', label: 'الإعدادات', icon: Settings },
];
export default function Sidebar() {
return (
<aside className="fixed right-0 top-0 h-screen w-[260px] bg-wine text-white z-40 overflow-y-auto flex flex-col">
{/* Logo */}
<div className="flex items-center gap-3 px-5 py-6 border-b border-white/10">
<div className="w-10 h-10 rounded-xl gold-gradient flex items-center justify-center">
<Scissors className="w-5 h-5 text-wine" />
</div>
<div>
<h1 className="text-lg font-bold tracking-wide">BeautyHub</h1>
<p className="text-[10px] text-white/60">لوحة إدارة الصالون</p>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 border-l-2 border-gold text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
}`
}
>
<item.icon className="w-[18px] h-[18px] flex-shrink-0" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
{/* User Card */}
<div className="px-3 pb-4">
<div className="bg-white/10 rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-9 h-9 rounded-full bg-gold/20 flex items-center justify-center text-gold font-bold text-sm">
س
</div>
<div>
<p className="text-sm font-semibold">صالون سارة</p>
<p className="text-[10px] text-white/50">مسؤول</p>
</div>
</div>
<button className="flex items-center gap-2 text-xs text-white/60 hover:text-white transition-colors w-full">
<LogOut className="w-3.5 h-3.5" />
<span>تسجيل الخروج</span>
</button>
</div>
</div>
</aside>
);
}

View File

@ -0,0 +1,32 @@
import { TrendingUp, TrendingDown, type LucideIcon } from 'lucide-react';
interface StatCardProps {
label: string;
value: string | number;
change?: number;
icon: LucideIcon;
iconColor?: string;
}
export default function StatCard({ label, value, change, icon: Icon, iconColor = 'bg-wine' }: StatCardProps) {
return (
<div className="bg-white rounded-2xl p-5 shadow-card stat-card-hover border border-border-color/50">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-text-secondary font-medium">{label}</p>
<p className="text-2xl font-bold text-text-primary mt-2">{value}</p>
{change !== undefined && (
<div className={`flex items-center gap-1 mt-2 text-xs font-medium ${change >= 0 ? 'text-success' : 'text-danger'}`}>
{change >= 0 ? <TrendingUp className="w-3.5 h-3.5" /> : <TrendingDown className="w-3.5 h-3.5" />}
<span>{change >= 0 ? '+' : ''}{change}%</span>
<span className="text-text-muted font-normal">هذا الشهر</span>
</div>
)}
</div>
<div className={`w-11 h-11 rounded-xl ${iconColor} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-5 h-5 text-white" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
const statusMap: Record<string, { label: string; className: string }> = {
pending: { label: 'قيد الانتظار', className: 'bg-gold/10 text-gold-dark border-gold/30' },
confirmed: { label: 'مؤكد', className: 'bg-success/10 text-success border-success/30' },
completed: { label: 'مكتمل', className: 'bg-wine/10 text-wine border-wine/30' },
cancelled: { label: 'ملغي', className: 'bg-danger/10 text-danger border-danger/30' },
active: { label: 'نشط', className: 'bg-success/10 text-success border-success/30' },
inactive: { label: 'غير نشط', className: 'bg-text-muted/10 text-text-muted border-text-muted/30' },
'on-leave': { label: 'في إجازة', className: 'bg-warning/10 text-warning border-warning/30' },
open: { label: 'مفتوح', className: 'bg-warning/10 text-warning border-warning/30' },
resolved: { label: 'محلول', className: 'bg-success/10 text-success border-success/30' },
processing: { label: 'قيد المعالجة', className: 'bg-gold/10 text-gold-dark border-gold/30' },
shipped: { label: 'تم الشحن', className: 'bg-wine/10 text-wine border-wine/30' },
delivered: { label: 'تم التوصيل', className: 'bg-success/10 text-success border-success/30' },
};
interface StatusBadgeProps {
status: string;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const config = statusMap[status] || { label: status, className: 'bg-gray-100 text-gray-600' };
return (
<span className={`inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium border ${config.className}`}>
{config.label}
</span>
);
}

98
src/components/TopBar.tsx Normal file
View File

@ -0,0 +1,98 @@
import { useState } from 'react';
import { Search, Bell, Plus, CalendarDays } from 'lucide-react';
interface TopBarProps {
title: string;
subtitle?: string;
}
export default function TopBar({ title, subtitle }: TopBarProps) {
const [searchValue, setSearchValue] = useState('');
const [notifOpen, setNotifOpen] = useState(false);
const notifications = [
{ id: 1, title: 'حجز جديد', message: 'نورا السالم حجزت موعد غداً', time: '5 دقائق', read: false },
{ id: 2, title: 'مخزون منخفض', message: 'كريم تنظيف البشرة وصل للحد الأدنى', time: '30 دقيقة', read: false },
{ id: 3, title: 'تقييم جديد', message: 'ريم الحربي قدمت تقييم 5 نجوم', time: '1 ساعة', read: true },
{ id: 4, title: 'طلب توظيف', message: 'ليلى الراشد أرسلت طلب انضمام', time: '2 ساعة', read: true },
];
const today = new Date();
const dateStr = today.toLocaleDateString('ar-SA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return (
<header className="sticky top-0 z-30 bg-cream border-b border-cream-dark px-6 py-4">
<div className="flex items-center justify-between">
{/* Title */}
<div>
<h2 className="text-xl font-bold text-text-primary">{title}</h2>
{subtitle && <p className="text-sm text-text-secondary mt-0.5">{subtitle}</p>}
</div>
{/* Actions */}
<div className="flex items-center gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="بحث..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="w-56 pr-9 pl-4 py-2.5 bg-white border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30 focus:border-wine transition-all"
/>
</div>
{/* Date */}
<div className="hidden lg:flex items-center gap-2 text-sm text-text-secondary bg-white px-4 py-2.5 rounded-xl border border-border-color">
<CalendarDays className="w-4 h-4 text-wine" />
<span>{dateStr}</span>
</div>
{/* Notifications */}
<div className="relative">
<button
onClick={() => setNotifOpen(!notifOpen)}
className="relative p-2.5 bg-white border border-border-color rounded-xl hover:bg-wine-50 transition-colors"
>
<Bell className="w-5 h-5 text-text-secondary" />
<span className="absolute top-1.5 left-1.5 w-2 h-2 bg-gold rounded-full" />
</button>
{notifOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setNotifOpen(false)} />
<div className="absolute left-0 top-full mt-2 w-80 bg-white rounded-2xl shadow-xl border border-border-color z-50 overflow-hidden animate-fade-in-up">
<div className="px-4 py-3 border-b border-border-color flex items-center justify-between">
<h3 className="font-semibold text-sm">الإشعارات</h3>
<span className="text-xs text-wine cursor-pointer hover:underline">تحديد الكل كمقروء</span>
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.map((n) => (
<div key={n.id} className={`px-4 py-3 border-b border-border-color/50 hover:bg-cream cursor-pointer transition-colors ${!n.read ? 'bg-wine-50/50' : ''}`}>
<div className="flex items-start gap-2">
{!n.read && <span className="w-2 h-2 mt-1.5 bg-gold rounded-full flex-shrink-0" />}
<div>
<p className="text-sm font-medium">{n.title}</p>
<p className="text-xs text-text-secondary mt-0.5">{n.message}</p>
<p className="text-[10px] text-text-muted mt-1">{n.time}</p>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
</div>
{/* Add Booking Button */}
<button className="flex items-center gap-2 bg-wine text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-wine-dark transition-colors shadow-lg shadow-wine/20">
<Plus className="w-4 h-4" />
<span>حجز جديد</span>
</button>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
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,
}

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 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 }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full 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 }

View File

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/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,
}

View File

@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-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 shadow-xs 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",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/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-xl border py-6 shadow-sm",
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-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", 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", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/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,
}

357
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } 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
.filter((item) => item.type !== "none")
.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
.filter((item) => item.type !== "none")
.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,
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input 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="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
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 }

View File

@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/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,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<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,
}

View File

@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/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 [&_svg:not([class*='text-'])]:text-muted-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,
}

View File

@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
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}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
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 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-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>
)
}
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}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/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-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
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,
}

View File

@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/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} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
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} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
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}
/>
)
}
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 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 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 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,
}

104
src/components/ui/empty.tsx Normal file
View File

@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

246
src/components/ui/field.tsx Normal file
View File

@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

167
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/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,
}

View File

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@ -0,0 +1,75 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/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", 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 shadow-xs 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 }

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/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 h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs 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 }

193
src/components/ui/item.tsx Normal file
View File

@ -0,0 +1,193 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

28
src/components/ui/kbd.tsx Normal file
View File

@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,274 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/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,
}

View File

@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/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,
}

View File

@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/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,
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,54 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
return (
<ResizablePrimitive.Group
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.Separator> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
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-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[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.Separator>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,188 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/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-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs 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 = "item-aligned",
align = "center",
...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}
align={align}
{...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
data-slot="select-item-indicator"
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,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
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 }

137
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
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,
}

View File

@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/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,
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/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 }

View File

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/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-1.5 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 ring-ring/50 block size-4 shrink-0 rounded-full border bg-white 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 }

View File

@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/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-input 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 shadow-xs 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-background dark:data-[state=unchecked]:bg-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 }

114
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/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,
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/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-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background 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-md 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 data-[state=active]:shadow-sm [&_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 }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"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-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,81 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{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}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,45 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

149
src/data/demoData.ts Normal file
View File

@ -0,0 +1,149 @@
import type {
Booking, Service, Client, Staff, JobRequest, InventoryItem,
Product, Order, TrainingCourse, Follower, Review, Complaint,
HealthRecord, Post, DashboardStats, WeeklyRevenue
} from '@/types';
export const dashboardStats: DashboardStats = {
totalRevenue: 48750,
totalBookings: 156,
newClients: 32,
averageRating: 4.8,
revenueChange: 12.5,
bookingsChange: 8.3,
clientsChange: 15.2,
ratingChange: 0.2,
};
export const weeklyRevenue: WeeklyRevenue[] = [
{ day: 'السبت', revenue: 6800, bookings: 22 },
{ day: 'الأحد', revenue: 5200, bookings: 18 },
{ day: 'الاثنين', revenue: 7500, bookings: 25 },
{ day: 'الثلاثاء', revenue: 8900, bookings: 28 },
{ day: 'الأربعاء', revenue: 6200, bookings: 20 },
{ day: 'الخميس', revenue: 9500, bookings: 32 },
{ day: 'الجمعة', revenue: 4650, bookings: 11 },
];
export const bookings: Booking[] = [
{ id: '1', clientName: 'نورا السالم', clientAvatar: '', services: ['قص الشعر', 'صبغ'], date: '2026-05-26', time: '10:00', duration: 120, status: 'confirmed', amount: 450, notes: '', preBookingAnswers: [{ question: 'هل لديك حساسية من الصبغة؟', answer: 'لا' }] },
{ id: '2', clientName: 'سارة العبدالله', clientAvatar: '', services: ['مانيكير'], date: '2026-05-26', time: '11:30', duration: 45, status: 'completed', amount: 150, notes: '', preBookingAnswers: [] },
{ id: '3', clientName: 'فاطمة الزهراني', clientAvatar: '', services: ['فشل', 'ماسك'], date: '2026-05-26', time: '13:00', duration: 90, status: 'pending', amount: 380, notes: '', preBookingAnswers: [{ question: 'نوع البشرة؟', answer: 'دهنية' }] },
{ id: '4', clientName: 'ريم الحربي', clientAvatar: '', services: ['مساج استرخاء'], date: '2026-05-26', time: '15:00', duration: 60, status: 'confirmed', amount: 300, notes: '', preBookingAnswers: [] },
{ id: '5', clientName: 'لمى القحطاني', clientAvatar: '', services: ['صبغ كامل'], date: '2026-05-26', time: '16:30', duration: 150, status: 'pending', amount: 600, notes: '', preBookingAnswers: [{ question: 'اللون المفضل؟', answer: 'بني فاتح' }] },
{ id: '6', clientName: 'هدى الشمري', clientAvatar: '', services: ['قص وتسريحة'], date: '2026-05-27', time: '09:00', duration: 75, status: 'confirmed', amount: 250, notes: '', preBookingAnswers: [] },
{ id: '7', clientName: 'أمل الدوسري', clientAvatar: '', services: ['بديكير'], date: '2026-05-27', time: '11:00', duration: 60, status: 'pending', amount: 180, notes: '', preBookingAnswers: [] },
{ id: '8', clientName: 'منى الغامدي', clientAvatar: '', services: ['تنظيف بشرة', 'تقشير'], date: '2026-05-27', time: '14:00', duration: 90, status: 'confirmed', amount: 420, notes: '', preBookingAnswers: [{ question: 'هل تستخدمين Retinol؟', answer: 'نعم' }] },
{ id: '9', clientName: 'دانة المطيري', clientAvatar: '', services: ['تسريحة مناسبة'], date: '2026-05-27', time: '17:00', duration: 120, status: 'pending', amount: 550, notes: 'مناسبة زفاف', preBookingAnswers: [] },
{ id: '10', clientName: 'وعد الهاجري', clientAvatar: '', services: ['صبغ جذور'], date: '2026-05-28', time: '10:00', duration: 90, status: 'confirmed', amount: 350, notes: '', preBookingAnswers: [{ question: 'لون الجذور الحالي؟', answer: 'أشقر' }] },
{ id: '11', clientName: 'رنا السبيعي', clientAvatar: '', services: ['ميكاب كامل'], date: '2026-05-28', time: '12:00', duration: 90, status: 'pending', amount: 480, notes: '', preBookingAnswers: [{ question: 'مناسبة؟', answer: 'حفل تخرج' }] },
{ id: '12', clientName: 'تالا الصقير', clientAvatar: '', services: ['علاج كيراتين'], date: '2026-05-28', time: '15:00', duration: 180, status: 'confirmed', amount: 800, notes: '', preBookingAnswers: [{ question: 'هل تم استخدام صبغة مؤخراً؟', answer: 'لا' }] },
];
export const services: Service[] = [
{ id: '1', name: 'قص الشعر', description: 'قص وتصفيف الشعر حسب الرغبة', price: 120, duration: 45, category: 'شعر', image: '', isActive: true, instructions: 'يرجى غسل الشعر قبل الموعد', questions: [{ id: 'q1', question: 'طول الشعر المطلوب؟', required: true }], materials: [{ materialId: '1', quantityPerService: 1 }] },
{ id: '2', name: 'صبغ الشعر', description: 'صبغ كامل أو جزئي', price: 350, duration: 120, category: 'شعر', image: '', isActive: true, instructions: 'اختبار حساسية مطلوب', questions: [{ id: 'q2', question: 'هل لديك حساسية من الصبغة؟', required: true }, { id: 'q3', question: 'اللون المطلوب؟', required: true }], materials: [{ materialId: '2', quantityPerService: 1 }, { materialId: '3', quantityPerService: 0.5 }] },
{ id: '3', name: 'مانيكير', description: 'تقليم الأظافر والطلاء', price: 150, duration: 45, category: 'أظافر', image: '', isActive: true, instructions: '', questions: [], materials: [{ materialId: '4', quantityPerService: 1 }] },
{ id: '4', name: 'بديكير', description: 'عناية بالقدمين والأظافر', price: 180, duration: 60, category: 'أظافر', image: '', isActive: true, instructions: '', questions: [], materials: [{ materialId: '5', quantityPerService: 1 }] },
{ id: '5', name: 'تنظيف بشرة', description: 'تنظيف عميق للبشرة', price: 250, duration: 60, category: 'بشرة', image: '', isActive: true, instructions: 'تجنب أشعة الشمس بعد الجلسة', questions: [{ id: 'q4', question: 'نوع البشرة؟', required: true }], materials: [{ materialId: '6', quantityPerService: 1 }] },
{ id: '6', name: 'مساج استرخاء', description: 'مساج كامل للجسم', price: 300, duration: 60, category: 'مساج', image: '', isActive: true, instructions: 'اشربي الماء قبل وبعد', questions: [{ id: 'q5', question: 'هل لديك إصابات؟', required: false }], materials: [{ materialId: '7', quantityPerService: 1 }] },
{ id: '7', name: 'ميكاب كامل', description: 'مكياج احترافي', price: 450, duration: 90, category: 'مكياج', image: '', isActive: true, instructions: '', questions: [{ id: 'q6', question: 'نوع المناسبة؟', required: true }], materials: [{ materialId: '8', quantityPerService: 1 }] },
{ id: '8', name: 'علاج كيراتين', description: 'تنعيم وت straightening', price: 800, duration: 180, category: 'شعر', image: '', isActive: true, instructions: 'عدم غسل الشعر 3 أيام', questions: [{ id: 'q7', question: 'هل تم استخدام صبغة مؤخراً؟', required: true }], materials: [{ materialId: '9', quantityPerService: 1 }] },
{ id: '9', name: 'تسريحة مناسبة', description: 'تسريحة للمناسبات', price: 350, duration: 90, category: 'شعر', image: '', isActive: true, instructions: '', questions: [{ id: 'q8', question: 'نوع المناسبة؟', required: true }], materials: [{ materialId: '10', quantityPerService: 1 }] },
{ id: '10', name: 'تقشير كيميائي', description: 'تجديد خلايا البشرة', price: 400, duration: 45, category: 'بشرة', image: '', isActive: true, instructions: 'تجنب Retinol قبل أسبوع', questions: [{ id: 'q9', question: 'هل تستخدمين Retinol؟', required: true }], materials: [{ materialId: '6', quantityPerService: 2 }] },
];
export const clients: Client[] = [
{ id: '1', name: 'نورا السالم', avatar: '', phone: '0501234567', email: 'nora@email.com', visitCount: 12, totalSpent: 5400, lastVisit: '2026-05-26', loyaltyPoints: 540, birthDate: '1995-03-15', healthConsent: true, allergies: ['صبغة آمونيا'], conditions: ['بشرة حساسة'], blocked: false },
{ id: '2', name: 'سارة العبدالله', avatar: '', phone: '0559876543', email: 'sara@email.com', visitCount: 8, totalSpent: 3200, lastVisit: '2026-05-26', loyaltyPoints: 320, birthDate: '1990-07-22', healthConsent: true, allergies: [], conditions: [], blocked: false },
{ id: '3', name: 'فاطمة الزهراني', avatar: '', phone: '0587654321', email: 'fatima@email.com', visitCount: 5, totalSpent: 2100, lastVisit: '2026-05-26', loyaltyPoints: 210, birthDate: '1998-11-08', healthConsent: false, allergies: [], conditions: ['بشرة دهنية'], blocked: false },
{ id: '4', name: 'ريم الحربي', avatar: '', phone: '0534567890', email: 'reem@email.com', visitCount: 15, totalSpent: 7800, lastVisit: '2026-05-26', loyaltyPoints: 780, birthDate: '1988-01-30', healthConsent: true, allergies: [], conditions: [], blocked: false },
{ id: '5', name: 'لمى القحطاني', avatar: '', phone: '0576543210', email: 'lama@email.com', visitCount: 3, totalSpent: 1200, lastVisit: '2026-05-26', loyaltyPoints: 120, birthDate: '2000-09-12', healthConsent: false, allergies: [], conditions: [], blocked: false },
{ id: '6', name: 'هدى الشمري', avatar: '', phone: '0598765432', email: 'huda@email.com', visitCount: 20, totalSpent: 9600, lastVisit: '2026-05-27', loyaltyPoints: 960, birthDate: '1992-05-18', healthConsent: true, allergies: ['لاكتوز'], conditions: [], blocked: false },
{ id: '7', name: 'أمل الدوسري', avatar: '', phone: '0543210987', email: 'amal@email.com', visitCount: 6, totalSpent: 2800, lastVisit: '2026-05-27', loyaltyPoints: 280, birthDate: '1996-12-25', healthConsent: true, allergies: [], conditions: ['أظافر هشة'], blocked: false },
{ id: '8', name: 'منى الغامدي', avatar: '', phone: '0567890123', email: 'mona@email.com', visitCount: 10, totalSpent: 4500, lastVisit: '2026-05-27', loyaltyPoints: 450, birthDate: '1985-04-03', healthConsent: true, allergies: ['روتينول'], conditions: ['تجاعيد'], blocked: false },
];
export const staffMembers: Staff[] = [
{ id: '1', name: 'أحمد العلي', avatar: '', role: 'مصفف شعر', specialty: 'قص وصبغ', status: 'active', rating: 4.9, email: 'ahmed@beautyhub.sa', phone: '0501112222', schedule: { saturday: { start: '09:00', end: '17:00', isOff: false }, sunday: { start: '09:00', end: '17:00', isOff: false }, monday: { start: '09:00', end: '17:00', isOff: false }, tuesday: { start: '09:00', end: '17:00', isOff: false }, wednesday: { start: '09:00', end: '17:00', isOff: false }, thursday: { start: '09:00', end: '21:00', isOff: false }, friday: { start: '', end: '', isOff: true } }, joinDate: '2024-01-15' },
{ id: '2', name: 'مريم الكعبي', avatar: '', role: 'أخصائية بشرة', specialty: 'علاجات البشرة', status: 'active', rating: 4.8, email: 'maryam@beautyhub.sa', phone: '0503334444', schedule: { saturday: { start: '10:00', end: '18:00', isOff: false }, sunday: { start: '10:00', end: '18:00', isOff: false }, monday: { start: '10:00', end: '18:00', isOff: false }, tuesday: { start: '10:00', end: '18:00', isOff: false }, wednesday: { start: '10:00', end: '18:00', isOff: false }, thursday: { start: '10:00', end: '20:00', isOff: false }, friday: { start: '', end: '', isOff: true } }, joinDate: '2024-03-01' },
{ id: '3', name: 'نورة الفيصل', avatar: '', role: 'خبيرة تجميل', specialty: 'مكياج ونيل آرت', status: 'active', rating: 4.7, email: 'noura@beautyhub.sa', phone: '0505556666', schedule: { saturday: { start: '09:00', end: '17:00', isOff: false }, sunday: { start: '09:00', end: '17:00', isOff: false }, monday: { start: '09:00', end: '17:00', isOff: false }, tuesday: { start: '09:00', end: '17:00', isOff: false }, wednesday: { start: '09:00', end: '17:00', isOff: false }, thursday: { start: '09:00', end: '21:00', isOff: false }, friday: { start: '', end: '', isOff: true } }, joinDate: '2024-06-10' },
{ id: '4', name: 'خالد الزهراني', avatar: '', role: 'أخصائي مساج', specialty: 'مساج علاجي', status: 'on-leave', rating: 4.9, email: 'khaled@beautyhub.sa', phone: '0507778888', schedule: { saturday: { start: '12:00', end: '20:00', isOff: false }, sunday: { start: '12:00', end: '20:00', isOff: false }, monday: { start: '12:00', end: '20:00', isOff: false }, tuesday: { start: '12:00', end: '20:00', isOff: false }, wednesday: { start: '12:00', end: '20:00', isOff: false }, thursday: { start: '12:00', end: '22:00', isOff: false }, friday: { start: '', end: '', isOff: true } }, joinDate: '2025-01-05' },
];
export const jobRequests: JobRequest[] = [
{ id: '1', expertName: 'ليلى الراشد', specialty: 'مصففة شعر', requestType: 'permanent', status: 'pending', requestDate: '2026-05-20', message: 'أرغب بالانضمام للفريق بخبرة 5 سنوات' },
{ id: '2', expertName: 'سمية العبدالرحمن', specialty: 'أخصائية بشرة', requestType: 'temporary', status: 'pending', requestDate: '2026-05-22', message: 'متاحة للعمل بشكل مؤقت' },
{ id: '3', expertName: 'هديل المنيف', specialty: 'خبيرة مكياج', requestType: 'permanent', status: 'approved', requestDate: '2026-05-15', message: 'خبرة 8 سنوات في المكياج الاحترافي' },
];
export const inventoryItems: InventoryItem[] = [
{ id: '1', name: 'شامبو قص الشعر', category: 'شعر', unit: 'عبوة', currentQuantity: 25, initialQuantity: 50, minThreshold: 10, costPerUnit: 35, supplier: 'شركة الجمال', lastRestocked: '2026-04-15' },
{ id: '2', name: 'صبغة شعر - بني فاتح', category: 'شعر', unit: 'أنبوب', currentQuantity: 18, initialQuantity: 40, minThreshold: 8, costPerUnit: 45, supplier: 'لوريال بروفشنال', lastRestocked: '2026-04-20' },
{ id: '3', name: 'أوكسجين 20%', category: 'شعر', unit: 'عبوة', currentQuantity: 12, initialQuantity: 30, minThreshold: 5, costPerUnit: 25, supplier: 'لوريال بروفشنال', lastRestocked: '2026-04-20' },
{ id: '4', name: 'طلاء أظافر - مجموعة', category: 'أظافر', unit: 'عبوة', currentQuantity: 8, initialQuantity: 25, minThreshold: 5, costPerUnit: 60, supplier: 'أوبي', lastRestocked: '2026-05-01' },
{ id: '5', name: 'كريم القدمين', category: 'أظافر', unit: 'عبوة', currentQuantity: 30, initialQuantity: 40, minThreshold: 8, costPerUnit: 40, supplier: 'شركة الجمال', lastRestocked: '2026-04-25' },
{ id: '6', name: 'كريم تنظيف البشرة', category: 'بشرة', unit: 'عبوة', currentQuantity: 5, initialQuantity: 20, minThreshold: 5, costPerUnit: 85, supplier: 'سي إي فينالي', lastRestocked: '2026-04-10' },
{ id: '7', name: 'زيت مساج', category: 'مساج', unit: 'لتر', currentQuantity: 15, initialQuantity: 25, minThreshold: 5, costPerUnit: 55, supplier: 'شركة الزيوت الطبيعية', lastRestocked: '2026-04-18' },
{ id: '8', name: 'مستحضرات مكياج', category: 'مكياج', unit: 'مجموعة', currentQuantity: 22, initialQuantity: 30, minThreshold: 8, costPerUnit: 200, supplier: 'ماك كوزمتكس', lastRestocked: '2026-05-05' },
{ id: '9', name: 'كيراتين علاج', category: 'شعر', unit: 'عبوة', currentQuantity: 7, initialQuantity: 15, minThreshold: 3, costPerUnit: 150, supplier: 'برازيليان', lastRestocked: '2026-04-28' },
{ id: '10', name: 'جل تسريحة', category: 'شعر', unit: 'عبوة', currentQuantity: 20, initialQuantity: 35, minThreshold: 8, costPerUnit: 30, supplier: 'شركة الجمال', lastRestocked: '2026-04-22' },
];
export const products: Product[] = [
{ id: '1', name: 'شامبو الكيراتين', description: 'شامبو غني بالكيراتين', price: 120, stock: 45, image: '', isActive: true, category: 'شامبو' },
{ id: '2', name: 'بلسم التغذية', description: 'بلسم عميق للشعر الجاف', price: 95, stock: 32, image: '', isActive: true, category: 'بلسم' },
{ id: '3', name: 'سيروم فيتامين C', description: 'سيروم مضاد للأكسدة', price: 180, stock: 18, image: '', isActive: true, category: 'عناية بالبشرة' },
{ id: '4', name: 'كريم مرطب SPF50', description: 'حماية وترطيب', price: 150, stock: 25, image: '', isActive: true, category: 'عناية بالبشرة' },
{ id: '5', name: 'طلاء أظافر جل', description: 'ثبات حتى 14 يوم', price: 75, stock: 60, image: '', isActive: true, category: 'أظافر' },
{ id: '6', name: 'زيت الأرغان', description: 'زيت نقي للشعر', price: 110, stock: 20, image: '', isActive: false, category: 'زيوت' },
];
export const orders: Order[] = [
{ id: 'ORD-001', customerName: 'نورا السالم', items: [{ productName: 'شامبو الكيراتين', quantity: 2, price: 240 }, { productName: 'سيروم فيتامين C', quantity: 1, price: 180 }], total: 420, status: 'delivered', date: '2026-05-20' },
{ id: 'ORD-002', customerName: 'سارة العبدالله', items: [{ productName: 'بلسم التغذية', quantity: 1, price: 95 }, { productName: 'كريم مرطب SPF50', quantity: 1, price: 150 }], total: 245, status: 'shipped', date: '2026-05-22' },
{ id: 'ORD-003', customerName: 'فاطمة الزهراني', items: [{ productName: 'طلاء أظافر جل', quantity: 3, price: 225 }], total: 225, status: 'processing', date: '2026-05-25' },
];
export const courses: TrainingCourse[] = [
{ id: '1', title: 'دورة قص الشعر الاحترافي', description: 'تعلمي أحدث تقنيات القص والتصفيف', enrolledCount: 25, price: 2500, image: '', hasCertificate: true, status: 'active' },
{ id: '2', title: 'دورة العناية بالبشرة', description: 'تقنيات متقدمة في العناية بالبشرة', enrolledCount: 18, price: 3000, image: '', hasCertificate: true, status: 'active' },
{ id: '3', title: 'دورة المكياج الاحترافي', description: 'من البداية للاحتراف', enrolledCount: 32, price: 3500, image: '', hasCertificate: true, status: 'active' },
];
export const followers: Follower[] = [
{ id: '1', name: 'ريم الحربي', avatar: '', followDate: '2026-05-01', isFollowing: true, isBlocked: false },
{ id: '2', name: 'نورة السبيعي', avatar: '', followDate: '2026-05-10', isFollowing: true, isBlocked: false },
{ id: '3', name: 'دانة القحطاني', avatar: '', followDate: '2026-04-20', isFollowing: false, isBlocked: true },
{ id: '4', name: 'أحلام المطيري', avatar: '', followDate: '2026-05-15', isFollowing: true, isBlocked: false },
];
export const reviews: Review[] = [
{ id: '1', clientName: 'نورا السالم', avatar: '', rating: 5, comment: 'تجربة رائعة! أحمد مصفف ممتاز وخدمة مميزة', service: 'قص وصبغ', date: '2026-05-26', reply: 'شكراً لكِ نورا! نتطلع لرؤيتكِ مجدداً' },
{ id: '2', clientName: 'سارة العبدالله', avatar: '', rating: 4, comment: 'المانيكير جميل لكن الانتظار كان طويلاً', service: 'مانيكير', date: '2026-05-26', reply: '' },
{ id: '3', clientName: 'فاطمة الزهراني', avatar: '', rating: 5, comment: 'مريم أخصائية رائعة، بشرتي أصبحت أفضل بكثير', service: 'تنظيف بشرة', date: '2026-05-25', reply: 'سعدنا بذلك! استمري بزيارتنا' },
{ id: '4', clientName: 'هدى الشمري', avatar: '', rating: 3, comment: 'الخدمة جيدة لكن السعر مرتفع بعض الشيء', service: 'صبغ', date: '2026-05-24', reply: '' },
];
export const complaints: Complaint[] = [
{ id: '1', clientName: 'دانة القحطاني', subject: 'تأخير الموعد', message: 'انتظرت 30 دقيقة عن موعدي المحدد', date: '2026-05-20', status: 'resolved', response: 'نعتذر عن التأخير، تم منحك خصم 20%' },
{ id: '2', clientName: 'أحلام المطيري', subject: 'جودة الخدمة', message: 'الصبغة لم تظهر باللون المطلوب', date: '2026-05-23', status: 'open', response: '' },
];
export const healthRecords: HealthRecord[] = [
{ clientId: '1', entries: [
{ id: 'h1', date: '2026-05-26', service: 'صبغ شعر', notes: 'صبغة بدون أمونيا، لون بني فاتح', conditions: ['حساسية أمونيا'], addedBy: 'أحمد العلي' },
{ id: 'h2', date: '2026-04-15', service: 'تنظيف بشرة', notes: 'بشرة حساسة، استخدمنا منتجات خاصة', conditions: ['بشرة حساسة'], addedBy: 'مريم الكعبي' },
]},
{ clientId: '4', entries: [
{ id: 'h3', date: '2026-05-26', service: 'مساج استرخاء', notes: 'مساج خفيف - العميلة تفضل الضغط المتوسط', conditions: [], addedBy: 'خالد الزهراني' },
]},
{ clientId: '8', entries: [
{ id: 'h4', date: '2026-05-27', service: 'تنظيف بشرة', notes: 'تجنب التقشير - العميلة تستخدم Retinol', conditions: ['تستخدم Retinol'], addedBy: 'مريم الكعبي' },
]},
];
export const posts: Post[] = [
{ id: '1', image: '', caption: 'قصة شعر جديدة لعميلتنا المميزة ✨ #BeautyHub', likes: 45, date: '2026-05-26', type: 'post' },
{ id: '2', image: '', caption: 'خلف الكواليس - فريقنا الجميل 💕', likes: 32, date: '2026-05-25', type: 'story' },
];

19
src/hooks/use-mobile.ts Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

78
src/index.css Normal file
View File

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 30 20% 95%;
--foreground: 330 12% 16%;
--card: 0 0% 100%;
--card-foreground: 330 12% 16%;
--popover: 0 0% 100%;
--popover-foreground: 330 12% 16%;
--primary: 340 48% 33%;
--primary-foreground: 30 20% 95%;
--secondary: 340 15% 92%;
--secondary-foreground: 330 12% 16%;
--muted: 30 15% 90%;
--muted-foreground: 330 8% 42%;
--accent: 42 55% 58%;
--accent-foreground: 330 12% 16%;
--destructive: 0 42% 50%;
--destructive-foreground: 0 0% 98%;
--border: 25 20% 88%;
--input: 25 20% 88%;
--ring: 340 48% 33%;
--radius: 0.75rem;
--sidebar-background: 340 48% 33%;
--sidebar-foreground: 30 20% 95%;
--sidebar-primary: 42 55% 58%;
--sidebar-primary-foreground: 330 12% 16%;
--sidebar-accent: 340 40% 40%;
--sidebar-accent-foreground: 30 20% 95%;
--sidebar-border: 340 40% 40%;
--sidebar-ring: 42 55% 58%;
--wine: #7B2D4B;
--wine-light: #9D4A6B;
--wine-dark: #5E1F36;
--gold: #D4A853;
--gold-light: #E8C77A;
--gold-dark: #B08A3D;
--cream: #F5F0EB;
--surface: #FFFFFF;
--text-primary: #2D2428;
--text-secondary: #6B5B63;
--text-muted: #9B8B93;
--success: #4A8B6F;
--warning: #C9933E;
--danger: #B54A4A;
--border-color: #E5DDD6;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: 'Tajawal', 'Segoe UI', sans-serif;
}
}
@layer utilities {
.wine-gradient {
background: linear-gradient(135deg, var(--wine) 0%, var(--wine-light) 100%);
}
.gold-gradient {
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-light) 100%);
}
.stat-card-hover {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -8px rgba(123, 45, 75, 0.2);
}
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

239
src/pages/Bookings.tsx Normal file
View File

@ -0,0 +1,239 @@
import { useState } from 'react';
import { CalendarDays, Search, Filter, ChevronLeft, ChevronRight, Eye, Edit3, XCircle, CheckCircle } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import StatusBadge from '@/components/StatusBadge';
import { bookings } from '@/data/demoData';
import type { Booking } from '@/types';
export default function Bookings() {
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const perPage = 8;
const filtered = bookings.filter((b) => {
const matchFilter = filter === 'all' || b.status === filter;
const matchSearch = b.clientName.includes(search) || b.services.some(s => s.includes(search));
return matchFilter && matchSearch;
});
const totalPages = Math.ceil(filtered.length / perPage);
const paginated = filtered.slice((currentPage - 1) * perPage, currentPage * perPage);
const filters = [
{ key: 'all', label: 'الكل' },
{ key: 'pending', label: 'قيد الانتظار' },
{ key: 'confirmed', label: 'مؤكد' },
{ key: 'completed', label: 'مكتمل' },
{ key: 'cancelled', label: 'ملغي' },
];
return (
<PageLayout title="المواعيد" subtitle="إدارة حجوزات العملاء والمواعيد">
{/* Filters */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-4 mb-5">
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="البحث باسم العميل أو الخدمة..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pr-10 pl-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30 focus:border-wine"
/>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<Filter className="w-4 h-4 text-text-muted flex-shrink-0" />
{filters.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-4 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all ${
filter === f.key
? 'bg-wine text-white shadow-lg shadow-wine/20'
: 'bg-cream text-text-secondary hover:bg-cream-dark'
}`}
>
{f.label}
</button>
))}
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-color/50 bg-cream/50">
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted uppercase tracking-wider">العميل</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted uppercase tracking-wider">الخدمات</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted uppercase tracking-wider">التاريخ</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted uppercase tracking-wider">الوقت</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted uppercase tracking-wider">المبلغ</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted uppercase tracking-wider">الحالة</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted uppercase tracking-wider">الإجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-color/30">
{paginated.map((b) => (
<tr key={b.id} className="hover:bg-cream/30 transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-wine-100 flex items-center justify-center text-wine text-xs font-bold">
{b.clientName.charAt(0)}
</div>
<span className="text-sm font-medium text-text-primary">{b.clientName}</span>
</div>
</td>
<td className="px-5 py-3.5">
<div className="flex flex-wrap gap-1">
{b.services.map((s) => (
<span key={s} className="px-2 py-0.5 bg-wine-50 text-wine text-xs rounded-lg">{s}</span>
))}
</div>
</td>
<td className="px-5 py-3.5 text-sm text-text-secondary">
<div className="flex items-center gap-1.5">
<CalendarDays className="w-3.5 h-3.5 text-text-muted" />
{b.date}
</div>
</td>
<td className="px-5 py-3.5 text-sm text-text-secondary">{b.time}</td>
<td className="px-5 py-3.5 text-sm font-bold text-gold-dark">{b.amount} ر.س</td>
<td className="px-5 py-3.5"><StatusBadge status={b.status} /></td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-1">
<button onClick={() => setSelectedBooking(b)} className="p-1.5 rounded-lg hover:bg-wine-50 text-wine transition-colors" title="عرض">
<Eye className="w-4 h-4" />
</button>
<button className="p-1.5 rounded-lg hover:bg-success/10 text-success transition-colors" title="تأكيد">
<CheckCircle className="w-4 h-4" />
</button>
<button className="p-1.5 rounded-lg hover:bg-danger/10 text-danger transition-colors" title="إلغاء">
<XCircle className="w-4 h-4" />
</button>
<button className="p-1.5 rounded-lg hover:bg-gold/10 text-gold-dark transition-colors" title="تعديل">
<Edit3 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-5 py-4 border-t border-border-color/50">
<p className="text-xs text-text-muted">{filtered.length} حجز</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-border-color hover:bg-cream disabled:opacity-40 transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setCurrentPage(p)}
className={`w-8 h-8 rounded-lg text-sm font-medium transition-colors ${
currentPage === p ? 'bg-wine text-white' : 'hover:bg-cream text-text-secondary'
}`}
>
{p}
</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-border-color hover:bg-cream disabled:opacity-40 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Booking Detail Modal */}
{selectedBooking && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => setSelectedBooking(null)} />
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 z-10 animate-fade-in-up">
<div className="p-6">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-bold">تفاصيل الحجز</h3>
<button onClick={() => setSelectedBooking(null)} className="text-text-muted hover:text-text-primary">
<XCircle className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 pb-4 border-b border-border-color/50">
<div className="w-12 h-12 rounded-full bg-wine-100 flex items-center justify-center text-wine font-bold text-lg">
{selectedBooking.clientName.charAt(0)}
</div>
<div>
<p className="font-bold text-text-primary">{selectedBooking.clientName}</p>
<StatusBadge status={selectedBooking.status} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-cream rounded-xl p-3">
<p className="text-xs text-text-muted">التاريخ</p>
<p className="text-sm font-semibold mt-0.5">{selectedBooking.date}</p>
</div>
<div className="bg-cream rounded-xl p-3">
<p className="text-xs text-text-muted">الوقت</p>
<p className="text-sm font-semibold mt-0.5">{selectedBooking.time}</p>
</div>
</div>
<div>
<p className="text-xs text-text-muted mb-1.5">الخدمات</p>
<div className="flex flex-wrap gap-2">
{selectedBooking.services.map((s) => (
<span key={s} className="px-3 py-1.5 bg-wine-50 text-wine rounded-lg text-sm font-medium">{s}</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-cream rounded-xl p-3">
<p className="text-xs text-text-muted">المدة</p>
<p className="text-sm font-semibold mt-0.5">{selectedBooking.duration} دقيقة</p>
</div>
<div className="bg-cream rounded-xl p-3">
<p className="text-xs text-text-muted">المبلغ</p>
<p className="text-sm font-bold text-gold-dark mt-0.5">{selectedBooking.amount} ر.س</p>
</div>
</div>
{selectedBooking.notes && (
<div className="bg-cream rounded-xl p-3">
<p className="text-xs text-text-muted">ملاحظات</p>
<p className="text-sm mt-0.5">{selectedBooking.notes}</p>
</div>
)}
{selectedBooking.preBookingAnswers.length > 0 && (
<div>
<p className="text-xs text-text-muted mb-2">إجابات ما قبل الحجز</p>
{selectedBooking.preBookingAnswers.map((a, i) => (
<div key={i} className="flex items-start gap-2 mb-1.5">
<span className="text-sm text-text-secondary">{a.question}</span>
<span className="text-sm font-medium text-wine">{a.answer}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)}
</PageLayout>
);
}

202
src/pages/Clients.tsx Normal file
View File

@ -0,0 +1,202 @@
import { useState } from 'react';
import { Search, Eye, Phone, Mail, Calendar, Star, Ban, Gift } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import { clients } from '@/data/demoData';
import type { Client } from '@/types';
export default function Clients() {
const [search, setSearch] = useState('');
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const filtered = clients.filter((c) =>
c.name.includes(search) || c.phone.includes(search) || c.email.includes(search)
);
return (
<PageLayout title="العملاء" subtitle="قاعدة بيانات العملاء والملفات الشخصية">
{/* Search */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-4 mb-5">
<div className="relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="البحث باسم العميل، رقم الهاتف أو البريد..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pr-10 pl-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
</div>
{/* Clients Table */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-color/50 bg-cream/50">
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">العميل</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">التواصل</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الزيارات</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">إجمالي الإنفاق</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">نقاط الولاء</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">آخر زيارة</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الإجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-color/30">
{filtered.map((c) => (
<tr key={c.id} className="hover:bg-cream/30 transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-wine-100 flex items-center justify-center text-wine font-bold text-sm">
{c.name.charAt(0)}
</div>
{new Date(c.birthDate).getMonth() === new Date().getMonth() && (
<div className="absolute -top-1 -left-1 w-5 h-5 bg-gold rounded-full flex items-center justify-center" title="عيد ميلاد">
<Gift className="w-3 h-3 text-white" />
</div>
)}
</div>
<div>
<p className="text-sm font-semibold text-text-primary">{c.name}</p>
{c.blocked && <span className="text-[10px] bg-danger/10 text-danger px-1.5 py-0.5 rounded">محظور</span>}
</div>
</div>
</td>
<td className="px-5 py-3.5">
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-text-secondary">
<Phone className="w-3 h-3 text-text-muted" />
{c.phone}
</div>
<div className="flex items-center gap-1.5 text-xs text-text-secondary">
<Mail className="w-3 h-3 text-text-muted" />
{c.email}
</div>
</div>
</td>
<td className="px-5 py-3.5 text-sm font-semibold text-text-primary">{c.visitCount}</td>
<td className="px-5 py-3.5 text-sm font-bold text-gold-dark">{c.totalSpent.toLocaleString()} ر.س</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-1">
<Star className="w-3.5 h-3.5 text-gold fill-gold" />
<span className="text-sm font-semibold">{c.loyaltyPoints}</span>
</div>
</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-1.5 text-sm text-text-secondary">
<Calendar className="w-3.5 h-3.5 text-text-muted" />
{c.lastVisit}
</div>
</td>
<td className="px-5 py-3.5">
<button onClick={() => setSelectedClient(c)} className="p-1.5 rounded-lg hover:bg-wine-50 text-wine transition-colors" title="عرض الملف">
<Eye className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Client Detail Drawer */}
{selectedClient && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/50" onClick={() => setSelectedClient(null)} />
<div className="absolute left-0 top-0 h-full w-full max-w-md bg-white shadow-2xl animate-slide-in-right overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold">الملف الشخصي</h3>
<button onClick={() => setSelectedClient(null)} className="p-2 rounded-lg hover:bg-cream">
<Ban className="w-5 h-5 text-text-muted" />
</button>
</div>
{/* Profile Header */}
<div className="text-center mb-6">
<div className="relative inline-block">
<div className="w-20 h-20 rounded-full bg-wine-100 flex items-center justify-center text-wine text-2xl font-bold mx-auto">
{selectedClient.name.charAt(0)}
</div>
{selectedClient.healthConsent && (
<div className="absolute bottom-0 left-0 w-6 h-6 bg-success rounded-full border-2 border-white flex items-center justify-center">
<span className="text-[8px] text-white font-bold"></span>
</div>
)}
</div>
<h4 className="font-bold text-lg mt-3">{selectedClient.name}</h4>
<div className="flex items-center justify-center gap-2 mt-1">
<span className="flex items-center gap-1 text-sm text-gold-dark">
<Star className="w-4 h-4 fill-gold text-gold" />
{selectedClient.loyaltyPoints} نقطة
</span>
</div>
</div>
{/* Info Tabs */}
<div className="space-y-4">
<div className="bg-cream rounded-xl p-4">
<h5 className="text-sm font-semibold mb-3">معلومات التواصل</h5>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Phone className="w-4 h-4 text-text-muted" />
<span className="text-text-secondary">{selectedClient.phone}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Mail className="w-4 h-4 text-text-muted" />
<span className="text-text-secondary">{selectedClient.email}</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-cream rounded-xl p-4 text-center">
<p className="text-2xl font-bold text-wine">{selectedClient.visitCount}</p>
<p className="text-xs text-text-muted mt-1">عدد الزيارات</p>
</div>
<div className="bg-cream rounded-xl p-4 text-center">
<p className="text-2xl font-bold text-gold-dark">{selectedClient.totalSpent.toLocaleString()}</p>
<p className="text-xs text-text-muted mt-1">إجمالي الإنفاق (ر.س)</p>
</div>
</div>
{/* Health Info */}
<div className="bg-cream rounded-xl p-4">
<h5 className="text-sm font-semibold mb-3">الملف الصحي</h5>
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 rounded-lg text-xs ${selectedClient.healthConsent ? 'bg-success/10 text-success' : 'bg-warning/10 text-warning'}`}>
{selectedClient.healthConsent ? 'تم منح الموافقة' : 'في انتظار الموافقة'}
</span>
</div>
{selectedClient.allergies.length > 0 && (
<div className="mb-2">
<p className="text-xs text-text-muted mb-1">الحساسية</p>
<div className="flex flex-wrap gap-1">
{selectedClient.allergies.map((a) => (
<span key={a} className="px-2 py-0.5 bg-danger/10 text-danger rounded-lg text-xs">{a}</span>
))}
</div>
</div>
)}
{selectedClient.conditions.length > 0 && (
<div>
<p className="text-xs text-text-muted mb-1">الحالات</p>
<div className="flex flex-wrap gap-1">
{selectedClient.conditions.map((c) => (
<span key={c} className="px-2 py-0.5 bg-warning/10 text-warning rounded-lg text-xs">{c}</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</PageLayout>
);
}

192
src/pages/Engagement.tsx Normal file
View File

@ -0,0 +1,192 @@
import { useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Heart, ImageIcon, Star, Flag, Ban, Reply, ThumbsUp, Send } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import StatusBadge from '@/components/StatusBadge';
import { followers, reviews, complaints, posts } from '@/data/demoData';
export default function Engagement() {
const [followersList, setFollowersList] = useState(followers);
const [reviewsList, setReviewsList] = useState(reviews);
const [complaintsList, setComplaintsList] = useState(complaints);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const toggleBlock = (id: string) => {
setFollowersList((prev) => prev.map((f) => (f.id === id ? { ...f, isBlocked: !f.isBlocked } : f)));
};
const submitReply = (reviewId: string) => {
setReviewsList((prev) => prev.map((r) => (r.id === reviewId ? { ...r, reply: replyText } : r)));
setReplyingTo(null);
setReplyText('');
};
const resolveComplaint = (id: string) => {
setComplaintsList((prev) => prev.map((c) => (c.id === id ? { ...c, status: 'resolved' as const } : c)));
};
return (
<PageLayout title="التفاعل" subtitle="المتابعون، التقييمات، المحتوى، والشكاوى">
<Tabs defaultValue="followers" className="w-full">
<TabsList className="bg-white border border-border-color rounded-xl p-1 mb-5 flex-wrap">
<TabsTrigger value="followers" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-4">المتابعون</TabsTrigger>
<TabsTrigger value="reviews" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-4">التقييمات</TabsTrigger>
<TabsTrigger value="posts" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-4">المحتوى</TabsTrigger>
<TabsTrigger value="complaints" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-4">الشكاوى</TabsTrigger>
</TabsList>
{/* Followers */}
<TabsContent value="followers">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{followersList.map((f) => (
<div key={f.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-wine-100 flex items-center justify-center text-wine font-bold">
{f.name.charAt(0)}
</div>
<div className="flex-1">
<h4 className="font-semibold text-text-primary">{f.name}</h4>
<p className="text-xs text-text-muted">متابع منذ {f.followDate}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleBlock(f.id)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-sm font-medium transition-colors ${
f.isBlocked ? 'bg-danger/10 text-danger' : 'bg-cream text-text-secondary hover:bg-danger/10 hover:text-danger'
}`}
>
<Ban className="w-3.5 h-3.5" />
{f.isBlocked ? 'محظور' : 'حظر'}
</button>
</div>
</div>
))}
</div>
</TabsContent>
{/* Reviews */}
<TabsContent value="reviews">
<div className="space-y-4">
{reviewsList.map((r) => (
<div key={r.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-wine-100 flex items-center justify-center text-wine font-bold">
{r.clientName.charAt(0)}
</div>
<div>
<h4 className="font-semibold text-text-primary">{r.clientName}</h4>
<p className="text-xs text-text-muted">{r.service} {r.date}</p>
</div>
</div>
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star key={i} className={`w-4 h-4 ${i < r.rating ? 'text-gold fill-gold' : 'text-text-muted/30'}`} />
))}
</div>
</div>
<p className="text-sm text-text-secondary mb-3 pr-4 border-r-2 border-wine-100">{r.comment}</p>
{r.reply && (
<div className="bg-cream rounded-xl p-3 mb-3">
<div className="flex items-center gap-2 mb-1">
<Reply className="w-3.5 h-3.5 text-wine" />
<span className="text-xs font-semibold text-wine">رد الصالون</span>
</div>
<p className="text-sm text-text-secondary">{r.reply}</p>
</div>
)}
{replyingTo === r.id ? (
<div className="flex items-center gap-2">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="اكتب ردك..."
className="flex-1 px-4 py-2 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
autoFocus
/>
<button onClick={() => submitReply(r.id)} className="p-2 bg-wine text-white rounded-xl hover:bg-wine-dark transition-colors">
<Send className="w-4 h-4" />
</button>
</div>
) : (
<button onClick={() => setReplyingTo(r.id)} className="flex items-center gap-1.5 text-sm text-wine hover:text-wine-dark font-medium">
<Reply className="w-3.5 h-3.5" />
رد على التقييم
</button>
)}
</div>
))}
</div>
</TabsContent>
{/* Posts */}
<TabsContent value="posts">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{posts.map((p) => (
<div key={p.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<div className="w-full h-48 bg-wine-50 flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-wine/20" />
</div>
<div className="p-5">
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs px-2 py-0.5 rounded-lg ${p.type === 'story' ? 'bg-gold/10 text-gold-dark' : 'bg-wine/10 text-wine'}`}>
{p.type === 'story' ? 'قصة' : 'منشور'}
</span>
</div>
<p className="text-sm text-text-secondary mb-3">{p.caption}</p>
<div className="flex items-center gap-4 text-xs text-text-muted">
<span className="flex items-center gap-1">
<Heart className="w-3.5 h-3.5" /> {p.likes}
</span>
<span>{p.date}</span>
</div>
</div>
</div>
))}
</div>
</TabsContent>
{/* Complaints */}
<TabsContent value="complaints">
<div className="space-y-4">
{complaintsList.map((c) => (
<div key={c.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-warning/10 flex items-center justify-center">
<Flag className="w-5 h-5 text-warning" />
</div>
<div>
<h4 className="font-semibold text-text-primary">{c.subject}</h4>
<p className="text-xs text-text-muted">{c.clientName} {c.date}</p>
</div>
</div>
<StatusBadge status={c.status} />
</div>
<p className="text-sm text-text-secondary mb-3 pr-4 border-r-2 border-warning/20">{c.message}</p>
{c.response && (
<div className="bg-cream rounded-xl p-3">
<p className="text-xs font-semibold text-success mb-1">الرد:</p>
<p className="text-sm text-text-secondary">{c.response}</p>
</div>
)}
{c.status === 'open' && (
<button
onClick={() => resolveComplaint(c.id)}
className="mt-3 flex items-center gap-1.5 text-sm text-success hover:text-success/80 font-medium"
>
<ThumbsUp className="w-3.5 h-3.5" />
تحديد كمحلول
</button>
)}
</div>
))}
</div>
</TabsContent>
</Tabs>
</PageLayout>
);
}

186
src/pages/HealthRecords.tsx Normal file
View File

@ -0,0 +1,186 @@
import { useState } from 'react';
import { Search, Shield, AlertCircle, FileText, Clock, UserCheck } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import { clients, healthRecords } from '@/data/demoData';
import type { Client } from '@/types';
export default function HealthRecords() {
const [search, setSearch] = useState('');
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const filtered = clients.filter((c) =>
c.name.includes(search) && (c.healthConsent || !search)
);
const record = selectedClient ? healthRecords.find((r) => r.clientId === selectedClient.id) : null;
return (
<PageLayout title="السجل الصحي" subtitle="إدارة السجلات الصحية للعملاء مع حماية الخصوصية">
{/* Privacy Banner */}
<div className="bg-wine/5 border border-wine/20 rounded-2xl p-4 mb-5 flex items-center gap-3">
<Shield className="w-5 h-5 text-wine flex-shrink-0" />
<div>
<p className="text-sm font-semibold text-wine">حماية البيانات الصحية</p>
<p className="text-xs text-text-secondary">يتطلب عرض السجل الصحي موافقة صريحة من العميل. جميع البيانات مشفرة ومحمية.</p>
</div>
</div>
{/* Client Search */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-4 mb-5">
<div className="relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="البحث باسم العميل..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pr-10 pl-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Client List */}
<div className="lg:col-span-1 bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<div className="px-5 py-4 border-b border-border-color/50">
<h3 className="font-bold text-text-primary">العملاء</h3>
</div>
<div className="divide-y divide-border-color/30 max-h-[600px] overflow-y-auto">
{filtered.map((c) => (
<button
key={c.id}
onClick={() => setSelectedClient(c)}
className={`w-full flex items-center gap-3 px-5 py-3.5 text-right hover:bg-cream/50 transition-colors ${selectedClient?.id === c.id ? 'bg-wine-50' : ''}`}
>
<div className="relative">
<div className="w-10 h-10 rounded-full bg-wine-100 flex items-center justify-center text-wine font-bold text-sm">
{c.name.charAt(0)}
</div>
<div className={`absolute -bottom-0.5 -left-0.5 w-4 h-4 rounded-full border-2 border-white flex items-center justify-center ${c.healthConsent ? 'bg-success' : 'bg-warning'}`}>
{c.healthConsent ? (
<UserCheck className="w-2.5 h-2.5 text-white" />
) : (
<AlertCircle className="w-2.5 h-2.5 text-white" />
)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-text-primary truncate">{c.name}</p>
<p className="text-[10px] text-text-muted">
{c.healthConsent ? 'موافقة نشطة' : 'بانتظار الموافقة'}
</p>
</div>
</button>
))}
</div>
</div>
{/* Health Record Detail */}
<div className="lg:col-span-2">
{selectedClient ? (
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-6">
{/* Header */}
<div className="flex items-center gap-4 mb-6 pb-4 border-b border-border-color/50">
<div className="w-14 h-14 rounded-full bg-wine-100 flex items-center justify-center text-wine text-xl font-bold">
{selectedClient.name.charAt(0)}
</div>
<div>
<h3 className="text-lg font-bold text-text-primary">{selectedClient.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2.5 py-0.5 rounded-lg ${selectedClient.healthConsent ? 'bg-success/10 text-success' : 'bg-warning/10 text-warning'}`}>
{selectedClient.healthConsent ? '✓ موافقة صحية نشطة' : '⚠ في انتظار الموافقة'}
</span>
</div>
</div>
</div>
{selectedClient.healthConsent ? (
<div className="space-y-5">
{/* Allergies & Conditions */}
{(selectedClient.allergies.length > 0 || selectedClient.conditions.length > 0) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{selectedClient.allergies.length > 0 && (
<div className="bg-danger/5 border border-danger/20 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-danger" />
<h4 className="text-sm font-semibold text-danger">الحساسية</h4>
</div>
<div className="flex flex-wrap gap-1">
{selectedClient.allergies.map((a) => (
<span key={a} className="px-2 py-0.5 bg-danger/10 text-danger rounded-lg text-xs font-medium">{a}</span>
))}
</div>
</div>
)}
{selectedClient.conditions.length > 0 && (
<div className="bg-warning/5 border border-warning/20 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-warning" />
<h4 className="text-sm font-semibold text-warning">الحالات الطبية</h4>
</div>
<div className="flex flex-wrap gap-1">
{selectedClient.conditions.map((c) => (
<span key={c} className="px-2 py-0.5 bg-warning/10 text-warning rounded-lg text-xs font-medium">{c}</span>
))}
</div>
</div>
)}
</div>
)}
{/* Timeline */}
<div>
<h4 className="font-semibold text-text-primary mb-4 flex items-center gap-2">
<Clock className="w-4 h-4 text-wine" />
سجل الزيارات
</h4>
{record ? (
<div className="space-y-4">
{record.entries.map((entry) => (
<div key={entry.id} className="relative pr-6 pb-4 border-r-2 border-wine-100 last:pb-0">
<div className="absolute right-[-5px] top-0 w-2 h-2 rounded-full bg-wine" />
<div className="bg-cream rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-wine">{entry.service}</span>
<span className="text-xs text-text-muted">{entry.date}</span>
</div>
<p className="text-sm text-text-secondary mb-2">{entry.notes}</p>
{entry.conditions.length > 0 && (
<div className="flex flex-wrap gap-1">
{entry.conditions.map((cond) => (
<span key={cond} className="px-2 py-0.5 bg-white rounded-lg text-xs text-text-secondary border border-border-color/50">{cond}</span>
))}
</div>
)}
<p className="text-[10px] text-text-muted mt-2">بواسطة: {entry.addedBy}</p>
</div>
</div>
))}
</div>
) : (
<div className="bg-cream rounded-xl p-6 text-center">
<FileText className="w-8 h-8 text-text-muted mx-auto mb-2" />
<p className="text-sm text-text-muted">لا توجد سجلات زيارات مسجلة</p>
</div>
)}
</div>
</div>
) : (
<div className="bg-warning/5 border border-warning/20 rounded-xl p-6 text-center">
<AlertCircle className="w-10 h-10 text-warning mx-auto mb-3" />
<p className="text-sm font-semibold text-warning mb-1">العميل لم يمنح الموافقة بعد</p>
<p className="text-xs text-text-muted">يتطلب عرض السجل الصحي موافقة صريحة من العميل</p>
</div>
)}
</div>
) : (
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-10 text-center">
<Shield className="w-12 h-12 text-text-muted/30 mx-auto mb-3" />
<p className="text-text-muted">اختر عميلاً لعرض سجله الصحي</p>
</div>
)}
</div>
</div>
</PageLayout>
);
}

165
src/pages/Home.tsx Normal file
View File

@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import {
DollarSign, CalendarCheck, UserPlus, Star,
Clock, TrendingUp, Eye
} from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import PageLayout from '@/components/PageLayout';
import StatCard from '@/components/StatCard';
import StatusBadge from '@/components/StatusBadge';
import { dashboardStats, weeklyRevenue, bookings } from '@/data/demoData';
export default function Home() {
const [animateStats, setAnimateStats] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setAnimateStats(true), 100);
return () => clearTimeout(timer);
}, []);
const stats = [
{ label: 'الإيرادات', value: `${animateStats ? dashboardStats.totalRevenue.toLocaleString() : '0'} ر.س`, change: dashboardStats.revenueChange, icon: DollarSign, color: 'bg-wine' },
{ label: 'الحجوزات', value: animateStats ? dashboardStats.totalBookings.toString() : '0', change: dashboardStats.bookingsChange, icon: CalendarCheck, color: 'bg-wine-light' },
{ label: 'عملاء جدد', value: animateStats ? dashboardStats.newClients.toString() : '0', change: dashboardStats.clientsChange, icon: UserPlus, color: 'bg-success' },
{ label: 'التقييم', value: animateStats ? dashboardStats.averageRating.toString() : '0', change: dashboardStats.ratingChange, icon: Star, color: 'bg-gold' },
];
const recentBookings = bookings.slice(0, 6);
const popularServices = [
{ name: 'صبغ الشعر', count: 45, percent: 90 },
{ name: 'قص الشعر', count: 38, percent: 76 },
{ name: 'تنظيف بشرة', count: 32, percent: 64 },
{ name: 'مانيكير', count: 28, percent: 56 },
{ name: 'مساج', count: 22, percent: 44 },
{ name: 'ميكاب', count: 18, percent: 36 },
];
return (
<PageLayout title="لوحة التحكم" subtitle="نظرة عامة على أداء صالونك">
{/* Welcome Banner */}
<div className="wine-gradient rounded-2xl p-6 mb-6 text-white relative overflow-hidden">
<div className="absolute left-0 top-0 w-40 h-40 bg-white/5 rounded-full -translate-x-10 -translate-y-10" />
<div className="absolute left-20 bottom-0 w-24 h-24 bg-white/5 rounded-full translate-y-10" />
<div className="relative z-10">
<h3 className="text-xl font-bold mb-1">أهلاً بكِ في BeautyHub 👋</h3>
<p className="text-white/80 text-sm">لديك 5 حجوزات اليوم وطلب توظيف جديد</p>
<div className="flex items-center gap-3 mt-4">
<div className="bg-white/15 rounded-lg px-4 py-2 text-sm backdrop-blur">
<span className="text-white/60">اليوم: </span>
<span className="font-semibold">5 حجوزات</span>
</div>
<div className="bg-white/15 rounded-lg px-4 py-2 text-sm backdrop-blur">
<span className="text-white/60">إيرادات اليوم: </span>
<span className="font-semibold">2,400 ر.س</span>
</div>
</div>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{stats.map((s) => (
<StatCard key={s.label} label={s.label} value={s.value} change={s.change} icon={s.icon} iconColor={s.color} />
))}
</div>
{/* Two Column: Recent Bookings + Popular Services */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Recent Bookings */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border-color/50">
<h3 className="font-bold text-text-primary">أحدث الحجوزات</h3>
<button className="flex items-center gap-1 text-xs text-wine hover:text-wine-dark font-medium transition-colors">
<Eye className="w-3.5 h-3.5" />
عرض الكل
</button>
</div>
<div className="divide-y divide-border-color/30">
{recentBookings.map((b) => (
<div key={b.id} className="flex items-center gap-4 px-5 py-3.5 hover:bg-cream/50 transition-colors">
<div className="w-9 h-9 rounded-full bg-wine-100 flex items-center justify-center text-wine text-sm font-bold flex-shrink-0">
{b.clientName.charAt(0)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-text-primary truncate">{b.clientName}</p>
<p className="text-xs text-text-muted">{b.services.join(' + ')}</p>
</div>
<div className="text-left flex-shrink-0">
<p className="text-sm font-bold text-gold-dark">{b.amount} ر.س</p>
<StatusBadge status={b.status} />
</div>
</div>
))}
</div>
</div>
{/* Popular Services */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border-color/50">
<h3 className="font-bold text-text-primary">الخدمات الأكثر طلباً</h3>
<div className="flex items-center gap-1 text-xs text-text-muted">
<TrendingUp className="w-3.5 h-3.5" />
هذا الشهر
</div>
</div>
<div className="px-5 py-4 space-y-4">
{popularServices.map((s) => (
<div key={s.name}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium text-text-primary">{s.name}</span>
<span className="text-xs text-text-muted">{s.count} حجز</span>
</div>
<div className="w-full h-2 bg-cream rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-l from-wine to-wine-light transition-all duration-1000"
style={{ width: `${s.percent}%` }}
/>
</div>
</div>
))}
</div>
</div>
</div>
{/* Weekly Revenue Chart */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="flex items-center justify-between mb-6">
<h3 className="font-bold text-text-primary">الإيرادات الأسبوعية</h3>
<div className="flex items-center gap-1 text-xs text-text-muted">
<Clock className="w-3.5 h-3.5" />
آخر 7 أيام
</div>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={weeklyRevenue} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<defs>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7B2D4B" stopOpacity={0.15} />
<stop offset="95%" stopColor="#7B2D4B" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#E5DDD6" vertical={false} />
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#6B5B63' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#6B5B63' }} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: '1px solid #E5DDD6', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }}
formatter={(value: number) => [`${value.toLocaleString()} ر.س`, 'الإيرادات']}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#7B2D4B"
strokeWidth={2.5}
fill="url(#colorRevenue)"
dot={{ fill: '#D4A853', strokeWidth: 2, r: 4, stroke: '#fff' }}
activeDot={{ r: 6, fill: '#D4A853', stroke: '#7B2D4B', strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</PageLayout>
);
}

110
src/pages/Inventory.tsx Normal file
View File

@ -0,0 +1,110 @@
import { useState } from 'react';
import { Package, AlertTriangle, TrendingDown, Search } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import StatCard from '@/components/StatCard';
import { inventoryItems } from '@/data/demoData';
export default function Inventory() {
const [search, setSearch] = useState('');
const items = inventoryItems.filter((i) => i.name.includes(search) || i.category.includes(search));
const lowStock = items.filter((i) => i.currentQuantity <= i.minThreshold);
const getStockPercent = (current: number, initial: number) => Math.round((current / initial) * 100);
const getStockColor = (current: number, min: number) => {
if (current <= min) return 'bg-danger';
if (current <= min * 2) return 'bg-warning';
return 'bg-success';
};
return (
<PageLayout title="المخزون" subtitle="إدارة المواد والمستلزمات والتنبيهات">
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<StatCard label="إجمالي المواد" value={items.length} icon={Package} iconColor="bg-wine" />
<StatCard label="تنبيهات المخزون" value={lowStock.length} icon={AlertTriangle} iconColor="bg-warning" />
<StatCard label="مواد منخفضة" value={lowStock.length} icon={TrendingDown} iconColor="bg-danger" />
</div>
{/* Low Stock Alerts */}
{lowStock.length > 0 && (
<div className="bg-danger/5 border border-danger/20 rounded-2xl p-4 mb-5">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-5 h-5 text-danger" />
<h3 className="font-bold text-danger text-sm">تنبيه: مخزون منخفض</h3>
</div>
<div className="flex flex-wrap gap-2">
{lowStock.map((i) => (
<span key={i.id} className="px-3 py-1.5 bg-white rounded-xl text-sm border border-danger/20">
{i.name}: <strong className="text-danger">{i.currentQuantity}</strong> متبقي
</span>
))}
</div>
</div>
)}
{/* Search */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-4 mb-5">
<div className="relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="البحث في المخزون..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pr-10 pl-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border-color/50 bg-cream/50">
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">المادة</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الفئة</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">المخزون</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الحد الأدنى</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">السعر/وحدة</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">المورد</th>
</tr>
</thead>
<tbody className="divide-y divide-border-color/30">
{items.map((item) => (
<tr key={item.id} className="hover:bg-cream/30 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-sm font-semibold text-text-primary">{item.name}</p>
<p className="text-xs text-text-muted">{item.unit}</p>
</div>
</td>
<td className="px-5 py-3.5">
<span className="px-2 py-0.5 bg-wine-50 text-wine text-xs rounded-lg">{item.category}</span>
</td>
<td className="px-5 py-3.5">
<div className="w-full max-w-[120px]">
<div className="flex items-center justify-between text-xs mb-1">
<span className={item.currentQuantity <= item.minThreshold ? 'text-danger font-bold' : 'text-text-secondary'}>
{item.currentQuantity}
</span>
<span className="text-text-muted">/ {item.initialQuantity}</span>
</div>
<div className="w-full h-2 bg-cream rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${getStockColor(item.currentQuantity, item.minThreshold)}`}
style={{ width: `${getStockPercent(item.currentQuantity, item.initialQuantity)}%` }}
/>
</div>
</div>
</td>
<td className="px-5 py-3.5 text-sm text-text-secondary">{item.minThreshold}</td>
<td className="px-5 py-3.5 text-sm font-medium text-gold-dark">{item.costPerUnit} ر.س</td>
<td className="px-5 py-3.5 text-sm text-text-secondary">{item.supplier}</td>
</tr>
))}
</tbody>
</table>
</div>
</PageLayout>
);
}

131
src/pages/Reports.tsx Normal file
View File

@ -0,0 +1,131 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { Download, FileText, TrendingUp } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import { weeklyRevenue, staffMembers } from '@/data/demoData';
const serviceData = [
{ name: 'صبغ', value: 35 },
{ name: 'قص', value: 25 },
{ name: 'بشرة', value: 20 },
{ name: 'أظافر', value: 12 },
{ name: 'مساج', value: 8 },
];
const COLORS = ['#7B2D4B', '#9D4A6B', '#D4A853', '#4A8B6F', '#6B5B63'];
export default function Reports() {
return (
<PageLayout title="التقارير" subtitle="تحليلات وإحصائيات أداء الصالون">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
{[
{ label: 'إجمالي الحجوزات', value: '156', sub: '+8.3% هذا الشهر', icon: FileText, color: 'text-wine' },
{ label: 'الإيرادات', value: '48,750 ر.س', sub: '+12.5% هذا الشهر', icon: TrendingUp, color: 'text-gold-dark' },
{ label: 'المصروفات', value: '18,200 ر.س', sub: 'مواد وتشغيل', icon: TrendingUp, color: 'text-danger' },
{ label: 'صافي الربح', value: '30,550 ر.س', sub: '62.6% هامش ربح', icon: TrendingUp, color: 'text-success' },
].map((card) => (
<div key={card.label} className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<card.icon className={`w-5 h-5 ${card.color} mb-2`} />
<p className="text-xs text-text-muted">{card.label}</p>
<p className="text-lg font-bold text-text-primary mt-0.5">{card.value}</p>
<p className="text-xs text-text-muted mt-1">{card.sub}</p>
</div>
))}
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Revenue Chart */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="flex items-center justify-between mb-5">
<h3 className="font-bold text-text-primary">الإيرادات الأسبوعية</h3>
<button className="flex items-center gap-1.5 text-xs text-wine hover:text-wine-dark">
<Download className="w-3.5 h-3.5" />
تصدير
</button>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyRevenue}>
<CartesianGrid strokeDasharray="3 3" stroke="#E5DDD6" vertical={false} />
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#6B5B63' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#6B5B63' }} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: '1px solid #E5DDD6' }}
formatter={(value: number) => [`${value.toLocaleString()} ر.س`, 'الإيرادات']}
/>
<Bar dataKey="revenue" fill="#7B2D4B" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Service Popularity */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="flex items-center justify-between mb-5">
<h3 className="font-bold text-text-primary">شعبية الخدمات</h3>
<button className="flex items-center gap-1.5 text-xs text-wine hover:text-wine-dark">
<Download className="w-3.5 h-3.5" />
تصدير
</button>
</div>
<div className="h-64 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={serviceData}
cx="50%"
cy="50%"
outerRadius={90}
innerRadius={50}
dataKey="value"
strokeWidth={2}
stroke="#fff"
>
{serviceData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value: number) => [`${value}%`, '']} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-3 justify-center mt-2">
{serviceData.map((entry, index) => (
<div key={entry.name} className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: COLORS[index] }} />
<span className="text-xs text-text-secondary">{entry.name}</span>
</div>
))}
</div>
</div>
</div>
{/* Staff Performance */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<h3 className="font-bold text-text-primary mb-5">أداء الموظفين</h3>
<div className="space-y-4">
{staffMembers.map((s) => (
<div key={s.id} className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-wine-100 flex items-center justify-center text-wine font-bold text-sm flex-shrink-0">
{s.name.charAt(0)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold">{s.name}</span>
<span className="text-sm font-bold text-gold-dark">{s.rating} </span>
</div>
<div className="w-full h-2 bg-cream rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-l from-wine to-wine-light"
style={{ width: `${(s.rating / 5) * 100}%` }}
/>
</div>
</div>
</div>
))}
</div>
</div>
</PageLayout>
);
}

166
src/pages/Services.tsx Normal file
View File

@ -0,0 +1,166 @@
import { useState } from 'react';
import { Plus, Search, Pencil, ChevronDown, ChevronUp, Clock, DollarSign, HelpCircle, FileText } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import StatusBadge from '@/components/StatusBadge';
import { services } from '@/data/demoData';
// Types are used implicitly through data
export default function Services() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [expandedService, setExpandedService] = useState<string | null>(null);
const [serviceList, setServiceList] = useState(services);
const categories = ['all', ...new Set(services.map((s) => s.category))];
const filtered = serviceList.filter((s) => {
const matchSearch = s.name.includes(search) || s.description.includes(search);
const matchCategory = category === 'all' || s.category === category;
return matchSearch && matchCategory;
});
const toggleActive = (id: string) => {
setServiceList((prev) =>
prev.map((s) => (s.id === id ? { ...s, isActive: !s.isActive } : s))
);
};
return (
<PageLayout title="الخدمات" subtitle="إدارة الخدمات والأسعار والأسئلة التمهيدية">
{/* Action Bar */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-4 mb-5">
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="البحث في الخدمات..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pr-10 pl-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-4 py-2.5 border border-border-color rounded-xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-wine/30"
>
<option value="all">جميع الفئات</option>
{categories.filter(c => c !== 'all').map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<button className="flex items-center gap-2 bg-wine text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-wine-dark transition-colors shadow-lg shadow-wine/20">
<Plus className="w-4 h-4" />
إضافة خدمة
</button>
</div>
</div>
{/* Services Grid */}
<div className="space-y-3">
{filtered.map((service) => {
const isExpanded = expandedService === service.id;
return (
<div key={service.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
{/* Main Row */}
<div className="flex items-center gap-4 px-5 py-4 cursor-pointer" onClick={() => setExpandedService(isExpanded ? null : service.id)}>
<div className="w-12 h-12 rounded-xl bg-wine-50 flex items-center justify-center text-wine">
<FileText className="w-5 h-5" />
</div>
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-bold text-text-primary">{service.name}</h3>
<span className="px-2 py-0.5 bg-cream text-text-secondary text-xs rounded-lg">{service.category}</span>
</div>
<p className="text-xs text-text-muted mt-0.5 line-clamp-1">{service.description}</p>
</div>
<div className="hidden sm:flex items-center gap-6 flex-shrink-0">
<div className="text-center">
<div className="flex items-center gap-1 text-gold-dark">
<DollarSign className="w-3.5 h-3.5" />
<span className="text-sm font-bold">{service.price}</span>
</div>
<span className="text-[10px] text-text-muted">ر.س</span>
</div>
<div className="text-center">
<div className="flex items-center gap-1 text-text-secondary">
<Clock className="w-3.5 h-3.5" />
<span className="text-sm font-medium">{service.duration}</span>
</div>
<span className="text-[10px] text-text-muted">دقيقة</span>
</div>
<StatusBadge status={service.isActive ? 'active' : 'inactive'} />
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); toggleActive(service.id); }}
className={`relative w-11 h-6 rounded-full transition-colors ${service.isActive ? 'bg-success' : 'bg-text-muted/30'}`}
>
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${service.isActive ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<button className="p-1.5 rounded-lg hover:bg-wine-50 text-wine transition-colors">
<Pencil className="w-4 h-4" />
</button>
{isExpanded ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="border-t border-border-color/50 px-5 py-4 bg-cream/30">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Instructions */}
{service.instructions && (
<div>
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-wine" />
<h4 className="text-sm font-semibold">تعليمات الخدمة</h4>
</div>
<p className="text-sm text-text-secondary bg-white rounded-xl p-3 border border-border-color/50">{service.instructions}</p>
</div>
)}
{/* Pre-booking Questions */}
<div>
<div className="flex items-center gap-2 mb-2">
<HelpCircle className="w-4 h-4 text-gold-dark" />
<h4 className="text-sm font-semibold">أسئلة ما قبل الحجز</h4>
</div>
{service.questions.length > 0 ? (
<div className="space-y-2">
{service.questions.map((q) => (
<div key={q.id} className="flex items-center justify-between bg-white rounded-xl p-3 border border-border-color/50">
<span className="text-sm">{q.question}</span>
{q.required && <span className="text-[10px] bg-danger/10 text-danger px-2 py-0.5 rounded-lg">مطلوب</span>}
</div>
))}
</div>
) : (
<p className="text-sm text-text-muted bg-white rounded-xl p-3 border border-border-color/50">لا توجد أسئلة مضافة</p>
)}
</div>
{/* Materials */}
{service.materials.length > 0 && (
<div className="md:col-span-2">
<h4 className="text-sm font-semibold mb-2">المواد المستخدمة</h4>
<div className="flex flex-wrap gap-2">
{service.materials.map((m) => (
<span key={m.materialId} className="px-3 py-1.5 bg-white rounded-xl text-sm border border-border-color/50">
{m.quantityPerService} × مادة #{m.materialId}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</PageLayout>
);
}

195
src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,195 @@
import { useState } from 'react';
import { Upload, Clock, Shield, Award, Bell, Mail, MessageSquare, Smartphone } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
const weekDays = [
{ key: 'saturday', label: 'السبت' },
{ key: 'sunday', label: 'الأحد' },
{ key: 'monday', label: 'الاثنين' },
{ key: 'tuesday', label: 'الثلاثاء' },
{ key: 'wednesday', label: 'الأربعاء' },
{ key: 'thursday', label: 'الخميس' },
{ key: 'friday', label: 'الجمعة' },
];
export default function Settings() {
const [salonName, setSalonName] = useState('صالون سارة للتجميل');
const [description, setDescription] = useState('صالون متخصص في العناية بالشعر والبشرة والأظافر');
const [phone, setPhone] = useState('0501234567');
const [email, setEmail] = useState('salon@beautyhub.sa');
const [address, setAddress] = useState('الرياض، حي العليا');
const [schedule, setSchedule] = useState<Record<string, { open: string; close: string; isOpen: boolean }>>({
saturday: { open: '09:00', close: '17:00', isOpen: true },
sunday: { open: '09:00', close: '17:00', isOpen: true },
monday: { open: '09:00', close: '17:00', isOpen: true },
tuesday: { open: '09:00', close: '17:00', isOpen: true },
wednesday: { open: '09:00', close: '17:00', isOpen: true },
thursday: { open: '09:00', close: '21:00', isOpen: true },
friday: { open: '', close: '', isOpen: false },
});
const [notifications, setNotifications] = useState({ email: true, push: true, sms: false });
const toggleDay = (day: string) => {
setSchedule((prev) => ({ ...prev, [day]: { ...prev[day], isOpen: !prev[day].isOpen } }));
};
return (
<PageLayout title="الإعدادات" subtitle="إدارة الملف التعريفي وساعات العمل والتفضيلات">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Profile */}
<div className="lg:col-span-2 space-y-6">
{/* Salon Info */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-6">
<div className="flex items-center gap-2 mb-5">
<Shield className="w-5 h-5 text-wine" />
<h3 className="font-bold text-text-primary">الملف التعريفي</h3>
</div>
{/* Logo Upload */}
<div className="flex items-center gap-4 mb-6">
<div className="w-20 h-20 rounded-full bg-wine-100 flex items-center justify-center text-wine text-2xl font-bold border-2 border-dashed border-wine/30">
س
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-cream rounded-xl text-sm text-text-secondary hover:bg-cream-dark transition-colors">
<Upload className="w-4 h-4" />
تغيير الشعار
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">اسم الصالون</label>
<input
type="text"
value={salonName}
onChange={(e) => setSalonName(e.target.value)}
className="w-full px-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">رقم الهاتف</label>
<input
type="text"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full px-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">البريد الإلكتروني</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">العنوان</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="w-full px-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-secondary mb-1.5">الوصف</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-4 py-2.5 border border-border-color rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-wine/30 resize-none"
/>
</div>
</div>
</div>
{/* Working Hours */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-6">
<div className="flex items-center gap-2 mb-5">
<Clock className="w-5 h-5 text-wine" />
<h3 className="font-bold text-text-primary">ساعات العمل</h3>
</div>
<div className="space-y-3">
{weekDays.map((day) => {
const sched = schedule[day.key];
return (
<div key={day.key} className="flex items-center gap-4 p-3 bg-cream rounded-xl">
<button
onClick={() => toggleDay(day.key)}
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ${sched.isOpen ? 'bg-success' : 'bg-text-muted/30'}`}
>
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${sched.isOpen ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span className="text-sm font-medium w-16">{day.label}</span>
{sched.isOpen ? (
<div className="flex items-center gap-2 flex-1">
<input type="time" value={sched.open} onChange={(e) => setSchedule((prev) => ({ ...prev, [day.key]: { ...prev[day.key], open: e.target.value } }))} className="px-2 py-1 border border-border-color rounded-lg text-sm" />
<span className="text-text-muted">-</span>
<input type="time" value={sched.close} onChange={(e) => setSchedule((prev) => ({ ...prev, [day.key]: { ...prev[day.key], close: e.target.value } }))} className="px-2 py-1 border border-border-color rounded-lg text-sm" />
</div>
) : (
<span className="text-sm text-text-muted">مغلق</span>
)}
</div>
);
})}
</div>
</div>
{/* Certificates */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-6">
<div className="flex items-center gap-2 mb-5">
<Award className="w-5 h-5 text-gold-dark" />
<h3 className="font-bold text-text-primary">الشهادات والتراخيص</h3>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{['رخصة البلدية', 'شهادة صحية', 'ترخيص مزاولة'].map((cert) => (
<div key={cert} className="border-2 border-dashed border-border-color rounded-xl p-4 text-center hover:border-wine/30 transition-colors cursor-pointer">
<Upload className="w-6 h-6 text-text-muted mx-auto mb-2" />
<p className="text-xs text-text-secondary">{cert}</p>
</div>
))}
</div>
</div>
</div>
{/* Notifications */}
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 p-6 h-fit">
<div className="flex items-center gap-2 mb-5">
<Bell className="w-5 h-5 text-wine" />
<h3 className="font-bold text-text-primary">الإشعارات</h3>
</div>
<div className="space-y-4">
{[
{ key: 'email' as const, label: 'البريد الإلكتروني', icon: Mail, desc: 'إشعارات عبر البريد' },
{ key: 'push' as const, label: 'إشعارات المتصفح', icon: MessageSquare, desc: 'إشعارات فورية' },
{ key: 'sms' as const, label: 'الرسائل النصية', icon: Smartphone, desc: 'إشعارات عبر SMS' },
].map((n) => (
<div key={n.key} className="flex items-center justify-between py-3 border-b border-border-color/30 last:border-0">
<div className="flex items-center gap-3">
<n.icon className="w-4 h-4 text-text-muted" />
<div>
<p className="text-sm font-medium">{n.label}</p>
<p className="text-xs text-text-muted">{n.desc}</p>
</div>
</div>
<button
onClick={() => setNotifications((prev) => ({ ...prev, [n.key]: !prev[n.key] }))}
className={`relative w-11 h-6 rounded-full transition-colors ${notifications[n.key] ? 'bg-success' : 'bg-text-muted/30'}`}
>
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${notifications[n.key] ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
</div>
))}
</div>
<button className="w-full mt-6 bg-wine text-white py-3 rounded-xl font-medium hover:bg-wine-dark transition-colors shadow-lg shadow-wine/20">
حفظ الإعدادات
</button>
</div>
</div>
</PageLayout>
);
}

151
src/pages/Staff.tsx Normal file
View File

@ -0,0 +1,151 @@
import { useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Star, Phone, Mail, Clock, CheckCircle, XCircle, Briefcase, CalendarDays } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import StatusBadge from '@/components/StatusBadge';
import { staffMembers, jobRequests } from '@/data/demoData';
import type { Staff as StaffType } from '@/types';
const daysArabic: Record<string, string> = {
saturday: 'السبت', sunday: 'الأحد', monday: 'الاثنين',
tuesday: 'الثلاثاء', wednesday: 'الأربعاء', thursday: 'الخميس', friday: 'الجمعة'
};
export default function Staff() {
const [selectedStaff, setSelectedStaff] = useState<StaffType | null>(null);
return (
<PageLayout title="الموظفون" subtitle="إدارة فريق العمل وطلبات التوظيف">
<Tabs defaultValue="staff" className="w-full">
<TabsList className="bg-white border border-border-color rounded-xl p-1 mb-5">
<TabsTrigger value="staff" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-6">الموظفون</TabsTrigger>
<TabsTrigger value="jobs" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-6">طلبات التوظيف</TabsTrigger>
</TabsList>
<TabsContent value="staff">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{staffMembers.map((s) => (
<div key={s.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5 hover:shadow-card-hover transition-all">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-xl bg-wine-100 flex items-center justify-center text-wine text-xl font-bold">
{s.name.charAt(0)}
</div>
<div>
<h3 className="font-bold text-text-primary">{s.name}</h3>
<p className="text-sm text-text-secondary">{s.role}</p>
<div className="flex items-center gap-1 mt-0.5">
<Star className="w-3.5 h-3.5 text-gold fill-gold" />
<span className="text-xs font-semibold">{s.rating}</span>
</div>
</div>
</div>
<StatusBadge status={s.status} />
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-text-secondary">
<Briefcase className="w-3.5 h-3.5 text-text-muted" />
<span>{s.specialty}</span>
</div>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<Phone className="w-3.5 h-3.5 text-text-muted" />
<span>{s.phone}</span>
</div>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<Mail className="w-3.5 h-3.5 text-text-muted" />
<span>{s.email}</span>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-border-color/50">
<span className="text-xs text-text-muted">منذ {s.joinDate}</span>
<button onClick={() => setSelectedStaff(s)} className="text-xs text-wine hover:text-wine-dark font-medium">
عرض الجدول
</button>
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="jobs">
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border-color/50 bg-cream/50">
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الاسم</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">التخصص</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">نوع الطلب</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الحالة</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">التاريخ</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الإجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-color/30">
{jobRequests.map((j) => (
<tr key={j.id} className="hover:bg-cream/30">
<td className="px-5 py-3.5 text-sm font-semibold">{j.expertName}</td>
<td className="px-5 py-3.5 text-sm text-text-secondary">{j.specialty}</td>
<td className="px-5 py-3.5">
<span className={`text-xs px-2 py-1 rounded-lg ${j.requestType === 'permanent' ? 'bg-wine/10 text-wine' : 'bg-gold/10 text-gold-dark'}`}>
{j.requestType === 'permanent' ? 'دائم' : 'مؤقت'}
</span>
</td>
<td className="px-5 py-3.5"><StatusBadge status={j.status} /></td>
<td className="px-5 py-3.5 text-sm text-text-secondary">{j.requestDate}</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-1">
<button className="p-1.5 rounded-lg hover:bg-success/10 text-success transition-colors">
<CheckCircle className="w-4 h-4" />
</button>
<button className="p-1.5 rounded-lg hover:bg-danger/10 text-danger transition-colors">
<XCircle className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
</Tabs>
{/* Staff Schedule Modal */}
{selectedStaff && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => setSelectedStaff(null)} />
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 z-10 animate-fade-in-up">
<div className="p-6">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-bold">جدول {selectedStaff.name}</h3>
<button onClick={() => setSelectedStaff(null)} className="text-text-muted hover:text-text-primary">
<XCircle className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
{Object.entries(selectedStaff.schedule).map(([day, sched]) => (
<div key={day} className="flex items-center justify-between bg-cream rounded-xl p-3">
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-wine" />
<span className="text-sm font-medium">{daysArabic[day]}</span>
</div>
{sched.isOff ? (
<span className="text-xs bg-text-muted/10 text-text-muted px-3 py-1 rounded-lg">إجازة</span>
) : (
<div className="flex items-center gap-1.5 text-sm text-text-secondary">
<Clock className="w-3.5 h-3.5" />
<span>{sched.start} - {sched.end}</span>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
)}
</PageLayout>
);
}

125
src/pages/Store.tsx Normal file
View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Package, GraduationCap, DollarSign, Users, Award } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
import StatusBadge from '@/components/StatusBadge';
import { products, orders, courses } from '@/data/demoData';
export default function Store() {
const [productList, setProductList] = useState(products);
const toggleProduct = (id: string) => {
setProductList((prev) => prev.map((p) => (p.id === id ? { ...p, isActive: !p.isActive } : p)));
};
return (
<PageLayout title="المتجر" subtitle="إدارة المنتجات والطلبات والدورات التدريبية">
<Tabs defaultValue="products" className="w-full">
<TabsList className="bg-white border border-border-color rounded-xl p-1 mb-5">
<TabsTrigger value="products" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-5">المنتجات</TabsTrigger>
<TabsTrigger value="orders" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-5">الطلبات</TabsTrigger>
<TabsTrigger value="courses" className="rounded-lg data-[state=active]:bg-wine data-[state=active]:text-white px-5">الدورات</TabsTrigger>
</TabsList>
{/* Products Tab */}
<TabsContent value="products">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{productList.map((p) => (
<div key={p.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="w-full h-32 bg-wine-50 rounded-xl flex items-center justify-center mb-4">
<Package className="w-10 h-10 text-wine/30" />
</div>
<div className="flex items-start justify-between mb-2">
<h4 className="font-bold text-text-primary">{p.name}</h4>
<button
onClick={() => toggleProduct(p.id)}
className={`relative w-9 h-5 rounded-full transition-colors ${p.isActive ? 'bg-success' : 'bg-text-muted/30'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${p.isActive ? 'translate-x-4' : 'translate-x-0.5'}`} />
</button>
</div>
<p className="text-xs text-text-muted mb-3">{p.description}</p>
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-gold-dark">{p.price} ر.س</span>
<span className="text-xs text-text-secondary">{p.stock} متوفر</span>
</div>
</div>
))}
</div>
</TabsContent>
{/* Orders Tab */}
<TabsContent value="orders">
<div className="bg-white rounded-2xl shadow-card border border-border-color/50 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border-color/50 bg-cream/50">
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">رقم الطلب</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">العميل</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">المنتجات</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الإجمالي</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">الحالة</th>
<th className="px-5 py-3.5 text-right text-xs font-semibold text-text-muted">التاريخ</th>
</tr>
</thead>
<tbody className="divide-y divide-border-color/30">
{orders.map((o) => (
<tr key={o.id} className="hover:bg-cream/30">
<td className="px-5 py-3.5 text-sm font-semibold text-wine">{o.id}</td>
<td className="px-5 py-3.5 text-sm text-text-primary">{o.customerName}</td>
<td className="px-5 py-3.5">
<div className="flex flex-wrap gap-1">
{o.items.map((item, i) => (
<span key={i} className="text-xs bg-cream px-2 py-0.5 rounded-lg">{item.productName} ({item.quantity})</span>
))}
</div>
</td>
<td className="px-5 py-3.5 text-sm font-bold text-gold-dark">{o.total} ر.س</td>
<td className="px-5 py-3.5"><StatusBadge status={o.status} /></td>
<td className="px-5 py-3.5 text-sm text-text-secondary">{o.date}</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
{/* Courses Tab */}
<TabsContent value="courses">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{courses.map((c) => (
<div key={c.id} className="bg-white rounded-2xl shadow-card border border-border-color/50 p-5">
<div className="w-full h-32 bg-wine-50 rounded-xl flex items-center justify-center mb-4">
<GraduationCap className="w-10 h-10 text-wine/30" />
</div>
<h4 className="font-bold text-text-primary mb-1">{c.title}</h4>
<p className="text-xs text-text-muted mb-4">{c.description}</p>
<div className="flex items-center justify-between text-sm mb-3">
<div className="flex items-center gap-1 text-text-secondary">
<Users className="w-3.5 h-3.5" />
<span>{c.enrolledCount} مشترك</span>
</div>
<div className="flex items-center gap-1">
<DollarSign className="w-3.5 h-3.5 text-gold" />
<span className="font-bold text-gold-dark">{c.price}</span>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-border-color/50">
{c.hasCertificate && (
<div className="flex items-center gap-1 text-xs text-success">
<Award className="w-3.5 h-3.5" />
<span>شهادة معتمدة</span>
</div>
)}
<span className={`text-xs px-2 py-0.5 rounded-lg ${c.status === 'active' ? 'bg-success/10 text-success' : 'bg-text-muted/10 text-text-muted'}`}>
{c.status === 'active' ? 'نشط' : 'غير نشط'}
</span>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</PageLayout>
);
}

207
src/types/index.ts Normal file
View File

@ -0,0 +1,207 @@
export interface Booking {
id: string;
clientName: string;
clientAvatar: string;
services: string[];
date: string;
time: string;
duration: number;
status: 'pending' | 'confirmed' | 'completed' | 'cancelled';
amount: number;
notes: string;
preBookingAnswers: { question: string; answer: string }[];
staffName?: string;
}
export interface Service {
id: string;
name: string;
description: string;
price: number;
duration: number;
category: string;
image: string;
isActive: boolean;
instructions: string;
questions: PreBookingQuestion[];
materials: ServiceMaterial[];
}
export interface PreBookingQuestion {
id: string;
question: string;
required: boolean;
}
export interface ServiceMaterial {
materialId: string;
quantityPerService: number;
}
export interface Client {
id: string;
name: string;
avatar: string;
phone: string;
email: string;
visitCount: number;
totalSpent: number;
lastVisit: string;
loyaltyPoints: number;
birthDate: string;
healthConsent: boolean;
allergies: string[];
conditions: string[];
blocked: boolean;
}
export interface Staff {
id: string;
name: string;
avatar: string;
role: string;
specialty: string;
status: 'active' | 'on-leave' | 'inactive';
rating: number;
email: string;
phone: string;
schedule: WeeklySchedule;
joinDate: string;
}
export interface WeeklySchedule {
[key: string]: { start: string; end: string; isOff: boolean };
}
export interface JobRequest {
id: string;
expertName: string;
specialty: string;
requestType: 'temporary' | 'permanent';
status: 'pending' | 'approved' | 'rejected';
requestDate: string;
message: string;
}
export interface InventoryItem {
id: string;
name: string;
category: string;
unit: string;
currentQuantity: number;
initialQuantity: number;
minThreshold: number;
costPerUnit: number;
supplier: string;
lastRestocked: string;
}
export interface Product {
id: string;
name: string;
description: string;
price: number;
stock: number;
image: string;
isActive: boolean;
category: string;
}
export interface Order {
id: string;
customerName: string;
items: { productName: string; quantity: number; price: number }[];
total: number;
status: 'pending' | 'processing' | 'shipped' | 'delivered';
date: string;
}
export interface TrainingCourse {
id: string;
title: string;
description: string;
enrolledCount: number;
price: number;
image: string;
hasCertificate: boolean;
status: 'active' | 'inactive';
}
export interface Follower {
id: string;
name: string;
avatar: string;
followDate: string;
isFollowing: boolean;
isBlocked: boolean;
}
export interface Review {
id: string;
clientName: string;
avatar: string;
rating: number;
comment: string;
service: string;
date: string;
reply?: string;
}
export interface Complaint {
id: string;
clientName: string;
subject: string;
message: string;
date: string;
status: 'open' | 'resolved' | 'pending';
response?: string;
}
export interface HealthRecord {
clientId: string;
entries: HealthEntry[];
}
export interface HealthEntry {
id: string;
date: string;
service: string;
notes: string;
conditions: string[];
addedBy: string;
}
export interface Post {
id: string;
image: string;
caption: string;
likes: number;
date: string;
type: 'post' | 'story';
}
export interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
read: boolean;
date: string;
}
export interface DashboardStats {
totalRevenue: number;
totalBookings: number;
newClients: number;
averageRating: number;
revenueChange: number;
bookingsChange: number;
clientsChange: number;
ratingChange: number;
}
export interface WeeklyRevenue {
day: string;
revenue: number;
bookings: number;
}

132
tailwind.config.js Normal file
View File

@ -0,0 +1,132 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
wine: {
DEFAULT: "#7B2D4B",
light: "#9D4A6B",
dark: "#5E1F36",
50: "#F9F0F3",
100: "#F0D6E0",
200: "#E1ADBF",
300: "#D2849E",
400: "#C35B7D",
500: "#7B2D4B",
600: "#6A2640",
700: "#5E1F36",
800: "#4A1830",
900: "#361223",
},
gold: {
DEFAULT: "#D4A853",
light: "#E8C77A",
dark: "#B08A3D",
50: "#FBF6EC",
100: "#F5EBD0",
200: "#EBD7A0",
300: "#E8C77A",
400: "#D4A853",
500: "#B08A3D",
},
cream: {
DEFAULT: "#F5F0EB",
dark: "#E5DDD6",
},
success: "#4A8B6F",
warning: "#C9933E",
danger: "#B54A4A",
},
fontFamily: {
tajawal: ['Tajawal', 'Segoe UI', 'sans-serif'],
},
borderRadius: {
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xs: "calc(var(--radius) - 6px)",
},
boxShadow: {
xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
card: "0 2px 12px -2px rgb(123 45 75 / 0.08)",
"card-hover": "0 8px 25px -8px rgb(123 45 75 / 0.2)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
"slide-in-right": {
from: { transform: "translateX(100%)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
"fade-in-up": {
from: { transform: "translateY(10px)", opacity: "0" },
to: { transform: "translateY(0)", opacity: "1" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
"slide-in-right": "slide-in-right 0.3s ease-out",
"fade-in-up": "fade-in-up 0.3s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

34
tsconfig.app.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

18
vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { inspectAttr } from 'kimi-plugin-inspect-react'
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [inspectAttr(), react()],
server: {
port: 3000,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});