Initial commit: silicondeck/shadcn-dashboard-landing-template (nextjs-version) + CLAUDE.md

- Next.js 16.1.1 + React 19.2.3 + Tailwind v4 + shadcn/ui v3
- Template scaffold (App Router with (auth)/(dashboard)/landing route groups)
- pnpm v10 lockfile
- CLAUDE.md describing multi-tenant Appwrite architecture, 8 modules, Gitea+Coolify deploy
This commit is contained in:
kovakmedya
2026-04-30 02:28:30 +03:00
commit 29aa346f9e
256 changed files with 35982 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# claude code (local-only)
.claude/
+124
View File
@@ -0,0 +1,124 @@
# İşletmem KovakCRM
Multi-tenant CRM. KovakSoft müşterileri kendi şirketleri için müşteri / hizmet / yazılım / takvim / görev / finans yönetir. Her tenant kendi verilerini görür, çapraz erişim yok.
## Stack
- **Next.js 16** + **React 19** (App Router, TypeScript)
- **Tailwind CSS v4** + **shadcn/ui v3** (Radix primitives)
- **Zustand** — client state
- **TanStack Table**, **react-hook-form** + **Zod**, **dnd-kit** (Kanban), **Recharts** (template'den)
- **Appwrite** — DB, Auth, Storage, Teams (tenant izolasyonu)
- **pnpm** — package manager
- **Coolify** — Gitea webhook ile auto-deploy
Template kaynağı: `silicondeck/shadcn-dashboard-landing-template` (MIT). `nextjs-version/` klonlandı, görsel tasarım ve mevcut dashboard davranışı korunacak — sadece data layer Appwrite'e bağlanıyor.
## Multi-tenancy modeli
| Concept | Appwrite primitive |
|---|---|
| Tenant | Appwrite **Team** (1 team = 1 şirket) |
| Tenant üyesi | Team membership (rol: `owner` / `admin` / `member`) |
| Veri izolasyonu | Her doküman `tenantId` attribute + `Permission.read/update/delete(Role.team(tenantId))` |
| Tenant onboarding | İlk login'de team yoksa "create workspace" akışı |
| Üye daveti | Appwrite `teams.createMembership` (email + role) → davet linki → kabul ile team'e dahil |
**Kural:** Tüm collection'larda `tenantId` zorunlu attribute, indexed. Server-side query'lerde her zaman tenant filtresi uygulanır. Client SDK'da row-level security Appwrite Permissions ile garantilenir — kod hatasında dahi başka tenant verisi görünmez.
## Modüller
| Modül | Collection | Notlar |
|---|---|---|
| Müşteriler | `customers` | name, email, phone, taxId, address, notes |
| Hizmetler | `services` | name, description, unitPrice, currency, customerId (FK) |
| Yazılımlarımız | `software` + `customer_software` | software (name, version, license); customer_software (many-to-many: customerId × softwareId, startDate, endDate, fee) |
| Takvim | `calendar_events` | title, start, end, allDay, customerId?, color |
| Görevler (Kanban) | `tasks` | title, description, status (`backlog`/`todo`/`in_progress`/`done`), priority, dueDate, assigneeId |
| Finans | `finance_entries` | type (`income`/`expense`/`debt`/`receivable`), amount, currency, date, customerId?, invoiceId?, note |
| Faturalar | `invoices` + `invoice_items` | number, customerId, issueDate, dueDate, status, totals; items (description, qty, unitPrice, vat) |
| Profil ayarları | Appwrite user prefs + `tenant_settings` | tenant logo, currency default, vat default |
Tüm collection'larda ortak: `tenantId`, `createdBy` (userId), `$createdAt`, `$updatedAt`.
## Auth
Appwrite Auth — email/password (başlangıçta). Sonrası: magic URL + Google OAuth.
- Login → team list çekilir → tek team varsa direkt dashboard, çoksa workspace switcher.
- `lib/appwrite/server.ts` — server SDK (API key ile admin ops, server actions).
- `lib/appwrite/client.ts` — browser SDK (session JWT, client components).
- Middleware: korumalı route'lar `(dashboard)/*`, public `(auth)/*` ve marketing.
## Klasör yapısı (template'den korunan)
```
src/
├── app/
│ ├── (auth)/ login, register, reset-password
│ ├── (dashboard)/ customers, services, software, calendar, tasks, finance, invoices, settings
│ └── landing/ marketing
├── assets/ SVG, images
├── components/ shadcn/ui + custom
├── config/ site config, nav
├── contexts/ React contexts
├── hooks/
├── lib/
│ ├── appwrite/ client.ts, server.ts, schema.ts (eklenecek)
│ └── tenant/ resolveTenant(), withTenant() (eklenecek)
├── middleware.ts auth/route protection
├── types/
└── utils/
```
## Komutlar
```bash
pnpm dev # localhost:3000
pnpm build
pnpm lint
pnpm typecheck # tsc --noEmit
```
## Appwrite — MCP üzerinden işlemler
Database, collection, attribute, index, permission CRUD'u **Appwrite MCP** ile yapılır (manual console tıklaması yok). Migration mantığı: yeni collection / attribute eklendiğinde MCP komutu çalıştır + `lib/appwrite/schema.ts` güncellenir (tek source of truth, tipler oradan üretilir).
```
# MCP tool format
tool: tables_db_create | tables_db_create_table | tables_db_create_string_attribute | ...
```
## Gitea + Coolify deploy
- **Repo:** `ssh://git@git.kovaksoft.com:2222/kovakmedya/isletmem-kovakcrm.git`
- **Coolify host:** `kovaksoft-coolify` (`ssh -p 22 root@194.31.52.65`)
- **Production domain:** `https://isletmem.kovakcrm.com`
- **Workflow:** `main` branch'e push → Gitea webhook → Coolify auto-deploy.
- Coolify'a Appwrite ENV'leri girilir (aşağı bak), build command `pnpm build`, start `pnpm start`.
## Environment variables
```
NEXT_PUBLIC_APPWRITE_ENDPOINT=
NEXT_PUBLIC_APPWRITE_PROJECT_ID=
APPWRITE_API_KEY= # server-only
NEXT_PUBLIC_APPWRITE_DATABASE_ID=
APP_URL=https://isletmem.kovakcrm.com
```
`.env.local` git'e gitmez. Coolify'da ayrı set edilir.
## Geliştirme prensipleri
- **Template görselini bozma.** Sayfa layout, sidebar, theme, component'ler aynı kalır; sadece içerikteki demo data Appwrite'a bağlanır.
- **Tenant filtresi şart.** Server actions / route handlers'da `tenantId`'yi her query'ye eklemeden veri çekme.
- **Server actions tercih edilir** — client'tan direkt Appwrite write yerine, server action içinden server SDK ile (validation + audit kolay).
- **Schema değişikliği = MCP çağrısı + `schema.ts` update + migration notu** (commit mesajına `db:` prefix).
- **Türkçe UI**, kod İngilizce.
## Faydalı referanslar
- Gitea CLI: `tea repos list --login git.kovaksoft.com`
- Coolify VPS SSH: `ssh kovaksoft-coolify`
- Appwrite docs: https://appwrite.io/docs
- Template repo: https://github.com/silicondeck/shadcn-dashboard-landing-template
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+59
View File
@@ -0,0 +1,59 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons"],
},
turbopack: {},
// Image optimization
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'ui.shadcn.com',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
formats: ['image/webp', 'image/avif'],
},
// Headers for better security and performance
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
];
},
// Redirects for better SEO
async redirects() {
return [
{
source: '/home',
destination: '/dashboard',
permanent: true,
},
];
},
};
export default nextConfig;
+71
View File
@@ -0,0 +1,71 @@
{
"name": "isletmem-kovakcrm",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@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-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-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",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.69.0",
"react-resizable-panels": "^3.0.4",
"recharts": "3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.2",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3"
}
}
+6024
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function ForbiddenError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>403</h1>
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
<p>Access to this resource is forbidden. You don&apos;t have the necessary permissions to view this page.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { ForbiddenError } from "./components/forbidden-error"
export default function ForbiddenPage() {
return <ForbiddenError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function InternalServerError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>500</h1>
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
<p>Something went wrong on our end. We&apos;re working to fix the issue. Please try again later.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { InternalServerError } from "./components/internal-server-error"
export default function InternalServerErrorPage() {
return <InternalServerError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function NotFoundError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>404</h1>
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
<p>The page you are looking for doesn&apos;t exist or has been moved to another location.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundError } from "./components/not-found-error"
export default function NotFoundPage() {
return <NotFoundError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function UnauthorizedError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>401</h1>
<h2 className="mb-3 text-2xl font-semibold">Unauthorized</h2>
<p>You don&apos;t have permission to access this resource. Please sign in or contact your administrator.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { UnauthorizedError } from "./components/unauthorized-error"
export default function UnauthorizedPage() {
return <UnauthorizedError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function UnderMaintenanceError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>503</h1>
<h2 className="mb-3 text-2xl font-semibold">Under Maintenance</h2>
<p>The service is currently unavailable. Please try again later.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { UnderMaintenanceError } from "./components/under-maintenance-error"
export default function UnderMaintenancePage() {
return <UnderMaintenanceError />
}
@@ -0,0 +1,37 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function ForgotPasswordForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Forgot your password?</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email address and we&apos;ll send you a link to reset your password
</p>
</div>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Send Reset Link
</Button>
</div>
<div className="text-center text-sm">
Remember your password?{" "}
<a href="/auth/sign-in-2" className="underline underline-offset-4">
Back to sign in
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { ForgotPasswordForm2 } from "./components/forgot-password-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function ForgotPassword2Page() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<ForgotPasswordForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function ForgotPasswordForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Forgot your password?</h1>
<p className="text-muted-foreground text-balance">
Enter your email to reset your ShadcnStore account password
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<Button type="submit" className="w-full cursor-pointer">
Send Reset Link
</Button>
<div className="text-center text-sm">
Remember your password?{" "}
<a href="/auth/sign-in-3" className="underline underline-offset-4">
Back to sign in
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import { ForgotPasswordForm3 } from "./components/forgot-password-form-3"
export default function ForgotPassword3Page() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<ForgotPasswordForm3 className="w-full max-w-sm md:max-w-4xl" />
</div>
)
}
@@ -0,0 +1,57 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function ForgotPasswordForm1({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Forgot your password?</CardTitle>
<CardDescription>
Enter your email address and we&apos;ll send you a link to reset your password
</CardDescription>
</CardHeader>
<CardContent>
<form>
<div className="grid gap-6">
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<Button type="submit" className="w-full cursor-pointer">
Send Reset Link
</Button>
</div>
<div className="text-center text-sm">
Remember your password?{" "}
<a href="/auth/sign-in" className="underline underline-offset-4">
Back to sign in
</a>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { ForgotPasswordForm1 } from "./components/forgot-password-form-1"
import { Logo } from "@/components/logo"
import Link from "next/link"
export default function ForgotPasswordPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link href="/" className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
<ForgotPasswordForm1 />
</div>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Authentication - ShadcnStore",
description: "Sign in to your account or create a new one",
};
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-background">
{children}
</div>
);
}
@@ -0,0 +1,63 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function LoginForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props} action="/dashboard">
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Login to your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account
</p>
</div>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="test@example.com" defaultValue="test@example.com" required />
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password-2"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" defaultValue="password" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Login
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button variant="outline" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Login with GitHub
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/auth/sign-up-2" className="underline underline-offset-4">
Sign up
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { LoginForm2 } from "./components/login-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function LoginPage() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<LoginForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,119 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function LoginForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" action="/dashboard">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Welcome back</h1>
<p className="text-muted-foreground text-balance">
Login to your ShadcnStore account
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="test@example.com"
defaultValue="test@example.com"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password-3"
className="ml-auto text-sm underline-offset-2 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" defaultValue="password" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Login
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Apple</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Google</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Meta</span>
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/auth/sign-up-3" className="underline underline-offset-4">
Sign up
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { LoginForm3 } from "./components/login-form-3"
export default function LoginPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm3 />
</div>
</div>
)
}
@@ -0,0 +1,127 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
const loginFormSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
})
type LoginFormValues = z.infer<typeof loginFormSchema>
export function LoginForm1({
className,
...props
}: React.ComponentProps<"div">) {
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: "test@example.com",
password: "password",
},
})
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form action="/">
<div className="grid gap-6">
<div className="grid gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="test@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Password</FormLabel>
<a
href="/auth/forgot-password"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full cursor-pointer">
Login
</Button>
<Button variant="outline" className="w-full cursor-pointer" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Login with Google
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/auth/sign-up" className="underline underline-offset-4">
Sign up
</a>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { LoginForm1 } from "./components/login-form-1"
import { Logo } from "@/components/logo"
import Link from "next/link"
export default function Page() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link href="/" className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
<LoginForm1 />
</div>
</div>
)
}
@@ -0,0 +1,83 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
export function SignupForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your information to create a new account
</p>
</div>
<div className="grid gap-6">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" placeholder="John" required />
</div>
<div className="grid gap-3">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" placeholder="Doe" required />
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" required />
</div>
<div className="grid gap-3">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required />
</div>
<div className="grid gap-3">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" required />
</div>
<div className="flex items-center space-x-2">
<Checkbox id="terms" required />
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
</Label>
</div>
<Button type="submit" className="w-full cursor-pointer">
Create Account
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button variant="outline" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Sign up with GitHub
</Button>
</div>
<div className="text-center text-sm">
Already have an account?{" "}
<a href="/auth/sign-in-2" className="underline underline-offset-4">
Sign in
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { SignupForm2 } from "./components/signup-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function SignUp2Page() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<SignupForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function SignupForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-balance">
Enter your information to create a new account
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
placeholder="John"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
placeholder="Doe"
required
/>
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required />
</div>
<div className="grid gap-3">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" required />
</div>
<div className="flex items-center space-x-2">
<Checkbox id="terms" required />
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
</Label>
</div>
<Button type="submit" className="w-full cursor-pointer">
Create Account
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Apple</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Google</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Meta</span>
</Button>
</div>
<div className="text-center text-sm">
Already have an account?{" "}
<a href="/auth/sign-in-3" className="underline underline-offset-4">
Sign in
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { SignupForm3 } from "./components/signup-form-3"
export default function SignUp3Page() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<SignupForm3 className="w-full max-w-5xl" />
</div>
)
}
@@ -0,0 +1,195 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Checkbox } from "@/components/ui/checkbox"
const signupFormSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Please confirm your password"),
terms: z.boolean().refine(val => val === true, "You must agree to the terms"),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
type SignupFormValues = z.infer<typeof signupFormSchema>
export function SignupForm1({
className,
...props
}: React.ComponentProps<"div">) {
const form = useForm<SignupFormValues>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
terms: false,
},
})
function onSubmit(data: SignupFormValues) {
console.log("Signup attempt:", data)
// Here you would typically handle the signup
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Create Account</CardTitle>
<CardDescription>
Enter your information to create a new account
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-6">
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="m@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="terms"
render={({ field }) => (
<FormItem className="flex items-start space-x-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
className="mt-0.5"
/>
</FormControl>
<FormLabel className="text-sm">
I agree to the terms of service and privacy policy
</FormLabel>
</FormItem>
)}
/>
<Button type="submit" className="w-full cursor-pointer">
Create Account
</Button>
<Button variant="outline" className="w-full cursor-pointer" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Sign up with Google
</Button>
</div>
<div className="text-center text-sm">
Already have an account?{" "}
<a href="/auth/sign-in" className="underline underline-offset-4">
Sign in
</a>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { SignupForm1 } from "./components/signup-form-1"
import { Logo } from "@/components/logo"
import Link from "next/link"
export default function SignUpPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link href="/" className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
<SignupForm1 />
</div>
</div>
)
}
@@ -0,0 +1,347 @@
"use client"
import { useState } from "react"
import {
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
Clock,
MapPin,
Users,
MoreHorizontal,
Search,
Grid3X3,
List,
ChevronDown,
Menu
} from "lucide-react"
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Input } from "@/components/ui/input"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import { type CalendarEvent } from "../types"
// Import data
import eventsData from "../data/events.json"
interface CalendarMainProps {
selectedDate?: Date
onDateSelect?: (date: Date) => void
onMenuClick?: () => void
events?: CalendarEvent[]
onEventClick?: (event: CalendarEvent) => void
}
export function CalendarMain({ selectedDate, onDateSelect, onMenuClick, events, onEventClick }: CalendarMainProps) {
// Convert JSON events to CalendarEvent objects with proper Date objects, fallback to imported data
const sampleEvents: CalendarEvent[] = events || eventsData.map(event => ({
...event,
date: new Date(event.date),
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
}))
const [currentDate, setCurrentDate] = useState(selectedDate || new Date())
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
const [showEventDialog, setShowEventDialog] = useState(false)
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
const monthStart = startOfMonth(currentDate)
const monthEnd = endOfMonth(currentDate)
// Extend to show full weeks (including previous/next month days)
const calendarStart = new Date(monthStart)
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
const calendarEnd = new Date(monthEnd)
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
const getEventsForDay = (date: Date) => {
return sampleEvents.filter(event => isSameDay(event.date, date))
}
const navigateMonth = (direction: "prev" | "next") => {
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
}
const goToToday = () => {
setCurrentDate(new Date())
}
const handleEventClick = (event: CalendarEvent) => {
if (onEventClick) {
onEventClick(event)
} else {
setSelectedEvent(event)
setShowEventDialog(true)
}
}
const renderCalendarGrid = () => {
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
return (
<div className="flex-1 bg-background">
{/* Calendar Header */}
<div className="grid grid-cols-7 border-b">
{weekDays.map(day => (
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
{day}
</div>
))}
</div>
{/* Calendar Body */}
<div className="grid grid-cols-7 flex-1">
{calendarDays.map(day => {
const dayEvents = getEventsForDay(day)
const isCurrentMonth = isSameMonth(day, currentDate)
const isDayToday = isToday(day)
const isSelected = selectedDate && isSameDay(day, selectedDate)
return (
<div
key={day.toISOString()}
className={cn(
"min-h-[120px] border-r border-b last:border-r-0 p-2 cursor-pointer transition-colors",
isCurrentMonth ? "bg-background hover:bg-accent/50" : "bg-muted/30 text-muted-foreground",
isSelected && "ring-2 ring-primary ring-inset",
isDayToday && "bg-accent/20"
)}
onClick={() => onDateSelect?.(day)}
>
<div className="flex items-center justify-between mb-1">
<span className={cn(
"text-sm font-medium",
isDayToday && "bg-primary text-primary-foreground rounded-md w-6 h-6 flex items-center justify-center text-xs"
)}>
{format(day, 'd')}
</span>
{dayEvents.length > 2 && (
<span className="text-xs text-muted-foreground">
+{dayEvents.length - 2}
</span>
)}
</div>
<div className="space-y-1">
{dayEvents.slice(0, 2).map(event => (
<div
key={event.id}
className={cn(
"text-xs p-1 rounded-sm text-white cursor-pointer truncate",
event.color
)}
onClick={(e) => {
e.stopPropagation()
handleEventClick(event)
}}
>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span className="truncate">{event.title}</span>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
const renderListView = () => {
const upcomingEvents = sampleEvents
.filter(event => event.date >= new Date())
.sort((a, b) => a.date.getTime() - b.date.getTime())
return (
<div className="flex-1 p-6">
<div className="space-y-4">
{upcomingEvents.map(event => (
<Card key={event.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleEventClick(event)}>
<CardContent className="px-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={cn("w-3 h-3 rounded-full mt-1.5", event.color)} />
<div className="flex-1">
<h3 className="font-medium">{event.title}</h3>
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<div className="flex items-center flex-wrap gap-1">
<CalendarIcon className="w-4 h-4" />
{format(event.date, 'MMM d, yyyy')}
</div>
<div className="flex items-center flex-wrap gap-1">
<Clock className="w-4 h-4" />
{event.time}
</div>
<div className="flex items-center flex-wrap gap-1">
<MapPin className="w-4 h-4" />
{event.location}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{event.attendees.slice(0, 3).map((attendee, index) => (
<Avatar key={index} className="border-2 border-background">
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
</Avatar>
))}
</div>
<Button variant="ghost" size="sm" className="cursor-pointer">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex flex-col flex-wrap gap-4 p-6 border-b md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4 flex-wrap">
{/* Mobile Menu Button */}
<Button
variant="outline"
size="sm"
className="xl:hidden cursor-pointer"
onClick={onMenuClick}
>
<Menu className="w-4 h-4" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigateMonth("prev")} className="cursor-pointer">
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => navigateMonth("next")} className="cursor-pointer">
<ChevronRight className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={goToToday} className="cursor-pointer">
Today
</Button>
</div>
<h1 className="text-2xl font-semibold">
{format(currentDate, 'MMMM yyyy')}
</h1>
</div>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
{/* Search */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search events..." className="pl-10 w-64" />
</div>
{/* View Mode Toggle */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="cursor-pointer">
{viewMode === "month" && <Grid3X3 className="w-4 h-4 mr-2" />}
{viewMode === "list" && <List className="w-4 h-4 mr-2" />}
{viewMode.charAt(0).toUpperCase() + viewMode.slice(1)}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setViewMode("month")} className="cursor-pointer">
<Grid3X3 className="w-4 h-4 mr-2" />
Month
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setViewMode("list")} className="cursor-pointer">
<List className="w-4 h-4 mr-2" />
List
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Calendar Content */}
{viewMode === "month" ? renderCalendarGrid() : renderListView()}
{/* Event Detail Dialog */}
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{selectedEvent?.title || "Event Details"}</DialogTitle>
<DialogDescription>
View and manage this calendar event
</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<span>{format(selectedEvent.date, 'EEEE, MMMM d, yyyy')}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<span>{selectedEvent.time} ({selectedEvent.duration})</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-muted-foreground" />
<span>{selectedEvent.location}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" />
<div className="flex items-center gap-2">
<span>Attendees:</span>
<div className="flex -space-x-2">
{selectedEvent.attendees.map((attendee: string, index: number) => (
<Avatar key={index} className="w-6 h-6 border-2 border-background">
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
{selectedEvent.type}
</Badge>
</div>
<div className="flex gap-2 pt-4">
<Button variant="outline" className="flex-1 cursor-pointer" onClick={() => {
setShowEventDialog(false)
}}>Edit</Button>
<Button variant="destructive" className="flex-1 cursor-pointer" onClick={() => {
setShowEventDialog(false)
}}>Delete</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,78 @@
"use client"
import { Plus } from "lucide-react"
import { Calendars } from "./calendars"
import { DatePicker } from "./date-picker"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
interface CalendarSidebarProps {
selectedDate?: Date
onDateSelect?: (date: Date) => void
onNewCalendar?: () => void
onNewEvent?: () => void
events?: Array<{ date: Date; count: number }>
className?: string
}
export function CalendarSidebar({
selectedDate,
onDateSelect,
onNewCalendar,
onNewEvent,
events = [],
className
}: CalendarSidebarProps) {
return (
<div className={`flex flex-col h-full bg-background rounded-lg ${className}`}>
{/* Add New Event Button */}
<div className="p-6 border-b">
<Button
className="w-full cursor-pointer"
onClick={onNewEvent}
>
<Plus className="w-4 h-4 mr-2" />
Add New Event
</Button>
</div>
{/* Date Picker */}
<DatePicker
selectedDate={selectedDate}
onDateSelect={onDateSelect}
events={events}
/>
<Separator />
{/* Calendars */}
<div className="flex-1 p-4">
<Calendars
onNewCalendar={onNewCalendar}
onCalendarToggle={(calendarId, visible) => {
console.log(`Calendar ${calendarId} visibility: ${visible}`)
}}
onCalendarEdit={(calendarId) => {
console.log(`Edit calendar: ${calendarId}`)
}}
onCalendarDelete={(calendarId) => {
console.log(`Delete calendar: ${calendarId}`)
}}
/>
</div>
{/* Footer */}
<div className="p-4 border-t">
<Button
variant="outline"
className="w-full justify-start cursor-pointer"
onClick={onNewCalendar}
>
<Plus className="w-4 h-4 mr-2" />
New Calendar
</Button>
</div>
</div>
)
}
@@ -0,0 +1,381 @@
"use client"
import { useState } from "react"
import {
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
Clock,
MapPin,
Users,
Search,
Grid3X3,
List,
ChevronDown,
Menu,
Plus
} from "lucide-react"
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Calendar } from "@/components/ui/calendar"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import { type CalendarEvent } from "../types"
// Import data
import eventsData from "../data/events.json"
import calendarsData from "../data/calendars.json"
interface CalendarMainProps {
eventDates?: Array<{ date: Date; count: number }>
}
export function CalendarMain({ eventDates = [] }: CalendarMainProps) {
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
const [currentDate, setCurrentDate] = useState(new Date())
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
const [showEventDialog, setShowEventDialog] = useState(false)
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
// Convert JSON events to CalendarEvent objects with proper Date objects
const sampleEvents: CalendarEvent[] = eventsData.map(event => ({
...event,
date: new Date(event.date),
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
}))
const monthStart = startOfMonth(currentDate)
const monthEnd = endOfMonth(currentDate)
// Extend to show full weeks (including previous/next month days)
const calendarStart = new Date(monthStart)
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
const calendarEnd = new Date(monthEnd)
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
const getEventsForDay = (date: Date) => {
return sampleEvents.filter(event => isSameDay(event.date, date))
}
const navigateMonth = (direction: "prev" | "next") => {
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
}
const goToToday = () => {
setCurrentDate(new Date())
}
const handleEventClick = (event: CalendarEvent) => {
setSelectedEvent(event)
setShowEventDialog(true)
}
const handleDateSelect = (date: Date) => {
setSelectedDate(date)
}
const handleNewCalendar = () => {
console.log("Creating new calendar")
// In a real app, this would open a new calendar form
}
const handleNewEvent = () => {
console.log("Creating new event")
// In a real app, this would open event form
}
const renderCalendarGrid = () => {
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
return (
<div className="flex-1 bg-background">
{/* Calendar Header */}
<div className="grid grid-cols-7 border-b">
{weekDays.map(day => (
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 min-h-[600px]">
{calendarDays.map((day) => {
const dayEvents = getEventsForDay(day)
const isCurrentMonth = isSameMonth(day, currentDate)
const isDayToday = isToday(day)
const isSelected = isSameDay(day, selectedDate)
return (
<div
key={day.toISOString()}
className={cn(
"relative border-r border-b last:border-r-0 p-2 min-h-[120px] hover:bg-muted/50 cursor-pointer transition-colors",
!isCurrentMonth && "text-muted-foreground bg-muted/20",
isDayToday && "bg-blue-50 dark:bg-blue-900/20",
isSelected && "bg-blue-100 dark:bg-blue-800/30"
)}
onClick={() => handleDateSelect(day)}
>
{/* Date Number */}
<div className={cn(
"text-sm font-medium mb-1",
isDayToday && "text-blue-600 dark:text-blue-400"
)}>
{format(day, 'd')}
</div>
{/* Events */}
<div className="space-y-1">
{dayEvents.slice(0, 3).map((event) => (
<div
key={event.id}
className={cn(
"text-xs px-2 py-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity truncate",
event.color
)}
onClick={(e) => {
e.stopPropagation()
handleEventClick(event)
}}
>
{event.time} {event.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-xs text-muted-foreground px-2">
+{dayEvents.length - 3} more
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
const renderSidebar = () => (
<div className="w-full h-full bg-background border-r">
<div className="p-4 border-b">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">Calendar</h2>
<Button size="sm" onClick={handleNewEvent}>
<Plus className="h-4 w-4 mr-1" />
Event
</Button>
</div>
{/* Date Picker */}
<Calendar
mode="single"
selected={selectedDate}
onSelect={(date) => date && handleDateSelect(date)}
className="rounded-md border"
modifiers={{
eventDay: eventDates.map(ed => ed.date)
}}
modifiersStyles={{
eventDay: { fontWeight: 'bold' }
}}
/>
</div>
{/* Mini Calendars List */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">My Calendars</h3>
<Button variant="ghost" size="sm" onClick={handleNewCalendar}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{calendarsData.map((calendar) => (
<div key={calendar.id} className="flex items-center space-x-2">
<div className={cn("w-3 h-3 rounded-full", calendar.color)} />
<span className="text-sm">{calendar.name}</span>
</div>
))}
</div>
</div>
</div>
)
return (
<div className="border rounded-lg bg-background relative">
<div className="flex min-h-[800px]">
{/* Desktop Sidebar */}
<div className="hidden xl:block w-80 flex-shrink-0">
{renderSidebar()}
</div>
{/* Main Calendar Panel */}
<div className="flex-1 min-w-0">
{/* Calendar Toolbar */}
<div className="border-b bg-background px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{/* Mobile Menu Button */}
<Button
variant="ghost"
size="sm"
className="xl:hidden"
onClick={() => setShowCalendarSheet(true)}
>
<Menu className="h-4 w-4" />
</Button>
{/* Month Navigation */}
<div className="flex items-center space-x-2">
<Button variant="ghost" size="sm" onClick={() => navigateMonth("prev")}>
<ChevronLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold min-w-[140px] text-center">
{format(currentDate, 'MMMM yyyy')}
</h2>
<Button variant="ghost" size="sm" onClick={() => navigateMonth("next")}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={goToToday}>
Today
</Button>
</div>
<div className="flex items-center space-x-2">
<div className="hidden sm:flex items-center space-x-2">
<Button variant="ghost" size="sm" className="text-xs">
<Search className="h-4 w-4 mr-1" />
Search
</Button>
</div>
{/* View Mode Toggle */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Grid3X3 className="h-4 w-4 mr-1" />
{viewMode === "month" ? "Month" : viewMode === "week" ? "Week" : viewMode === "day" ? "Day" : "List"}
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setViewMode("month")}>
<Grid3X3 className="h-4 w-4 mr-2" />
Month
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setViewMode("week")}>
<List className="h-4 w-4 mr-2" />
Week
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setViewMode("day")}>
<CalendarIcon className="h-4 w-4 mr-2" />
Day
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setViewMode("list")}>
<List className="h-4 w-4 mr-2" />
List
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Calendar Content */}
{renderCalendarGrid()}
</div>
</div>
{/* Mobile/Tablet Sheet */}
<Sheet open={showCalendarSheet} onOpenChange={setShowCalendarSheet}>
<SheetContent side="left" className="w-80 p-0">
<SheetHeader className="p-4 pb-2">
<SheetTitle>Calendar</SheetTitle>
<SheetDescription>
Browse dates and manage your calendar events
</SheetDescription>
</SheetHeader>
{renderSidebar()}
</SheetContent>
</Sheet>
{/* Event Details Dialog */}
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{selectedEvent?.title}</DialogTitle>
<DialogDescription>
Event details and information
</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4 pt-4">
<div className="flex items-center space-x-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>{selectedEvent.time} {selectedEvent.duration}</span>
</div>
{selectedEvent.location && (
<div className="flex items-center space-x-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span>{selectedEvent.location}</span>
</div>
)}
{selectedEvent.attendees.length > 0 && (
<div className="flex items-center space-x-2 text-sm">
<Users className="h-4 w-4 text-muted-foreground" />
<div className="flex space-x-1">
{selectedEvent.attendees.map((attendee, index) => (
<Avatar key={index} className="h-6 w-6">
<AvatarFallback className="text-xs">
{attendee}
</AvatarFallback>
</Avatar>
))}
</div>
</div>
)}
{selectedEvent.description && (
<div className="text-sm text-muted-foreground">
{selectedEvent.description}
</div>
)}
<div className="flex items-center space-x-2 pt-4">
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
{selectedEvent.type}
</Badge>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,77 @@
"use client"
import { CalendarSidebar } from "./calendar-sidebar"
import { CalendarMain } from "./calendar-main"
import { EventForm } from "./event-form"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { type CalendarEvent } from "../types"
import { useCalendar } from "../use-calendar"
interface CalendarProps {
events: CalendarEvent[]
eventDates: Array<{ date: Date; count: number }>
}
export function Calendar({ events, eventDates }: CalendarProps) {
const calendar = useCalendar(events)
return (
<>
<div className="border rounded-lg bg-background relative">
<div className="flex min-h-[800px]">
{/* Desktop Sidebar - Hidden on mobile/tablet, shown on extra large screens */}
<div className="hidden xl:block w-80 flex-shrink-0 border-r">
<CalendarSidebar
selectedDate={calendar.selectedDate}
onDateSelect={calendar.handleDateSelect}
onNewCalendar={calendar.handleNewCalendar}
onNewEvent={calendar.handleNewEvent}
events={eventDates}
className="h-full"
/>
</div>
{/* Main Calendar Panel */}
<div className="flex-1 min-w-0">
<CalendarMain
selectedDate={calendar.selectedDate}
onDateSelect={calendar.handleDateSelect}
onMenuClick={() => calendar.setShowCalendarSheet(true)}
events={calendar.events}
onEventClick={calendar.handleEditEvent}
/>
</div>
</div>
{/* Mobile/Tablet Sheet - Positioned relative to calendar container */}
<Sheet open={calendar.showCalendarSheet} onOpenChange={calendar.setShowCalendarSheet}>
<SheetContent side="left" className="w-80 p-0" style={{ position: 'absolute' }}>
<SheetHeader className="p-4 pb-2">
<SheetTitle>Calendar</SheetTitle>
<SheetDescription>
Browse dates and manage your calendar events
</SheetDescription>
</SheetHeader>
<CalendarSidebar
selectedDate={calendar.selectedDate}
onDateSelect={calendar.handleDateSelect}
onNewCalendar={calendar.handleNewCalendar}
onNewEvent={calendar.handleNewEvent}
events={eventDates}
className="h-full"
/>
</SheetContent>
</Sheet>
</div>
{/* Event Form Dialog */}
<EventForm
event={calendar.editingEvent}
open={calendar.showEventForm}
onOpenChange={calendar.setShowEventForm}
onSave={calendar.handleSaveEvent}
onDelete={calendar.handleDeleteEvent}
/>
</>
)
}
@@ -0,0 +1,203 @@
"use client"
import { useState } from "react"
import { Check, ChevronRight, Plus, Eye, EyeOff, MoreHorizontal } from "lucide-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
interface CalendarItem {
id: string
name: string
color: string
visible: boolean
type: "personal" | "work" | "shared"
}
interface CalendarGroup {
name: string
items: CalendarItem[]
}
interface CalendarsProps {
calendars?: {
name: string
items: string[]
}[]
onCalendarToggle?: (calendarId: string, visible: boolean) => void
onCalendarEdit?: (calendarId: string) => void
onCalendarDelete?: (calendarId: string) => void
onNewCalendar?: () => void
}
// Enhanced calendar data with colors and visibility
const enhancedCalendars: CalendarGroup[] = [
{
name: "My Calendars",
items: [
{ id: "personal", name: "Personal", color: "bg-blue-500", visible: true, type: "personal" },
{ id: "work", name: "Work", color: "bg-green-500", visible: true, type: "work" },
{ id: "family", name: "Family", color: "bg-pink-500", visible: true, type: "personal" }
]
},
{
name: "Favorites",
items: [
{ id: "holidays", name: "Holidays", color: "bg-red-500", visible: true, type: "shared" },
{ id: "birthdays", name: "Birthdays", color: "bg-purple-500", visible: true, type: "personal" }
]
},
{
name: "Other",
items: [
{ id: "travel", name: "Travel", color: "bg-orange-500", visible: false, type: "personal" },
{ id: "reminders", name: "Reminders", color: "bg-yellow-500", visible: true, type: "personal" },
{ id: "deadlines", name: "Deadlines", color: "bg-red-600", visible: true, type: "work" }
]
}
]
export function Calendars({
onCalendarToggle,
onCalendarEdit,
onCalendarDelete,
onNewCalendar
}: CalendarsProps) {
const [calendarData, setCalendarData] = useState(enhancedCalendars)
const handleToggleVisibility = (calendarId: string) => {
setCalendarData(prev => prev.map(group => ({
...group,
items: group.items.map(item =>
item.id === calendarId
? { ...item, visible: !item.visible }
: item
)
})))
const calendar = calendarData.flatMap(g => g.items).find(c => c.id === calendarId)
if (calendar) {
onCalendarToggle?.(calendarId, !calendar.visible)
}
}
return (
<div className="space-y-4">
{calendarData.map((calendar, index) => (
<div key={calendar.name}>
<Collapsible
defaultOpen={index === 0}
className="group/collapsible"
>
<CollapsibleTrigger className="flex items-center justify-between w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-md cursor-pointer">
<span className="text-sm font-medium">{calendar.name}</span>
<div className="flex items-center gap-1">
{index === 0 && (
<div
className="h-5 w-5 flex items-center justify-center opacity-0 group-hover/collapsible:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
onClick={(e) => {
e.stopPropagation()
onNewCalendar?.()
}}
>
<Plus className="h-3 w-3" />
</div>
)}
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-90" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-1">
{calendar.items.map((item) => (
<div key={item.id} className="group/calendar-item">
<div className="flex items-center justify-between p-2 hover:bg-accent/50 rounded-md">
<div className="flex items-center gap-3 flex-1">
{/* Calendar Color & Visibility Toggle */}
<button
onClick={() => handleToggleVisibility(item.id)}
className={cn(
"flex aspect-square size-4 shrink-0 items-center justify-center rounded-sm border transition-all cursor-pointer",
item.visible
? cn("border-transparent text-white", item.color)
: "border-border bg-transparent"
)}
>
{item.visible && <Check className="size-3" />}
</button>
{/* Calendar Name */}
<span
className={cn(
"flex-1 truncate text-sm cursor-pointer",
!item.visible && "text-muted-foreground"
)}
onClick={() => handleToggleVisibility(item.id)}
>
{item.name}
</span>
{/* Visibility Icon */}
<div className="opacity-0 group-hover/calendar-item:opacity-100">
{item.visible ? (
<Eye className="h-3 w-3 text-muted-foreground" />
) : (
<EyeOff className="h-3 w-3 text-muted-foreground" />
)}
</div>
{/* More Options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className="h-5 w-5 flex items-center justify-center p-0 opacity-0 group-hover/calendar-item:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3 w-3" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
<DropdownMenuItem
onClick={() => onCalendarEdit?.(item.id)}
className="cursor-pointer"
>
Edit calendar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleToggleVisibility(item.id)}
className="cursor-pointer"
>
{item.visible ? "Hide" : "Show"} calendar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCalendarDelete?.(item.id)}
className="cursor-pointer text-destructive"
>
Delete calendar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
))}
</div>
)
}
@@ -0,0 +1,48 @@
"use client"
import { useState } from "react"
import { Calendar } from "@/components/ui/calendar"
interface DatePickerProps {
selectedDate?: Date
onDateSelect?: (date: Date) => void
events?: Array<{ date: Date; count: number }>
}
export function DatePicker({ selectedDate, onDateSelect, events = [] }: DatePickerProps) {
const [date, setDate] = useState<Date | undefined>(selectedDate || new Date())
const handleDateSelect = (selectedDate: Date | undefined) => {
if (selectedDate) {
setDate(selectedDate)
onDateSelect?.(selectedDate)
}
}
// Create a map of dates with events for styling
const eventDates = events.reduce((acc, event) => {
const dateKey = event.date.toDateString()
acc[dateKey] = event.count
return acc
}, {} as Record<string, number>)
return (
<div className="flex justify-center">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
className="w-full [&_[role=gridcell]_button]:cursor-pointer [&_button]:cursor-pointer"
modifiers={{
hasEvents: (date) => {
const eventCount = eventDates[date.toDateString()]
return Boolean(eventCount && eventCount > 0)
}
}}
modifiersClassNames={{
hasEvents: "relative after:absolute after:bottom-1 after:right-1 after:w-1.5 after:h-1.5 after:bg-primary after:rounded-full"
}}
/>
</div>
)
}
@@ -0,0 +1,339 @@
"use client"
import { useState } from "react"
import { CalendarIcon, Clock, MapPin, Users, Type, Tag } from "lucide-react"
import { format } from "date-fns"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
import { type CalendarEvent } from "../types"
interface EventFormProps {
event?: CalendarEvent | null
open: boolean
onOpenChange: (open: boolean) => void
onSave: (event: Partial<CalendarEvent>) => void
onDelete?: (eventId: number) => void
}
const eventTypes = [
{ value: "meeting", label: "Meeting", color: "bg-blue-500" },
{ value: "event", label: "Event", color: "bg-green-500" },
{ value: "personal", label: "Personal", color: "bg-pink-500" },
{ value: "task", label: "Task", color: "bg-orange-500" },
{ value: "reminder", label: "Reminder", color: "bg-purple-500" }
]
const timeSlots = [
"9:00 AM", "9:30 AM", "10:00 AM", "10:30 AM", "11:00 AM", "11:30 AM",
"12:00 PM", "12:30 PM", "1:00 PM", "1:30 PM", "2:00 PM", "2:30 PM",
"3:00 PM", "3:30 PM", "4:00 PM", "4:30 PM", "5:00 PM", "5:30 PM",
"6:00 PM", "6:30 PM", "7:00 PM", "7:30 PM", "8:00 PM", "8:30 PM"
]
const durationOptions = [
"15 min", "30 min", "45 min", "1 hour", "1.5 hours", "2 hours", "3 hours", "All day"
]
export function EventForm({ event, open, onOpenChange, onSave, onDelete }: EventFormProps) {
const [formData, setFormData] = useState({
title: event?.title || "",
date: event?.date || new Date(),
time: event?.time || "9:00 AM",
duration: event?.duration || "1 hour",
type: event?.type || "meeting",
location: event?.location || "",
description: event?.description || "",
attendees: event?.attendees || [],
allDay: false,
reminder: true
})
const [showCalendar, setShowCalendar] = useState(false)
const [newAttendee, setNewAttendee] = useState("")
const handleSave = () => {
const eventData: Partial<CalendarEvent> = {
...formData,
id: event?.id,
color: eventTypes.find(t => t.value === formData.type)?.color || "bg-blue-500"
}
onSave(eventData)
onOpenChange(false)
}
const handleDelete = () => {
if (event?.id && onDelete) {
onDelete(event.id)
onOpenChange(false)
}
}
const addAttendee = () => {
if (newAttendee.trim() && !formData.attendees.includes(newAttendee.trim())) {
setFormData(prev => ({
...prev,
attendees: [...prev.attendees, newAttendee.trim()]
}))
setNewAttendee("")
}
}
const removeAttendee = (attendee: string) => {
setFormData(prev => ({
...prev,
attendees: prev.attendees.filter(a => a !== attendee)
}))
}
const selectedEventType = eventTypes.find(t => t.value === formData.type)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div className={cn("w-3 h-3 rounded-full", selectedEventType?.color)} />
{event ? "Edit Event" : "Create New Event"}
</DialogTitle>
<DialogDescription>
{event ? "Make changes to this event" : "Add a new event to your calendar"}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Event Title */}
<div className="space-y-2">
<Label htmlFor="title" className="flex items-center gap-2">
<Type className="w-4 h-4" />
Event Title
</Label>
<Input
id="title"
placeholder="Enter event title..."
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="text-lg font-medium"
/>
</div>
{/* Event Type */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag className="w-4 h-4" />
Event Type
</Label>
<Select value={formData.type} onValueChange={(value) => setFormData(prev => ({ ...prev, type: value as CalendarEvent["type"] }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{eventTypes.map(type => (
<SelectItem key={type.value} value={type.value}>
<div className="flex items-center gap-2">
<div className={cn("w-3 h-3 rounded-full", type.color)} />
{type.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Date and Time */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
Date
</Label>
<Popover open={showCalendar} onOpenChange={setShowCalendar}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
{format(formData.date, "PPP")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.date}
onSelect={(date) => {
if (date) {
setFormData(prev => ({ ...prev, date }))
setShowCalendar(false)
}
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Clock className="w-4 h-4" />
Time
</Label>
<Select value={formData.time} onValueChange={(value) => setFormData(prev => ({ ...prev, time: value }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeSlots.map(time => (
<SelectItem key={time} value={time}>{time}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Duration and All Day */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Duration</Label>
<Select value={formData.duration} onValueChange={(value) => setFormData(prev => ({ ...prev, duration: value }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{durationOptions.map(duration => (
<SelectItem key={duration} value={duration}>{duration}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Options</Label>
<div className="flex items-center space-x-4 h-10">
<div className="flex items-center space-x-2">
<Switch
id="all-day"
checked={formData.allDay}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allDay: checked }))}
/>
<Label htmlFor="all-day" className="text-sm">All day</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="reminder"
checked={formData.reminder}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, reminder: checked }))}
/>
<Label htmlFor="reminder" className="text-sm">Reminder</Label>
</div>
</div>
</div>
</div>
{/* Location */}
<div className="space-y-2">
<Label htmlFor="location" className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
Location
</Label>
<Input
id="location"
placeholder="Add location..."
value={formData.location}
onChange={(e) => setFormData(prev => ({ ...prev, location: e.target.value }))}
/>
</div>
{/* Attendees */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Users className="w-4 h-4" />
Attendees
</Label>
<div className="flex gap-2">
<Input
placeholder="Add attendee..."
value={newAttendee}
onChange={(e) => setNewAttendee(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && addAttendee()}
/>
<Button onClick={addAttendee} variant="outline" className="cursor-pointer">Add</Button>
</div>
{formData.attendees.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{formData.attendees.map((attendee, index) => (
<Badge key={index} variant="secondary" className="flex items-center gap-2 px-2 py-1">
<Avatar className="w-5 h-5">
<AvatarFallback className="text-[10px] font-medium">
{attendee.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm">{attendee}</span>
<button
onClick={() => removeAttendee(attendee)}
className="text-muted-foreground hover:text-foreground cursor-pointer"
type="button"
>
×
</button>
</Badge>
))}
</div>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Add description..."
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-6">
<Button onClick={handleSave} className="flex-1 cursor-pointer">
{event ? "Update Event" : "Create Event"}
</Button>
{event && onDelete && (
<Button onClick={handleDelete} variant="destructive" className="cursor-pointer">
Delete
</Button>
)}
<Button onClick={() => onOpenChange(false)} variant="outline" className="cursor-pointer">
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,152 @@
"use client"
import {
Clock,
Users,
Plus,
Settings,
Download,
Share,
Bell
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
interface QuickActionsProps {
onNewEvent?: () => void
onNewMeeting?: () => void
onNewReminder?: () => void
onSettings?: () => void
}
export function QuickActions({
onNewEvent,
onNewMeeting,
onNewReminder,
onSettings
}: QuickActionsProps) {
const quickStats = [
{ label: "Today's Events", value: "3", color: "bg-blue-500" },
{ label: "This Week", value: "12", color: "bg-green-500" },
{ label: "Pending", value: "2", color: "bg-orange-500" }
]
return (
<div className="space-y-4">
{/* Quick Stats */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Overview</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{quickStats.map((stat, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${stat.color}`} />
<span className="text-sm text-muted-foreground">{stat.label}</span>
</div>
<Badge variant="secondary">{stat.value}</Badge>
</div>
))}
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start cursor-pointer"
onClick={onNewEvent}
>
<Plus className="w-4 h-4 mr-2" />
New Event
</Button>
<Button
variant="outline"
className="w-full justify-start cursor-pointer"
onClick={onNewMeeting}
>
<Users className="w-4 h-4 mr-2" />
Schedule Meeting
</Button>
<Button
variant="outline"
className="w-full justify-start cursor-pointer"
onClick={onNewReminder}
>
<Bell className="w-4 h-4 mr-2" />
Set Reminder
</Button>
<Separator className="my-3" />
<Button
variant="ghost"
size="sm"
className="w-full justify-start cursor-pointer"
>
<Share className="w-4 h-4 mr-2" />
Share Calendar
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start cursor-pointer"
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start cursor-pointer"
onClick={onSettings}
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
</CardContent>
</Card>
{/* Upcoming Events */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="w-4 h-4" />
Next Up
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="flex items-start gap-3">
<div className="w-2 h-2 bg-blue-500 rounded-full mt-2" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">Team Standup</p>
<p className="text-xs text-muted-foreground">9:00 AM Conference Room A</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-2 h-2 bg-purple-500 rounded-full mt-2" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">Design Review</p>
<p className="text-xs text-muted-foreground">2:00 PM Virtual</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { type CalendarEvent, type Calendar } from "./types"
// Import JSON data
import eventsData from "./data/events.json"
import eventDatesData from "./data/event-dates.json"
import calendarsData from "./data/calendars.json"
// Convert JSON events to CalendarEvent objects with proper Date objects
// Always use current month and year, but preserve day and time from JSON
export const events: CalendarEvent[] = eventsData.map(event => {
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-based month
// Parse the day from the date string (format: "11T09:00:00.000Z")
const dayAndTime = event.date.split('T')
const day = parseInt(dayAndTime[0])
const timeStr = dayAndTime[1] // "09:00:00.000Z"
// Parse hours and minutes from time string
const timeParts = timeStr.split(':')
const hours = parseInt(timeParts[0])
const minutes = parseInt(timeParts[1])
// Create date with current year/month but original day and time
const eventDate = new Date(currentYear, currentMonth, day, hours, minutes)
return {
...event,
date: eventDate,
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
}
})
// Convert event dates for calendar picker - also use current month/year
export const eventDates = eventDatesData.map(item => {
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()
// Parse day from date string
const day = parseInt(item.date.split('T')[0])
const eventDate = new Date(currentYear, currentMonth, day)
return {
date: eventDate,
count: item.count
}
})
// Calendars data
export const calendars: Calendar[] = calendarsData as Calendar[]
// Export individual collections for convenience
export { eventsData, eventDatesData, calendarsData }
@@ -0,0 +1,37 @@
[
{
"id": "personal",
"name": "Personal",
"color": "bg-blue-500",
"visible": true,
"type": "personal"
},
{
"id": "work",
"name": "Work",
"color": "bg-green-500",
"visible": true,
"type": "work"
},
{
"id": "shared",
"name": "Team Calendar",
"color": "bg-purple-500",
"visible": true,
"type": "shared"
},
{
"id": "meetings",
"name": "Meetings",
"color": "bg-orange-500",
"visible": true,
"type": "work"
},
{
"id": "events",
"name": "Events",
"color": "bg-pink-500",
"visible": true,
"type": "shared"
}
]
@@ -0,0 +1,30 @@
[
{
"date": "11T00:00:00.000Z",
"count": 2
},
{
"date": "15T00:00:00.000Z",
"count": 1
},
{
"date": "18T00:00:00.000Z",
"count": 1
},
{
"date": "20T00:00:00.000Z",
"count": 1
},
{
"date": "22T00:00:00.000Z",
"count": 1
},
{
"date": "25T00:00:00.000Z",
"count": 1
},
{
"date": "27T00:00:00.000Z",
"count": 1
}
]
@@ -0,0 +1,62 @@
[
{
"id": 1,
"title": "Team Standup",
"date": "11T09:00:00.000Z",
"time": "9:00 AM",
"duration": "30 min",
"type": "meeting",
"attendees": ["JD", "SM", "AR"],
"location": "Conference Room A",
"color": "bg-blue-500",
"description": "Daily team standup meeting to discuss progress and blockers"
},
{
"id": 2,
"title": "Design Review",
"date": "11T14:00:00.000Z",
"time": "2:00 PM",
"duration": "1 hour",
"type": "meeting",
"attendees": ["ER", "LC"],
"location": "Virtual",
"color": "bg-purple-500",
"description": "Review new UI designs and provide feedback"
},
{
"id": 3,
"title": "Product Launch",
"date": "15T10:00:00.000Z",
"time": "10:00 AM",
"duration": "2 hours",
"type": "event",
"attendees": ["TL", "ST"],
"location": "Main Hall",
"color": "bg-green-500",
"description": "Official product launch event with stakeholders"
},
{
"id": 4,
"title": "Client Presentation",
"date": "18T15:00:00.000Z",
"time": "3:00 PM",
"duration": "1 hour",
"type": "meeting",
"attendees": ["AT", "SM"],
"location": "Client Office",
"color": "bg-orange-500",
"description": "Present project progress to client stakeholders"
},
{
"id": 5,
"title": "Birthday Party 🎉",
"date": "20T19:00:00.000Z",
"time": "7:00 PM",
"duration": "3 hours",
"type": "personal",
"attendees": ["PB", "VB"],
"location": "Home",
"color": "bg-pink-500",
"description": "Birthday celebration with friends and family"
}
]
+10
View File
@@ -0,0 +1,10 @@
import { Calendar } from "./components/calendar"
import { events, eventDates } from "./data"
export default function CalendarPage() {
return (
<div className="px-4 lg:px-6">
<Calendar events={events} eventDates={eventDates} />
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
export interface CalendarEvent {
id: number
title: string
date: Date
time: string
duration: string
type: "meeting" | "event" | "personal" | "task" | "reminder"
attendees: string[]
location: string
color: string
description?: string
}
export interface Calendar {
id: string
name: string
color: string
visible: boolean
type: "personal" | "work" | "shared"
}
@@ -0,0 +1,90 @@
"use client"
import { useState, useCallback } from "react"
import { type CalendarEvent } from "./types"
export interface UseCalendarState {
selectedDate: Date
showEventForm: boolean
editingEvent: CalendarEvent | null
showCalendarSheet: boolean
events: CalendarEvent[]
}
export interface UseCalendarActions {
setSelectedDate: (date: Date) => void
setShowEventForm: (show: boolean) => void
setEditingEvent: (event: CalendarEvent | null) => void
setShowCalendarSheet: (show: boolean) => void
handleDateSelect: (date: Date) => void
handleNewEvent: () => void
handleNewCalendar: () => void
handleSaveEvent: (eventData: Partial<CalendarEvent>) => void
handleDeleteEvent: (eventId: number) => void
handleEditEvent: (event: CalendarEvent) => void
}
export interface UseCalendarReturn extends UseCalendarState, UseCalendarActions {}
export function useCalendar(initialEvents: CalendarEvent[] = []): UseCalendarReturn {
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
const [showEventForm, setShowEventForm] = useState(false)
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
const [events] = useState<CalendarEvent[]>(initialEvents)
const handleDateSelect = useCallback((date: Date) => {
setSelectedDate(date)
// Auto-close mobile sheet when date is selected
setShowCalendarSheet(false)
}, [])
const handleNewEvent = useCallback(() => {
setEditingEvent(null)
setShowEventForm(true)
}, [])
const handleNewCalendar = useCallback(() => {
console.log("Creating new calendar")
// In a real app, this would open a new calendar form
}, [])
const handleSaveEvent = useCallback((eventData: Partial<CalendarEvent>) => {
console.log("Saving event:", eventData)
// In a real app, this would save to a backend
setShowEventForm(false)
setEditingEvent(null)
}, [])
const handleDeleteEvent = useCallback((eventId: number) => {
console.log("Deleting event:", eventId)
// In a real app, this would delete from backend
setShowEventForm(false)
setEditingEvent(null)
}, [])
const handleEditEvent = useCallback((event: CalendarEvent) => {
setEditingEvent(event)
setShowEventForm(true)
}, [])
return {
// State
selectedDate,
showEventForm,
editingEvent,
showCalendarSheet,
events,
// Actions
setSelectedDate,
setShowEventForm,
setEditingEvent,
setShowCalendarSheet,
handleDateSelect,
handleNewEvent,
handleNewCalendar,
handleSaveEvent,
handleDeleteEvent,
handleEditEvent,
}
}
@@ -0,0 +1,233 @@
"use client"
import {
Phone,
Video,
Info,
Search,
MoreVertical,
Users,
Bell,
BellOff
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
import { type Conversation, type User } from "../use-chat"
interface ChatHeaderProps {
conversation: Conversation | null
users: User[]
onToggleMute?: () => void
onToggleInfo?: () => void
}
export function ChatHeader({
conversation,
users,
onToggleMute,
onToggleInfo
}: ChatHeaderProps) {
if (!conversation) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Select a conversation to start chatting</p>
</div>
)
}
const getConversationUsers = () => {
if (conversation.type === "direct") {
return users.filter(user => conversation.participants.includes(user.id))
}
return users.filter(user => conversation.participants.includes(user.id))
}
const conversationUsers = getConversationUsers()
const primaryUser = conversationUsers[0]
const getStatusText = () => {
if (conversation.type === "group") {
const onlineCount = conversationUsers.filter(user => user.status === "online").length
return `${conversation.participants.length} members, ${onlineCount} online`
} else if (primaryUser) {
switch (primaryUser.status) {
case "online":
return "Active now"
case "away":
return "Away"
case "offline":
return `Last seen ${new Date(primaryUser.lastSeen).toLocaleDateString()}`
default:
return ""
}
}
return ""
}
const getStatusColor = () => {
if (conversation.type === "group") return "text-muted-foreground"
switch (primaryUser?.status) {
case "online":
return "text-green-600"
case "away":
return "text-yellow-600"
case "offline":
return "text-muted-foreground"
default:
return "text-muted-foreground"
}
}
return (
<div className="flex items-center justify-between h-full">
{/* Left side - Avatar and info */}
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 cursor-pointer">
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback>
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="font-semibold truncate">{conversation.name}</h2>
{conversation.isMuted && (
<BellOff className="h-4 w-4 text-muted-foreground" />
)}
{conversation.type === "group" && (
<Badge variant="secondary" className="text-xs cursor-pointer">
Group
</Badge>
)}
</div>
<p className={`text-sm ${getStatusColor()}`}>
{getStatusText()}
</p>
</div>
</div>
{/* Right side - Action buttons */}
<div className="flex items-center gap-1">
<TooltipProvider>
{/* Search */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Search in conversation</p>
</TooltipContent>
</Tooltip>
{/* Phone call */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Phone className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Voice call</p>
</TooltipContent>
</Tooltip>
{/* Video call */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Video className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Video call</p>
</TooltipContent>
</Tooltip>
{/* Info */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onToggleInfo}
className="cursor-pointer"
>
<Info className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Conversation info</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* More options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={onToggleMute}
className="cursor-pointer"
>
{conversation.isMuted ? (
<>
<Bell className="h-4 w-4 mr-2" />
Unmute conversation
</>
) : (
<>
<BellOff className="h-4 w-4 mr-2" />
Mute conversation
</>
)}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Search className="h-4 w-4 mr-2" />
Search messages
</DropdownMenuItem>
{conversation.type === "group" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Users className="h-4 w-4 mr-2" />
Manage members
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1,193 @@
"use client"
import { useEffect, useState } from "react"
import { Menu, X } from "lucide-react"
import { TooltipProvider } from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
import { ConversationList } from "./conversation-list"
import { ChatHeader } from "./chat-header"
import { MessageList } from "./message-list"
import { MessageInput } from "./message-input"
import { useChat, type Conversation, type Message, type User } from "../use-chat"
interface ChatProps {
conversations: Conversation[]
messages: Record<string, Message[]>
users: User[]
}
export function Chat({
conversations,
messages,
users,
}: ChatProps) {
const {
selectedConversation,
setSelectedConversation,
setConversations,
setMessages,
setUsers,
addMessage,
toggleMute,
} = useChat()
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// Close sidebar when clicking outside on mobile
useEffect(() => {
const handleResize = () => {
if (typeof window !== "undefined" ? window.innerWidth : 0 >= 1024) { // lg breakpoint
setIsSidebarOpen(false)
}
}
if (typeof window !== "undefined") {
window.addEventListener('resize', handleResize)
}
return () => {
if (typeof window !== "undefined") {
window.removeEventListener('resize', handleResize)
}
}
}, [])
// Initialize data
useEffect(() => {
setConversations(conversations)
setUsers(users)
// Set messages for all conversations
Object.entries(messages).forEach(([conversationId, conversationMessages]) => {
setMessages(conversationId, conversationMessages)
})
// Auto-select first conversation if none selected
if (!selectedConversation && conversations.length > 0) {
setSelectedConversation(conversations[0].id)
}
}, [conversations, messages, users, selectedConversation, setConversations, setMessages, setUsers, setSelectedConversation])
const currentConversation = conversations.find(conv => conv.id === selectedConversation)
const currentMessages = selectedConversation ? messages[selectedConversation] || [] : []
const handleSendMessage = (content: string) => {
if (!selectedConversation) return
const newMessage = {
id: `msg-${Date.now()}`,
content,
timestamp: new Date().toISOString(),
senderId: "current-user",
type: "text" as const,
isEdited: false,
reactions: [],
replyTo: null,
}
addMessage(selectedConversation, newMessage)
}
const handleToggleMute = () => {
if (selectedConversation) {
toggleMute(selectedConversation)
}
}
return (
<TooltipProvider delayDuration={0}>
<div className="h-full min-h-[600px] max-h-[calc(100vh-200px)] flex rounded-lg border overflow-hidden bg-background">
{/* Mobile Sidebar Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Conversations Sidebar - Responsive */}
<div className={`
w-100 border-r bg-background flex-shrink-0
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
lg:relative lg:block
fixed inset-y-0 left-0 z-50
transition-transform duration-300 ease-in-out
`}>
{/* Sidebar Header with Close Button (Mobile Only) */}
<div className="lg:hidden p-4 border-b flex items-center justify-between bg-background">
<h2 className="text-lg font-semibold">Messages</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(false)}
className="cursor-pointer"
>
<X className="h-4 w-4" />
</Button>
</div>
<ConversationList
conversations={conversations}
selectedConversation={selectedConversation}
onSelectConversation={(id) => {
setSelectedConversation(id)
setIsSidebarOpen(false) // Close sidebar on mobile after selection
}}
/>
</div>
{/* Chat Panel - Flexible Width */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{/* Chat Header with Hamburger Menu */}
<div className="flex items-center h-16 px-4 border-b bg-background">
{/* Hamburger Menu Button - Only visible when sidebar is hidden on mobile */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(true)}
className="cursor-pointer lg:hidden mr-2"
>
<Menu className="h-4 w-4" />
</Button>
<div className="flex-1">
<ChatHeader
conversation={currentConversation || null}
users={users}
onToggleMute={handleToggleMute}
/>
</div>
</div>
{/* Messages */}
<div className="flex-1 flex flex-col min-h-0">
{selectedConversation ? (
<>
<MessageList
messages={currentMessages}
users={users}
/>
{/* Message Input */}
<MessageInput
onSendMessage={handleSendMessage}
placeholder={`Message ${currentConversation?.name || ""}...`}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">Welcome to Chat</h3>
<p className="text-muted-foreground">
Select a conversation to start messaging
</p>
</div>
</div>
)}
</div>
</div>
</div>
</TooltipProvider>
)
}
@@ -0,0 +1,221 @@
"use client"
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
import {
Search,
Pin,
VolumeX,
MoreHorizontal,
Users,
Hash
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useChat, type Conversation } from "../use-chat"
interface ConversationListProps {
conversations: Conversation[]
selectedConversation: string | null
onSelectConversation: (conversationId: string) => void
}
// Enhanced time formatting function
function formatMessageTime(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, 'h:mm a') // 3:30 PM
} else if (isYesterday(date)) {
return 'Yesterday'
} else if (isThisWeek(date)) {
return format(date, 'EEEE') // Day name
} else if (isThisYear(date)) {
return format(date, 'MMM d') // Jan 15
} else {
return format(date, 'dd/MM/yy') // 15/01/24
}
}
export function ConversationList({
conversations,
selectedConversation,
onSelectConversation
}: ConversationListProps) {
const { searchQuery, setSearchQuery, togglePin, toggleMute } = useChat()
const filteredConversations = conversations.filter((conversation) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const sortedConversations = filteredConversations.sort((a, b) => {
// Pinned conversations first
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
// Then by last message timestamp
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
})
const getOnlineStatus = (conversation: Conversation) => {
if (conversation.type === "direct" && conversation.participants.length === 1) {
// In a real app, you'd check user online status
return Math.random() > 0.5 // Mock online status
}
return false
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
<h2 className="text-lg font-semibold">Messages</h2>
</div>
{/* Search */}
<div className="p-4 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 cursor-text"
/>
</div>
</div>
{/* Conversations */}
<ScrollArea className="flex-1">
<div className="p-2">
{sortedConversations.map((conversation) => (
<div
key={conversation.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative group overflow-hidden hover:bg-accent/50 transition-colors",
selectedConversation === conversation.id
? "bg-accent text-accent-foreground"
: ""
)}
onClick={() => onSelectConversation(conversation.id)}
>
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<Avatar className={cn(
"h-12 w-12",
selectedConversation === conversation.id && "ring-2 ring-background"
)}>
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback className="text-sm">
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
{/* Online indicator for direct messages */}
{conversation.type === "direct" && getOnlineStatus(conversation) && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
)}
{/* Group indicator */}
{conversation.type === "group" && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
<Hash className="h-2 w-2 text-white" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-1 min-w-0">
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
<h3 className="font-medium truncate min-w-0 max-w-[180px]">{conversation.name}</h3>
{conversation.isPinned && (
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{conversation.isMuted && (
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2 whitespace-nowrap">
{formatMessageTime(conversation.lastMessage.timestamp)}
</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[200px]">
{conversation.lastMessage.content}
</p>
{/* Unread count */}
{conversation.unreadCount > 0 && (
<Badge variant="default" className="ml-2 min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
</Badge>
)}
</div>
</div>
{/* Actions menu */}
<div className="opacity-0 group-hover:opacity-100 ml-2 flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
togglePin(conversation.id)
}}
className="cursor-pointer"
>
<Pin className="h-4 w-4 mr-2" />
{conversation.isPinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
toggleMute(conversation.id)
}}
className="cursor-pointer"
>
<VolumeX className="h-4 w-4 mr-2" />
{conversation.isMuted ? "Unmute" : "Mute"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,208 @@
"use client"
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
import {
Search,
Pin,
VolumeX,
MoreVertical,
Users,
Hash,
Settings,
UserPlus,
Filter
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useChat, type Conversation } from "../use-chat"
interface ConversationListProps {
conversations: Conversation[]
selectedConversation: string | null
onSelectConversation: (conversationId: string) => void
}
// Enhanced time formatting function
function formatMessageTime(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, 'h:mm a') // 3:30 PM
} else if (isYesterday(date)) {
return 'Yesterday'
} else if (isThisWeek(date)) {
return format(date, 'EEEE') // Day name
} else if (isThisYear(date)) {
return format(date, 'MMM d') // Jan 15
} else {
return format(date, 'dd/MM/yy') // 15/01/24
}
}
export function ConversationList({
conversations,
selectedConversation,
onSelectConversation
}: ConversationListProps) {
const { searchQuery, setSearchQuery } = useChat()
const filteredConversations = conversations.filter((conversation) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const sortedConversations = filteredConversations.sort((a, b) => {
// Pinned conversations first
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
// Then by last message timestamp
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
})
const getOnlineStatus = (conversation: Conversation) => {
if (conversation.type === "direct" && conversation.participants.length === 1) {
// In a real app, you'd check user online status
return Math.random() > 0.5 // Mock online status
}
return false
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header - Hidden on mobile (handled by parent) */}
<div className="hidden lg:flex items-center justify-between h-16 px-4 border-b flex-shrink-0">
<h2 className="text-lg font-semibold">Messages</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 cursor-pointer"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">
<UserPlus className="h-4 w-4 mr-2" />
New Chat
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Filter className="h-4 w-4 mr-2" />
Filter Messages
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Chat Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Search */}
<div className="px-4 py-3 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 cursor-text"
/>
</div>
</div>
{/* Conversations */}
<ScrollArea className="flex-1">
<div className="p-2">
{sortedConversations.map((conversation) => (
<div
key={conversation.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative overflow-hidden hover:bg-accent/50 transition-colors",
selectedConversation === conversation.id
? "bg-accent text-accent-foreground"
: ""
)}
onClick={() => onSelectConversation(conversation.id)}
>
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<Avatar className={cn(
"h-12 w-12",
selectedConversation === conversation.id && "ring-2 ring-background"
)}>
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback className="text-sm">
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
{/* Online indicator for direct messages */}
{conversation.type === "direct" && getOnlineStatus(conversation) && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
)}
{/* Group indicator */}
{conversation.type === "group" && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
<Hash className="h-2 w-2 text-white" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-1 min-w-0">
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden pr-2">
<h3 className="font-medium truncate min-w-0 max-w-[160px] lg:max-w-[180px]">{conversation.name}</h3>
{conversation.isPinned && (
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{conversation.isMuted && (
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0 whitespace-nowrap">
{formatMessageTime(conversation.lastMessage.timestamp)}
</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[180px] lg:max-w-[200px] pr-2">
{conversation.lastMessage.content}
</p>
{/* Unread count */}
{conversation.unreadCount > 0 && (
<Badge variant="default" className="min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,225 @@
"use client"
import { useState, useRef } from "react"
import {
Send,
Paperclip,
Smile,
Image as ImageIcon,
FileText,
Mic,
MoreHorizontal
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
interface MessageInputProps {
onSendMessage: (content: string) => void
disabled?: boolean
placeholder?: string
}
export function MessageInput({
onSendMessage,
disabled = false,
placeholder = "Type a message..."
}: MessageInputProps) {
const [message, setMessage] = useState("")
const [isTyping, setIsTyping] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSendMessage = () => {
const trimmedMessage = message.trim()
if (trimmedMessage && !disabled) {
onSendMessage(trimmedMessage)
setMessage("")
setIsTyping(false)
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
}
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setMessage(value)
// Auto-resize textarea
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
}
// Handle typing indicator
if (value.trim() && !isTyping) {
setIsTyping(true)
} else if (!value.trim() && isTyping) {
setIsTyping(false)
}
}
const handleFileUpload = (type: "image" | "file") => {
// In a real app, this would open a file picker
console.log(`Upload ${type}`)
}
return (
<div className="border-t p-4">
<div className="flex items-end gap-2">
{/* Attachment button */}
<TooltipProvider>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Paperclip className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Attach file</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onClick={() => handleFileUpload("image")}
className="cursor-pointer"
>
<ImageIcon className="h-4 w-4 mr-2" />
Photo or video
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleFileUpload("file")}
className="cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
Document
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
{/* Message input */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
placeholder={placeholder}
value={message}
onChange={handleTextareaChange}
onKeyDown={handleKeyPress}
disabled={disabled}
className={cn(
"min-h-[40px] max-h-[120px] resize-none cursor-text disabled:cursor-not-allowed",
"pr-20" // Space for emoji and more buttons
)}
rows={1}
/>
{/* Input action buttons */}
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
>
<Smile className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add emoji</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>More options</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Voice message or send button */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{message.trim() ? (
<Button
onClick={handleSendMessage}
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Mic className="h-4 w-4" />
</Button>
)}
</TooltipTrigger>
<TooltipContent>
<p>{message.trim() ? "Send message" : "Voice message"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Typing indicator */}
{isTyping && (
<div className="text-xs text-muted-foreground mt-2">
You are typing...
</div>
)}
</div>
)
}
@@ -0,0 +1,295 @@
"use client"
import { useEffect, useRef } from "react"
import { format, isToday, isYesterday } from "date-fns"
import { CheckCheck, MoreHorizontal, Reply, Copy, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { type Message, type User } from "../use-chat"
interface MessageListProps {
messages: Message[]
users: User[]
currentUserId?: string
}
export function MessageList({ messages, users, currentUserId = "current-user" }: MessageListProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const previousMessageCountRef = useRef(0)
const isInitialLoadRef = useRef(true)
const previousConversationRef = useRef<string | null>(null)
// Reset scroll behavior when switching conversations
useEffect(() => {
const currentConversationId = messages.length > 0 ? messages[0]?.id?.split('-')[0] : null
if (currentConversationId !== previousConversationRef.current) {
isInitialLoadRef.current = true
previousConversationRef.current = currentConversationId
}
}, [messages])
// Auto-scroll to bottom only when new messages are added (not on initial load)
useEffect(() => {
// Skip auto-scroll on initial load
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false
previousMessageCountRef.current = messages.length
return
}
// Only auto-scroll if new messages were added
if (messages.length > previousMessageCountRef.current && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" })
}
previousMessageCountRef.current = messages.length
}, [messages])
const getUserById = (userId: string) => {
if (userId === currentUserId) {
return {
id: currentUserId,
name: "You",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-7",
status: "online" as const,
email: "you@example.com",
lastSeen: new Date().toISOString(),
role: "Developer",
department: "Engineering"
}
}
return users.find(user => user.id === userId)
}
const formatMessageTime = (timestamp: string) => {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, "HH:mm")
} else if (isYesterday(date)) {
return `Yesterday ${format(date, "HH:mm")}`
} else {
return format(date, "MMM d, HH:mm")
}
}
const shouldShowAvatar = (message: Message, index: number) => {
if (message.senderId === currentUserId) return false
if (index === 0) return true
const prevMessage = messages[index - 1]
return prevMessage.senderId !== message.senderId
}
const shouldShowName = (message: Message, index: number) => {
if (message.senderId === currentUserId) return false
if (index === 0) return true
const prevMessage = messages[index - 1]
return prevMessage.senderId !== message.senderId
}
const isConsecutiveMessage = (message: Message, index: number) => {
if (index === 0) return false
const prevMessage = messages[index - 1]
const timeDiff = new Date(message.timestamp).getTime() - new Date(prevMessage.timestamp).getTime()
return prevMessage.senderId === message.senderId && timeDiff < 5 * 60 * 1000 // 5 minutes
}
const groupMessagesByDay = (messages: Message[]) => {
const groups: { date: string; messages: Message[] }[] = []
messages.forEach((message) => {
const messageDate = format(new Date(message.timestamp), "yyyy-MM-dd")
const lastGroup = groups[groups.length - 1]
if (lastGroup && lastGroup.date === messageDate) {
lastGroup.messages.push(message)
} else {
groups.push({
date: messageDate,
messages: [message]
})
}
})
return groups
}
const formatDateHeader = (dateString: string) => {
const date = new Date(dateString)
if (isToday(date)) {
return "Today"
} else if (isYesterday(date)) {
return "Yesterday"
} else {
return format(date, "EEEE, MMMM d")
}
}
const messageGroups = groupMessagesByDay(messages)
return (
<ScrollArea className="flex-1 px-4" ref={scrollAreaRef}>
<div className="space-y-4 py-4">
{messageGroups.map((group) => (
<div key={group.date}>
{/* Date separator */}
<div className="flex items-center justify-center py-2">
<div className="text-xs text-muted-foreground bg-background px-3 py-1 rounded-full border">
{formatDateHeader(group.date)}
</div>
</div>
{/* Messages for this day */}
<div className="space-y-1">
{group.messages.map((message, messageIndex) => {
const user = getUserById(message.senderId)
const isOwnMessage = message.senderId === currentUserId
const showAvatar = shouldShowAvatar(message, messageIndex)
const showName = shouldShowName(message, messageIndex)
const isConsecutive = isConsecutiveMessage(message, messageIndex)
return (
<div
key={message.id}
className={cn(
"flex gap-3 group",
isOwnMessage && "flex-row-reverse",
isConsecutive && !isOwnMessage && "ml-12"
)}
>
{/* Avatar */}
{!isOwnMessage && (
<div className="w-8">
{showAvatar && user && (
<Avatar className="h-8 w-8 cursor-pointer">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="text-xs">
{user.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
</AvatarFallback>
</Avatar>
)}
</div>
)}
{/* Message content */}
<div className={cn("flex-1 max-w-[70%]", isOwnMessage && "flex flex-col items-end")}>
{/* Sender name for group messages */}
{showName && user && !isOwnMessage && (
<div className="text-sm font-medium text-foreground mb-1">
{user.name}
</div>
)}
{/* Message bubble */}
<div className="relative group/message">
<div
className={cn(
"rounded-lg px-3 py-2 text-sm break-words",
isOwnMessage
? "bg-primary text-primary-foreground"
: "bg-muted",
isConsecutive && "mt-1"
)}
>
<p>{message.content}</p>
{/* Message reactions */}
{message.reactions.length > 0 && (
<div className="flex gap-1 mt-2">
{message.reactions.map((reaction, idx) => (
<div
key={idx}
className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs border cursor-pointer",
"bg-background/90 backdrop-blur-sm shadow-sm"
)}
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</div>
))}
</div>
)}
{/* Timestamp and status */}
<div className={cn(
"flex items-center gap-1 mt-1 text-xs",
isOwnMessage
? "text-primary-foreground/70 justify-end"
: "text-muted-foreground"
)}>
<span>{formatMessageTime(message.timestamp)}</span>
{message.isEdited && (
<span className="italic">(edited)</span>
)}
{isOwnMessage && (
<div className="flex">
{/* Message status indicators */}
<CheckCheck className="h-3 w-3" />
</div>
)}
</div>
</div>
{/* Message actions */}
<div className="absolute top-0 right-0 opacity-0 group-hover/message:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer"
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">
<Reply className="h-4 w-4 mr-2" />
Reply
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Copy className="h-4 w-4 mr-2" />
Copy
</DropdownMenuItem>
{isOwnMessage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
))}
{/* Scroll anchor */}
<div ref={bottomRef} />
</div>
</ScrollArea>
)
}
@@ -0,0 +1,96 @@
[
{
"id": "conv-1",
"type": "direct",
"participants": ["1"],
"name": "Sarah Mitchell",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
"lastMessage": {
"id": "msg-1-4",
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
"timestamp": "2025-08-11T15:30:00Z",
"senderId": "1"
},
"unreadCount": 2,
"isPinned": true,
"isMuted": false
},
{
"id": "conv-2",
"type": "group",
"participants": ["2", "3", "5"],
"name": "Project Alpha",
"lastMessage": {
"id": "msg-2-8",
"content": "David: Marketing campaign is scheduled for next week",
"timestamp": "2025-08-11T08:15:00Z",
"senderId": "2"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-3",
"type": "group",
"participants": ["2", "3", "5"],
"name": "Frontend Team",
"lastMessage": {
"id": "msg-3-6",
"content": "Alex: The new component library is ready for testing",
"timestamp": "2025-08-11T23:45:00Z",
"senderId": "3"
},
"unreadCount": 1,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-4",
"type": "direct",
"participants": ["3"],
"name": "Emily Rodriguez",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
"lastMessage": {
"id": "msg-4-3",
"content": "Let's review the wireframes together tomorrow",
"timestamp": "2025-08-10T16:30:00Z",
"senderId": "3"
},
"unreadCount": 1,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-5",
"type": "direct",
"participants": ["5"],
"name": "Lisa Chen",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
"lastMessage": {
"id": "msg-5-3",
"content": "Found a few edge cases in the new feature",
"timestamp": "2025-08-06T14:20:00Z",
"senderId": "5"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": true
},
{
"id": "conv-6",
"type": "direct",
"participants": ["2"],
"name": "Alex Thompson",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
"lastMessage": {
"id": "msg-6-3",
"content": "Code review completed, looks good to merge! 👍",
"timestamp": "2025-01-15T17:45:00Z",
"senderId": "2"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": false
}
]
+224
View File
@@ -0,0 +1,224 @@
{
"conv-1": [
{
"id": "msg-1-1",
"content": "Hey! How's the new dashboard coming along?",
"timestamp": "2024-01-15T10:15:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-1-2",
"content": "It's going great! We've implemented the new design system and it looks fantastic.",
"timestamp": "2024-01-15T10:17:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["1"], "count": 1}],
"replyTo": null
},
{
"id": "msg-1-3",
"content": "That's awesome! Can you share a preview?",
"timestamp": "2024-01-15T10:18:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-1-4",
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
"timestamp": "2024-01-15T10:30:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "❤️", "users": ["current-user"], "count": 1}],
"replyTo": null
}
],
"conv-2": [
{
"id": "msg-2-1",
"content": "Hey team! The component library update is ready",
"timestamp": "2024-01-15T09:00:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-2-2",
"content": "Awesome work Alex! 🚀",
"timestamp": "2024-01-15T09:05:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-2-3",
"content": "I've tested the new Button and Input components, they work perfectly",
"timestamp": "2024-01-15T09:10:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "✅", "users": ["2", "3"], "count": 2}],
"replyTo": null
},
{
"id": "msg-2-4",
"content": "Great! I'll start integrating them into the main app",
"timestamp": "2024-01-15T09:15:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-3": [
{
"id": "msg-3-1",
"content": "Hi! I've completed the wireframes for the new user onboarding flow",
"timestamp": "2024-01-15T09:30:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-3-2",
"content": "That's fantastic Emily! When can we review them?",
"timestamp": "2024-01-15T09:32:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-3-3",
"content": "How about tomorrow at 2 PM? I'll share my screen and walk through the designs",
"timestamp": "2024-01-15T09:35:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
"replyTo": null
},
{
"id": "msg-3-4",
"content": "Perfect! Looking forward to it",
"timestamp": "2024-01-15T09:40:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-4": [
{
"id": "msg-4-1",
"content": "Hi! I've been working on the wireframes for the new feature",
"timestamp": "2025-08-10T14:15:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-4-2",
"content": "That's great! I'd love to take a look at them",
"timestamp": "2025-08-10T14:18:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-4-3",
"content": "Let's review the wireframes together tomorrow",
"timestamp": "2025-08-10T16:30:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
"replyTo": null
}
],
"conv-5": [
{
"id": "msg-5-1",
"content": "I've been testing the new feature and it looks good overall",
"timestamp": "2025-08-06T13:45:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-5-2",
"content": "Thanks for testing it! Any issues you found?",
"timestamp": "2025-08-06T14:10:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-5-3",
"content": "Found a few edge cases in the new feature",
"timestamp": "2025-08-06T14:20:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-6": [
{
"id": "msg-6-1",
"content": "Hey! I've finished the code review for the latest PR",
"timestamp": "2025-01-15T16:30:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-6-2",
"content": "Thanks for the quick review! Any feedback?",
"timestamp": "2025-01-15T17:15:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-6-3",
"content": "Code review completed, looks good to merge! 👍",
"timestamp": "2025-01-15T17:45:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "🎉", "users": ["current-user"], "count": 1}],
"replyTo": null
}
]
}
+52
View File
@@ -0,0 +1,52 @@
[
{
"id": "1",
"name": "Sarah Mitchell",
"email": "sarah.mitchell@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
"status": "online",
"lastSeen": "2024-01-15T10:30:00Z",
"role": "Project Manager",
"department": "Product"
},
{
"id": "2",
"name": "Alex Thompson",
"email": "alex.thompson@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
"status": "away",
"lastSeen": "2024-01-15T09:45:00Z",
"role": "Senior Developer",
"department": "Engineering"
},
{
"id": "3",
"name": "Emily Rodriguez",
"email": "emily.rodriguez@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
"status": "online",
"lastSeen": "2024-01-15T10:25:00Z",
"role": "UX Designer",
"department": "Design"
},
{
"id": "4",
"name": "David Kim",
"email": "david.kim@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-5",
"status": "offline",
"lastSeen": "2024-01-14T18:30:00Z",
"role": "Marketing Lead",
"department": "Marketing"
},
{
"id": "5",
"name": "Lisa Chen",
"email": "lisa.chen@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
"status": "online",
"lastSeen": "2024-01-15T10:20:00Z",
"role": "QA Engineer",
"department": "Engineering"
}
]
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useEffect, useState } from "react"
import { Chat } from "./components/chat"
import { type Conversation, type Message, type User } from "./use-chat"
// Import static data
import conversationsData from "./data/conversations.json"
import messagesData from "./data/messages.json"
import usersData from "./data/users.json"
export default function ChatPage() {
const [conversations, setConversations] = useState<Conversation[]>([])
const [messages, setMessages] = useState<Record<string, Message[]>>({})
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadData = async () => {
try {
// In a real app, these would be API calls
setConversations(conversationsData as Conversation[])
setMessages(messagesData as Record<string, Message[]>)
setUsers(usersData as User[])
} catch (error) {
console.error("Failed to load chat data:", error)
} finally {
setLoading(false)
}
}
loadData()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-muted-foreground">Loading chat...</div>
</div>
)
}
return (
<div className="px-4 md:px-6">
<Chat
conversations={conversations}
messages={messages}
users={users}
/>
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
"use client"
import { create } from "zustand"
export interface User {
id: string
name: string
email: string
avatar: string
status: "online" | "away" | "offline"
lastSeen: string
role: string
department: string
}
export interface Message {
id: string
content: string
timestamp: string
senderId: string
type: "text" | "image" | "file"
isEdited: boolean
reactions: Array<{
emoji: string
users: string[]
count: number
}>
replyTo: string | null
}
export interface Conversation {
id: string
type: "direct" | "group"
participants: string[]
name: string
avatar: string
lastMessage: {
id: string
content: string
timestamp: string
senderId: string
}
unreadCount: number
isPinned: boolean
isMuted: boolean
}
interface ChatState {
conversations: Conversation[]
messages: Record<string, Message[]>
users: User[]
selectedConversation: string | null
searchQuery: string
isTyping: Record<string, boolean>
onlineUsers: string[]
}
interface ChatActions {
setConversations: (conversations: Conversation[]) => void
setMessages: (conversationId: string, messages: Message[]) => void
setUsers: (users: User[]) => void
setSelectedConversation: (conversationId: string | null) => void
setSearchQuery: (query: string) => void
addMessage: (conversationId: string, message: Message) => void
markAsRead: (conversationId: string) => void
togglePin: (conversationId: string) => void
toggleMute: (conversationId: string) => void
setTyping: (conversationId: string, isTyping: boolean) => void
setOnlineUsers: (userIds: string[]) => void
}
export const useChat = create<ChatState & ChatActions>((set, get) => ({
// State
conversations: [],
messages: {},
users: [],
selectedConversation: null,
searchQuery: "",
isTyping: {},
onlineUsers: [],
// Actions
setConversations: (conversations) => set({ conversations }),
setMessages: (conversationId, messages) =>
set((state) => ({
messages: { ...state.messages, [conversationId]: messages }
})),
setUsers: (users) => set({ users }),
setSelectedConversation: (conversationId) => {
set({ selectedConversation: conversationId })
if (conversationId) {
get().markAsRead(conversationId)
}
},
setSearchQuery: (query) => set({ searchQuery: query }),
addMessage: (conversationId, message) =>
set((state) => ({
messages: {
...state.messages,
[conversationId]: [...(state.messages[conversationId] || []), message]
},
conversations: state.conversations.map((conv) =>
conv.id === conversationId
? {
...conv,
lastMessage: {
id: message.id,
content: message.content,
timestamp: message.timestamp,
senderId: message.senderId
}
}
: conv
)
})),
markAsRead: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv
)
})),
togglePin: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, isPinned: !conv.isPinned } : conv
)
})),
toggleMute: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, isMuted: !conv.isMuted } : conv
)
})),
setTyping: (conversationId, isTyping) =>
set((state) => ({
isTyping: { ...state.isTyping, [conversationId]: isTyping }
})),
setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),
}))
@@ -0,0 +1,248 @@
"use client"
import { useState } from "react"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Users, MapPin, TrendingUp, Target, ArrowUpIcon, UserIcon } from "lucide-react"
const customerGrowthData = [
{ month: "Jan", new: 245, returning: 890, churn: 45 },
{ month: "Feb", new: 312, returning: 934, churn: 52 },
{ month: "Mar", new: 289, returning: 1023, churn: 38 },
{ month: "Apr", new: 456, returning: 1156, churn: 61 },
{ month: "May", new: 523, returning: 1298, churn: 47 },
{ month: "Jun", new: 634, returning: 1445, churn: 55 },
]
const chartConfig = {
new: {
label: "New Customers",
color: "var(--chart-1)",
},
returning: {
label: "Returning",
color: "var(--chart-2)",
},
churn: {
label: "Churned",
color: "var(--chart-3)",
},
}
const demographicsData = [
{ ageGroup: "18-24", customers: 2847, percentage: "18.0%", growth: "+15.2%", growthColor: "text-green-600" },
{ ageGroup: "25-34", customers: 4521, percentage: "28.5%", growth: "+8.7%", growthColor: "text-green-600" },
{ ageGroup: "35-44", customers: 3982, percentage: "25.1%", growth: "+3.4%", growthColor: "text-blue-600" },
{ ageGroup: "45-54", customers: 2734, percentage: "17.2%", growth: "+1.2%", growthColor: "text-orange-600" },
{ ageGroup: "55+", customers: 1763, percentage: "11.2%", growth: "-2.1%", growthColor: "text-red-600" },
]
const regionsData = [
{ region: "North America", customers: 6847, revenue: "$847,523", growth: "+12.3%", growthColor: "text-green-600" },
{ region: "Europe", customers: 4521, revenue: "$563,891", growth: "+9.7%", growthColor: "text-green-600" },
{ region: "Asia Pacific", customers: 2892, revenue: "$321,456", growth: "+18.4%", growthColor: "text-blue-600" },
{ region: "Latin America", customers: 1123, revenue: "$187,234", growth: "+15.8%", growthColor: "text-green-600" },
{ region: "Others", customers: 464, revenue: "$67,891", growth: "+5.2%", growthColor: "text-orange-600" },
]
export function CustomerInsights() {
const [activeTab, setActiveTab] = useState("growth")
return (
<Card className="h-fit">
<CardHeader>
<CardTitle>Customer Insights</CardTitle>
<CardDescription>Growth trends and demographics</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-muted/50 p-1 rounded-lg h-12">
<TabsTrigger
value="growth"
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
>
<TrendingUp className="h-4 w-4" />
<span className="hidden sm:inline">Growth</span>
</TabsTrigger>
<TabsTrigger
value="demographics"
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
>
<UserIcon className="h-4 w-4" />
<span className="hidden sm:inline">Demographics</span>
</TabsTrigger>
<TabsTrigger
value="regions"
className="cursor-pointer flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground"
>
<MapPin className="h-4 w-4" />
<span className="hidden sm:inline">Regions</span>
</TabsTrigger>
</TabsList>
<TabsContent value="growth" className="mt-8 space-y-6">
<div className="grid gap-6">
{/* Chart and Key Metrics Side by Side */}
<div className="grid grid-cols-10 gap-6">
{/* Chart Area - 70% */}
<div className="col-span-10 xl:col-span-7">
<h3 className="text-sm font-medium text-muted-foreground mb-6">Customer Growth Trends</h3>
<ChartContainer config={chartConfig} className="h-[375px] w-full">
<BarChart data={customerGrowthData} margin={{ top: 20, right: 20, bottom: 20, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="month"
className="text-xs"
tick={{ fontSize: 12 }}
tickLine={{ stroke: 'var(--border)' }}
axisLine={{ stroke: 'var(--border)' }}
/>
<YAxis
className="text-xs"
tick={{ fontSize: 12 }}
tickLine={{ stroke: 'var(--border)' }}
axisLine={{ stroke: 'var(--border)' }}
domain={[0, 'dataMax']}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="new" fill="var(--color-new)" radius={[2, 2, 0, 0]} />
<Bar dataKey="returning" fill="var(--color-returning)" radius={[2, 2, 0, 0]} />
<Bar dataKey="churn" fill="var(--color-churn)" radius={[2, 2, 0, 0]} />
</BarChart>
</ChartContainer>
</div>
{/* Key Metrics - 30% */}
<div className="col-span-10 xl:col-span-3 space-y-5">
<h3 className="text-sm font-medium text-muted-foreground mb-6">Key Metrics</h3>
<div className="grid grid-cols-3 gap-5">
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Total Customers</span>
</div>
<div className="text-2xl font-bold">15,847</div>
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
<ArrowUpIcon className="h-3 w-3" />
+12.5% from last month
</div>
</div>
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
<div className="flex items-center gap-2 mb-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Retention Rate</span>
</div>
<div className="text-2xl font-bold">92.4%</div>
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
<ArrowUpIcon className="h-3 w-3" />
+2.1% improvement
</div>
</div>
<div className="p-4 rounded-lg max-lg:col-span-3 xl:col-span-3 border">
<div className="flex items-center gap-2 mb-2">
<Target className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Avg. LTV</span>
</div>
<div className="text-2xl font-bold">$2,847</div>
<div className="text-xs text-green-600 flex items-center gap-1 mt-1">
<ArrowUpIcon className="h-3 w-3" />
+8.3% growth
</div>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="demographics" className="mt-8">
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow className="border-b">
<TableHead className="py-5 px-6 font-semibold">Age Group</TableHead>
<TableHead className="text-right py-5 px-6 font-semibold">Customers</TableHead>
<TableHead className="text-right py-5 px-6 font-semibold">Percentage</TableHead>
<TableHead className="text-right py-5 px-6 font-semibold">Growth</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{demographicsData.map((row, index) => (
<TableRow key={index} className="hover:bg-muted/30 transition-colors">
<TableCell className="font-medium py-5 px-6">{row.ageGroup}</TableCell>
<TableCell className="text-right py-5 px-6">{row.customers.toLocaleString()}</TableCell>
<TableCell className="text-right py-5 px-6">{row.percentage}</TableCell>
<TableCell className="text-right py-5 px-6">
<span className={`font-medium ${row.growthColor}`}>{row.growth}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-6">
<div className="text-muted-foreground text-sm hidden sm:block">
0 of {demographicsData.length} row(s) selected.
</div>
<div className="space-x-2 space-y-2">
<Button variant="outline" size="sm" disabled>
Previous
</Button>
<Button variant="outline" size="sm" disabled>
Next
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="regions" className="mt-8">
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow className="border-b">
<TableHead className="py-5 px-6 font-semibold">Region</TableHead>
<TableHead className="text-right py-5 px-6 font-semibold">Customers</TableHead>
<TableHead className="text-right py-5 px-6 font-semibold">Revenue</TableHead>
<TableHead className="text-right py-5 px-6 font-semibold">Growth</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{regionsData.map((row, index) => (
<TableRow key={index} className="hover:bg-muted/30 transition-colors">
<TableCell className="font-medium py-5 px-6">{row.region}</TableCell>
<TableCell className="text-right py-5 px-6">{row.customers.toLocaleString()}</TableCell>
<TableCell className="text-right py-5 px-6">{row.revenue}</TableCell>
<TableCell className="text-right py-5 px-6">
<span className={`font-medium ${row.growthColor}`}>{row.growth}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-6">
<div className="text-muted-foreground text-sm hidden sm:block">
0 of {regionsData.length} row(s) selected.
</div>
<div className="space-x-2 space-y-2">
<Button variant="outline" size="sm" disabled>
Previous
</Button>
<Button variant="outline" size="sm" disabled>
Next
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}
@@ -0,0 +1,69 @@
"use client"
import { useState } from "react"
import { Calendar, Clock, RefreshCw, Filter } from "lucide-react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
export function DashboardHeader() {
const [dateRange, setDateRange] = useState("30d")
const lastUpdated = new Date().toLocaleString()
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-3xl font-bold">Business Dashboard</CardTitle>
<CardDescription className="text-base mt-2">
Comprehensive overview of your business performance and key metrics
</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="cursor-pointer">
<Clock className="h-3 w-3 mr-1" />
Live Data
</Badge>
<Button variant="outline" size="sm" className="cursor-pointer">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Date Range:</span>
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-40 cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d" className="cursor-pointer">Last 7 days</SelectItem>
<SelectItem value="30d" className="cursor-pointer">Last 30 days</SelectItem>
<SelectItem value="90d" className="cursor-pointer">Last 90 days</SelectItem>
<SelectItem value="1y" className="cursor-pointer">Last year</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" className="cursor-pointer">
<Filter className="h-4 w-4 mr-2" />
Filters
</Button>
</div>
<div className="text-sm text-muted-foreground">
Last updated: {lastUpdated}
</div>
</div>
</CardHeader>
</Card>
)
}
@@ -0,0 +1,90 @@
"use client"
import {
TrendingUp,
TrendingDown,
DollarSign,
Users,
ShoppingCart,
BarChart3
} from "lucide-react"
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
const metrics = [
{
title: "Total Revenue",
value: "$54,230",
description: "Monthly revenue",
change: "+12%",
trend: "up",
icon: DollarSign,
footer: "Trending up this month",
subfooter: "Revenue for the last 6 months"
},
{
title: "Active Customers",
value: "2,350",
description: "Total active users",
change: "+5.2%",
trend: "up",
icon: Users,
footer: "Strong user retention",
subfooter: "Engagement exceeds targets"
},
{
title: "Total Orders",
value: "1,247",
description: "Orders this month",
change: "-2.1%",
trend: "down",
icon: ShoppingCart,
footer: "Down 2% this period",
subfooter: "Order volume needs attention"
},
{
title: "Conversion Rate",
value: "3.24%",
description: "Average conversion",
change: "+8.3%",
trend: "up",
icon: BarChart3,
footer: "Steady performance increase",
subfooter: "Meets conversion projections"
},
]
export function MetricsOverview() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 @5xl:grid-cols-4">
{metrics.map((metric) => {
const TrendIcon = metric.trend === "up" ? TrendingUp : TrendingDown
return (
<Card key={metric.title} className=" cursor-pointer">
<CardHeader>
<CardDescription>{metric.title}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{metric.value}
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendIcon className="h-4 w-4" />
{metric.change}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{metric.footer} <TrendIcon className="size-4" />
</div>
<div className="text-muted-foreground">
{metric.subfooter}
</div>
</CardFooter>
</Card>
)
})}
</div>
)
}
@@ -0,0 +1,39 @@
"use client"
import { Plus, Settings, FileText, Download } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
export function QuickActions() {
return (
<div className="flex items-center space-x-2">
<Button className="cursor-pointer">
<Plus className="h-4 w-4 mr-2" />
New Sale
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">
<FileText className="h-4 w-4 mr-2" />
Generate Report
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Download className="h-4 w-4 mr-2" />
Export Data
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Dashboard Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,130 @@
"use client"
import { Eye, MoreHorizontal } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
const transactions = [
{
id: "TXN-001",
customer: {
name: "Olivia Martin",
email: "olivia.martin@email.com",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
},
amount: "$1,999.00",
status: "completed",
date: "2 hours ago",
},
{
id: "TXN-002",
customer: {
name: "Jackson Lee",
email: "jackson.lee@email.com",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
},
amount: "$2,999.00",
status: "pending",
date: "5 hours ago",
},
{
id: "TXN-003",
customer: {
name: "Isabella Nguyen",
email: "isabella.nguyen@email.com",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
},
amount: "$39.00",
status: "completed",
date: "1 day ago",
},
{
id: "TXN-004",
customer: {
name: "William Kim",
email: "will@email.com",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-5",
},
amount: "$299.00",
status: "failed",
date: "2 days ago",
},
{
id: "TXN-005",
customer: {
name: "Sofia Davis",
email: "sofia.davis@email.com",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
},
amount: "$99.00",
status: "completed",
date: "3 days ago",
},
]
export function RecentTransactions() {
return (
<Card className="cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div>
<CardTitle>Recent Transactions</CardTitle>
<CardDescription>Latest customer transactions</CardDescription>
</div>
<Button variant="outline" size="sm" className="cursor-pointer">
<Eye className="h-4 w-4 mr-2" />
View All
</Button>
</CardHeader>
<CardContent className="space-y-4">
{transactions.map((transaction) => (
<div key={transaction.id} >
<div className="flex p-3 rounded-lg border gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={transaction.customer.avatar} alt={transaction.customer.name} />
<AvatarFallback>{transaction.customer.name.split(" ").map(n => n[0]).join("")}</AvatarFallback>
</Avatar>
<div className="flex flex-1 items-center flex-wrap justify-between gap-1">
<div className="flex items-center space-x-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{transaction.customer.name}</p>
<p className="text-xs text-muted-foreground truncate">{transaction.customer.email}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge
variant={
transaction.status === "completed" ? "default" :
transaction.status === "pending" ? "secondary" : "destructive"
}
className="cursor-pointer"
>
{transaction.status}
</Badge>
<div className="text-right">
<p className="text-sm font-medium">{transaction.amount}</p>
<p className="text-xs text-muted-foreground">{transaction.date}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 cursor-pointer">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">View Details</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">Download Receipt</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">Contact Customer</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
)
}
@@ -0,0 +1,204 @@
"use client"
import * as React from "react"
import { Label, Pie, PieChart, Sector } from "recharts"
import type { PieSectorDataItem } from "recharts/types/polar/Pie"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartContainer, ChartStyle, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
const revenueData = [
{ category: "subscriptions", value: 45, amount: 24500, fill: "var(--color-subscriptions)" },
{ category: "sales", value: 30, amount: 16300, fill: "var(--color-sales)" },
{ category: "services", value: 15, amount: 8150, fill: "var(--color-services)" },
{ category: "partnerships", value: 10, amount: 5430, fill: "var(--color-partnerships)" },
]
const chartConfig = {
revenue: {
label: "Revenue",
},
amount: {
label: "Amount",
},
subscriptions: {
label: "Subscriptions",
color: "var(--chart-1)",
},
sales: {
label: "One-time Sales",
color: "var(--chart-2)",
},
services: {
label: "Services",
color: "var(--chart-3)",
},
partnerships: {
label: "Partnerships",
color: "var(--chart-4)",
},
}
export function RevenueBreakdown() {
const id = "revenue-breakdown"
const [activeCategory, setActiveCategory] = React.useState("sales")
const activeIndex = React.useMemo(() => {
const index = revenueData.findIndex((item) => item.category === activeCategory)
return index === -1 ? 0 : index
}, [activeCategory])
const categories = React.useMemo(() => revenueData.map((item) => item.category), [])
return (
<Card data-chart={id} className="flex flex-col cursor-pointer">
<ChartStyle id={id} config={chartConfig} />
<CardHeader className="flex flex-col space-y-2 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-2">
<div>
<CardTitle>Revenue Breakdown</CardTitle>
<CardDescription>Revenue distribution by source</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Select value={activeCategory} onValueChange={setActiveCategory}>
<SelectTrigger
className="w-[175px] rounded-lg cursor-pointer"
aria-label="Select a category"
>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent align="end" className="rounded-lg">
{categories.map((key) => {
const config = chartConfig[key as keyof typeof chartConfig]
if (!config) {
return null
}
return (
<SelectItem
key={key}
value={key}
className="rounded-md [&_span]:flex cursor-pointer"
>
<div className="flex items-center gap-2">
<span
className="flex h-3 w-3 shrink-0 "
style={{
backgroundColor: `var(--color-${key})`,
}}
/>
{config?.label}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
<Button variant="outline" className="cursor-pointer">
Export
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-1 justify-center">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
<div className="flex justify-center">
<ChartContainer
id={id}
config={chartConfig}
className="mx-auto aspect-square w-full max-w-[300px]"
>
<PieChart>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={revenueData}
dataKey="amount"
nameKey="category"
innerRadius={60}
strokeWidth={5}
activeShape={({
outerRadius = 0,
...props
}: PieSectorDataItem) => (
<g>
<Sector {...props} outerRadius={outerRadius + 10} />
<Sector
{...props}
outerRadius={outerRadius + 25}
innerRadius={outerRadius + 12}
/>
</g>
)}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-3xl font-bold"
>
${(revenueData[activeIndex].amount / 1000).toFixed(0)}K
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground"
>
Revenue
</tspan>
</text>
)
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
</div>
<div className="flex flex-col justify-center space-y-4">
{revenueData.map((item, index) => {
const config = chartConfig[item.category as keyof typeof chartConfig]
const isActive = index === activeIndex
return (
<div
key={item.category}
className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${
isActive ? 'bg-muted' : 'hover:bg-muted/50'
}`}
onClick={() => setActiveCategory(item.category)}
>
<div className="flex items-center gap-3">
<span
className="flex h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor: `var(--color-${item.category})`,
}}
/>
<span className="font-medium">{config?.label}</span>
</div>
<div className="text-right">
<div className="font-bold">${(item.amount / 1000).toFixed(1)}K</div>
<div className="text-sm text-muted-foreground">{item.value}%</div>
</div>
</div>
)
})}
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,115 @@
"use client"
import { useState } from "react"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
const salesData = [
{ month: "Jan", sales: 12500, target: 15000 },
{ month: "Feb", sales: 18200, target: 15000 },
{ month: "Mar", sales: 16800, target: 15000 },
{ month: "Apr", sales: 22400, target: 20000 },
{ month: "May", sales: 24600, target: 20000 },
{ month: "Jun", sales: 28200, target: 25000 },
{ month: "Jul", sales: 31500, target: 25000 },
{ month: "Aug", sales: 29800, target: 25000 },
{ month: "Sep", sales: 33200, target: 30000 },
{ month: "Oct", sales: 35100, target: 30000 },
{ month: "Nov", sales: 38900, target: 35000 },
{ month: "Dec", sales: 42300, target: 35000 },
]
const chartConfig = {
sales: {
label: "Sales",
color: "var(--primary)",
},
target: {
label: "Target",
color: "var(--primary)",
},
}
export function SalesChart() {
const [timeRange, setTimeRange] = useState("12m")
return (
<Card className="cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle>Sales Performance</CardTitle>
<CardDescription>Monthly sales vs targets</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-32 cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3m" className="cursor-pointer">Last 3 months</SelectItem>
<SelectItem value="6m" className="cursor-pointer">Last 6 months</SelectItem>
<SelectItem value="12m" className="cursor-pointer">Last 12 months</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" className="cursor-pointer">
Export
</Button>
</div>
</CardHeader>
<CardContent className="p-0 pt-6">
<div className="px-6 pb-6">
<ChartContainer config={chartConfig} className="h-[350px] w-full">
<AreaChart data={salesData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorSales" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.4} />
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="colorTarget" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-target)" stopOpacity={0.2} />
<stop offset="95%" stopColor="var(--color-target)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/30" />
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
className="text-xs"
tick={{ fontSize: 12 }}
/>
<YAxis
axisLine={false}
tickLine={false}
className="text-xs"
tick={{ fontSize: 12 }}
tickFormatter={(value) => `$${value.toLocaleString()}`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="target"
stackId="1"
stroke="var(--color-target)"
fill="url(#colorTarget)"
strokeDasharray="5 5"
strokeWidth={1}
/>
<Area
type="monotone"
dataKey="sales"
stackId="2"
stroke="var(--color-sales)"
fill="url(#colorSales)"
strokeWidth={1}
/>
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,123 @@
"use client"
import { Eye, Star, TrendingUp } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
const products = [
{
id: 1,
name: "Premium Dashboard",
sales: 2847,
revenue: "$142,350",
growth: "+23%",
rating: 4.8,
stock: 145,
category: "Software",
},
{
id: 2,
name: "Analytics Pro",
sales: 1923,
revenue: "$96,150",
growth: "+18%",
rating: 4.6,
stock: 67,
category: "Tools",
},
{
id: 3,
name: "Mobile App Suite",
sales: 1456,
revenue: "$72,800",
growth: "+12%",
rating: 4.9,
stock: 234,
category: "Mobile",
},
{
id: 4,
name: "Enterprise License",
sales: 892,
revenue: "$178,400",
growth: "+8%",
rating: 4.7,
stock: 12,
category: "Enterprise",
},
{
id: 5,
name: "Basic Subscription",
sales: 3421,
revenue: "$68,420",
growth: "+31%",
rating: 4.4,
stock: 999,
category: "Subscription",
},
]
export function TopProducts() {
return (
<Card className="cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div>
<CardTitle>Top Products</CardTitle>
<CardDescription>Best performing products this month</CardDescription>
</div>
<Button variant="outline" size="sm" className="cursor-pointer">
<Eye className="h-4 w-4 mr-2" />
View All
</Button>
</CardHeader>
<CardContent className="space-y-4">
{products.map((product, index) => (
<div key={product.id} className="flex items-center p-3 rounded-lg border gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
#{index + 1}
</div>
<div className="flex gap-2 items-center justify-between space-x-3 flex-1 flex-wrap">
<div className="">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium truncate">{product.name}</p>
<Badge variant="outline" className="text-xs">
{product.category}
</Badge>
</div>
<div className="flex items-center space-x-2 mt-1">
<div className="flex items-center space-x-1">
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
<span className="text-xs text-muted-foreground">{product.rating}</span>
</div>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{product.sales} sales</span>
</div>
</div>
<div className="text-right space-y-1">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">{product.revenue}</p>
<Badge
variant="outline"
className="text-green-600 border-green-200 cursor-pointer"
>
<TrendingUp className="h-3 w-3 mr-1" />
{product.growth}
</Badge>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-muted-foreground">Stock: {product.stock}</span>
<Progress
value={product.stock > 100 ? 100 : (product.stock / 100) * 100}
className="w-12 h-1"
/>
</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
)
}
@@ -0,0 +1,39 @@
{
"totalRevenue": 54231.89,
"revenueChange": 12.5,
"activeCustomers": 2350,
"customerChange": 5.2,
"totalOrders": 1247,
"orderChange": -2.1,
"conversionRate": 3.24,
"conversionChange": 8.3,
"salesData": [
{ "month": "Jan", "sales": 12500, "target": 15000 },
{ "month": "Feb", "sales": 18200, "target": 15000 },
{ "month": "Mar", "sales": 16800, "target": 15000 },
{ "month": "Apr", "sales": 22400, "target": 20000 },
{ "month": "May", "sales": 24600, "target": 20000 },
{ "month": "Jun", "sales": 28200, "target": 25000 },
{ "month": "Jul", "sales": 31500, "target": 25000 },
{ "month": "Aug", "sales": 29800, "target": 25000 },
{ "month": "Sep", "sales": 33200, "target": 30000 },
{ "month": "Oct", "sales": 35100, "target": 30000 },
{ "month": "Nov", "sales": 38900, "target": 35000 },
{ "month": "Dec", "sales": 42300, "target": 35000 }
],
"revenueBreakdown": [
{ "name": "Subscriptions", "value": 45, "amount": 24500, "color": "hsl(210, 100%, 50%)" },
{ "name": "One-time Sales", "value": 30, "amount": 16300, "color": "hsl(280, 100%, 70%)" },
{ "name": "Services", "value": 15, "amount": 8150, "color": "hsl(120, 100%, 40%)" },
{ "name": "Partnerships", "value": 10, "amount": 5430, "color": "hsl(30, 100%, 50%)" }
],
"customerGrowth": [
{ "month": "Jan", "new": 245, "returning": 890, "churn": 45 },
{ "month": "Feb", "new": 312, "returning": 934, "churn": 52 },
{ "month": "Mar", "new": 289, "returning": 1023, "churn": 38 },
{ "month": "Apr", "new": 456, "returning": 1156, "churn": 61 },
{ "month": "May", "new": 523, "returning": 1298, "churn": 47 },
{ "month": "Jun", "new": 634, "returning": 1445, "churn": 55 }
],
"lastUpdated": "2025-08-12T15:30:00Z"
}
+47
View File
@@ -0,0 +1,47 @@
import { MetricsOverview } from "./components/metrics-overview"
import { SalesChart } from "./components/sales-chart"
import { RecentTransactions } from "./components/recent-transactions"
import { TopProducts } from "./components/top-products"
import { CustomerInsights } from "./components/customer-insights"
import { QuickActions } from "./components/quick-actions"
import { RevenueBreakdown } from "./components/revenue-breakdown"
export default function Dashboard2() {
return (
<div className="flex-1 space-y-6 px-6 pt-0">
{/* Enhanced Header */}
<div className="flex md:flex-row flex-col md:items-center justify-between gap-4 md:gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold tracking-tight">Business Dashboard</h1>
<p className="text-muted-foreground">
Monitor your business performance and key metrics in real-time
</p>
</div>
<QuickActions />
</div>
{/* Main Dashboard Grid */}
<div className="@container/main space-y-6">
{/* Top Row - Key Metrics */}
<MetricsOverview />
{/* Second Row - Charts in 6-6 columns */}
<div className="grid gap-6 grid-cols-1 @5xl:grid-cols-2">
<SalesChart />
<RevenueBreakdown />
</div>
{/* Third Row - Two Column Layout */}
<div className="grid gap-6 grid-cols-1 @5xl:grid-cols-2">
<RecentTransactions />
<TopProducts />
</div>
{/* Fourth Row - Customer Insights and Team Performance */}
<CustomerInsights />
</div>
</div>
)
}
@@ -0,0 +1,291 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
export const description = "An interactive area chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { TrendingDown, TrendingUp } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export function SectionCards() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <TrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <TrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <TrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <TrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
)
}
@@ -0,0 +1,614 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]
@@ -0,0 +1,47 @@
[
{
"id": 1,
"header": "Technical Specifications Document v2.1",
"type": "Technical Document",
"status": "Final",
"target": "100%",
"limit": "100%",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Security Compliance Report Q4 2024",
"type": "Compliance Document",
"status": "Under Review",
"target": "95%",
"limit": "100%",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 3,
"header": "Project Management Plan v3.0",
"type": "Management Document",
"status": "Final",
"target": "100%",
"limit": "100%",
"reviewer": "Emily Whalen"
},
{
"id": 4,
"header": "Risk Assessment Matrix 2025",
"type": "Risk Document",
"status": "Draft",
"target": "80%",
"limit": "90%",
"reviewer": "Eddie Lake"
},
{
"id": 5,
"header": "Quality Assurance Protocol v1.5",
"type": "QA Document",
"status": "Final",
"target": "100%",
"limit": "100%",
"reviewer": "Jamik Tashpulatov"
}
]
@@ -0,0 +1,47 @@
[
{
"id": 1,
"header": "Dr. Sarah Mitchell",
"type": "Project Manager",
"status": "Active",
"target": "15 years",
"limit": "20 years",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "James Thompson",
"type": "Lead Engineer",
"status": "Active",
"target": "12 years",
"limit": "15 years",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 3,
"header": "Maria Rodriguez",
"type": "Security Specialist",
"status": "Active",
"target": "8 years",
"limit": "10 years",
"reviewer": "Emily Whalen"
},
{
"id": 4,
"header": "David Chen",
"type": "Systems Architect",
"status": "Active",
"target": "10 years",
"limit": "12 years",
"reviewer": "Eddie Lake"
},
{
"id": 5,
"header": "Lisa Johnson",
"type": "Quality Assurance Lead",
"status": "Active",
"target": "6 years",
"limit": "8 years",
"reviewer": "Jamik Tashpulatov"
}
]
@@ -0,0 +1,47 @@
[
{
"id": 1,
"header": "Federal Communications Commission - Network Infrastructure Modernization",
"type": "Government Contract",
"status": "Completed",
"target": "95%",
"limit": "100%",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Department of Defense - Cybersecurity Enhancement Program",
"type": "Defense Contract",
"status": "Completed",
"target": "98%",
"limit": "100%",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 3,
"header": "NASA - Satellite Communication System Upgrade",
"type": "Space Technology",
"status": "Completed",
"target": "92%",
"limit": "95%",
"reviewer": "Emily Whalen"
},
{
"id": 4,
"header": "Department of Homeland Security - Border Security Tech",
"type": "Security Contract",
"status": "In Progress",
"target": "85%",
"limit": "90%",
"reviewer": "Eddie Lake"
},
{
"id": 5,
"header": "GSA - Cloud Infrastructure Migration",
"type": "IT Services",
"status": "Completed",
"target": "96%",
"limit": "98%",
"reviewer": "Jamik Tashpulatov"
}
]
+35
View File
@@ -0,0 +1,35 @@
import { ChartAreaInteractive } from "./components/chart-area-interactive"
import { DataTable } from "./components/data-table"
import { SectionCards } from "./components/section-cards"
import data from "./data/data.json"
import pastPerformanceData from "./data/past-performance-data.json"
import keyPersonnelData from "./data/key-personnel-data.json"
import focusDocumentsData from "./data/focus-documents-data.json"
export default function Page() {
return (
<>
{/* Page Title and Description */}
<div className="px-4 lg:px-6">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">Welcome to your admin dashboard</p>
</div>
</div>
<div className="@container/main px-4 lg:px-6 space-y-6">
<SectionCards />
<ChartAreaInteractive />
</div>
<div className="@container/main">
<DataTable
data={data}
pastPerformanceData={pastPerformanceData}
keyPersonnelData={keyPersonnelData}
focusDocumentsData={focusDocumentsData}
/>
</div>
</>
)
}
@@ -0,0 +1,13 @@
import { z } from "zod"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
export type Task = z.infer<typeof schema>
@@ -0,0 +1,129 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { cn } from "@/lib/utils"
import { Search } from "lucide-react"
interface FAQ {
id: number
question: string
answer: string
category: string
}
interface Category {
name: string
count: number
}
interface FAQListProps {
faqs: FAQ[]
categories: Category[]
}
export function FAQList({ faqs, categories }: FAQListProps) {
const [selectedCategory, setSelectedCategory] = useState("All")
const [searchQuery, setSearchQuery] = useState("")
// Filter FAQs based on selected category and search query
const filteredFaqs = faqs.filter(faq => {
const matchesCategory = selectedCategory === "All" || faq.category === selectedCategory
const matchesSearch = searchQuery === "" ||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
return (
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-4 gap-6">
{/* Categories Sidebar */}
<Card className="lg:col-span-2 xl:col-span-1">
<CardHeader>
<CardTitle className="text-lg">Categories</CardTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search FAQs..."
className="pl-10 cursor-pointer"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</CardHeader>
<CardContent className="space-y-2">
{categories.map((category) => (
<div
key={category.name}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg hover:bg-muted cursor-pointer transition-colors group",
selectedCategory === category.name && "bg-muted"
)}
onClick={() => setSelectedCategory(category.name)}
>
<span className="font-medium">{category.name}</span>
<Badge
variant="secondary"
className={cn(
"transition-colors",
selectedCategory === category.name && "bg-background"
)}
>
{category.name === "All" ? faqs.length : category.count}
</Badge>
</div>
))}
</CardContent>
</Card>
{/* FAQs List */}
<div className="lg:col-span-4 xl:col-span-3">
<Card>
<CardHeader>
<CardTitle className="text-lg">
{selectedCategory === "All" ? "All FAQs" : `${selectedCategory} FAQs`}
<span className="text-sm font-normal text-muted-foreground ml-2">
({filteredFaqs.length} {filteredFaqs.length === 1 ? 'question' : 'questions'})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[570px] pr-4">
{filteredFaqs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No FAQs found matching your search criteria.</p>
</div>
) : (
<Accordion type='single' className='space-y-4' defaultValue="item-1">
{filteredFaqs.map((item) => (
<AccordionItem
key={item.id}
value={`item-${item.id}`}
className='rounded-md !border'
>
<AccordionTrigger className='cursor-pointer px-4 hover:no-underline'>
<div className="flex items-start text-left">
<span>{item.question}</span>
<Badge variant="outline" className="ms-3 mt-0.5 shrink-0 text-xs">
{item.category}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className='text-muted-foreground px-4'>
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More