feat: add Review Management module and UI layout fixes

This commit is contained in:
2026-03-07 11:55:18 +05:30
commit 1da66c1be5
226 changed files with 39160 additions and 0 deletions

24
.gitignore vendored Normal file
View File

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

216
.lovable/plan.md Normal file
View File

@@ -0,0 +1,216 @@
# Eventify Backoffice - Implementation Plan
## Overview
A beautiful admin command center using the **Neumorphic Blue Theme** design system with React + Vite, featuring a full navigation shell with dashboard home and placeholder pages for all modules.
---
## 1. Design System Foundation
### Tailwind Configuration
- Extend `tailwind.config.ts` with custom neumorphic shadows:
- `shadow-neu` - Raised elements (cards, buttons)
- `shadow-neu-inset` - Pressed/input fields
- `shadow-neu-hover` - Elevated hover states
- `shadow-neu-flat` - Subtle depth
- Add custom colors: `neu-base`, `neu-surface`, `neu-raised`, `neu-inset`
- Configure Deep Blue, Royal Blue, Ocean Blue, Sky Blue, Ice Blue palette
---
## 2. Core Layout Components
### AppLayout (Shell)
- **Fixed Sidebar** (264px width):
- Eventify logo and "BACKOFFICE" label
- Navigation items with neumorphic buttons:
- Dashboard (Home icon)
- Partner Management (Users icon)
- Events (Calendar icon)
- Users (User icon)
- Financials (DollarSign icon)
- Settings (Settings icon)
- Active state with Royal Blue background
- Hover effects with `shadow-neu-hover`
### Top Bar
- Page title with description
- Global search input (neumorphic inset style)
- Notification bell (neumorphic icon button with badge)
- Admin profile avatar with dropdown
---
## 3. Dashboard Home Page
### Metrics Grid (4 cards in responsive grid)
Using the reusable **DashboardMetricCard** component:
1. **Total Platform Revenue**
- Value: ₹24,50,000
- Growth indicator: +12.5% MoM
- Icon: TrendingUp (green)
2. **Active Partners**
- Value: 156
- Subtitle: 12 pending approval
- Icon: Users
3. **Live Events**
- Value: 43
- Subtitle: 8 starting today
- Icon: Calendar
4. **Ticket Sales Volume**
- Value: 12,847
- Growth indicator: +8.3% this week
- Icon: Ticket
### Action Items Section (Priority Queue)
Neumorphic card containing urgent items from Partner Controls:
- **Partner Approval Queue**: "5 Partners awaiting KYC verification" → Link to review
- **Flagged Events**: "3 Events reported for review" → Link to moderation
- **Pending Payouts**: "₹8,45,000 ready for release" → Link to financials
- **Stripe Issues**: "2 Connected accounts need attention" → Link to fix
Each item shows count badge, description, and action button.
### Revenue Analytics Section
- **Revenue vs Payouts Chart** placeholder
- Static SVG bar chart visualization
- Legend: Platform Revenue (blue) vs Partner Payouts (green)
- Time period: Last 7 days
### Recent Activity Feed
- Compact list showing latest platform activities
- Examples: "New partner registered", "Event approved", "Payout completed"
- Timestamps and status indicators
---
## 4. Navigation Pages (Placeholders)
Each page will have:
- Consistent top bar with title
- "Coming Soon" neumorphic card
- Relevant icon and brief description
### Partner Management
- Placeholder with stats preview
- Quick access buttons for KYC queue
### Events
- Placeholder with calendar icon
- Event count summary
### Users
- Placeholder with user stats
### Financials
- Placeholder with revenue summary
### Settings
- Placeholder with configuration options
---
## 5. Reusable Components
### DashboardMetricCard
- Accepts: `title`, `value`, `subtitle`, `icon`, `trend`
- Neumorphic styling with icon container
- Hover elevation effect
### ActionItemCard
- Accepts: `title`, `count`, `description`, `actionLabel`, `href`
- Warning/attention styling
- Badge with count
### NeuButton (Primary/Secondary/Icon variants)
- Primary: Blue background with white text
- Secondary: Neumorphic surface with blue text
- Icon: Square neumorphic button
### NeuInput
- Inset shadow styling
- Focus ring animation
- Label support
---
## 6. API-Ready Architecture
### Data Interfaces
All components will use TypeScript interfaces ready for API integration:
```typescript
interface DashboardMetrics {
totalRevenue: number;
revenueGrowth: number;
activePartners: number;
pendingPartners: number;
liveEvents: number;
eventsToday: number;
ticketSales: number;
ticketGrowth: number;
}
interface ActionItem {
id: string;
type: 'kyc' | 'flagged' | 'payout' | 'stripe';
count: number;
description: string;
}
```
### Hooks Structure
- `useDashboardMetrics()` - Ready to fetch from API
- `useActionItems()` - Ready to fetch pending actions
- Static mock data initially, easily replaceable
---
## 7. File Structure
```
src/
├── components/
│ ├── layout/
│ │ ├── AppLayout.tsx # Main shell with sidebar
│ │ ├── Sidebar.tsx # Navigation sidebar
│ │ └── TopBar.tsx # Header with search/profile
│ ├── dashboard/
│ │ ├── DashboardMetricCard.tsx
│ │ ├── ActionItemsPanel.tsx
│ │ └── RevenueChart.tsx
│ └── ui/
│ ├── NeuButton.tsx
│ └── NeuInput.tsx
├── pages/
│ ├── Dashboard.tsx # Main dashboard
│ ├── Partners.tsx # Partner management
│ ├── Events.tsx # Events management
│ ├── Users.tsx # User management
│ ├── Financials.tsx # Financial overview
│ └── Settings.tsx # Platform settings
├── types/
│ └── dashboard.ts # TypeScript interfaces
└── data/
└── mockData.ts # Static demo data
```
---
## Visual Design Summary
- **Background**: `#E8EFF8` (neu-base) - Soft blue-gray
- **Cards**: `#DFE9F5` (neu-surface) with `shadow-neu`
- **Text**: `#0F1E3D` (Deep Blue) primary
- **Accents**: `#1E3A8A` (Royal Blue) for active states
- **Interactive**: `#3B82F6` (Ocean Blue) for links/buttons
- **Rounded corners**: `rounded-xl` (12px) and `rounded-2xl` (16px)
- **Smooth transitions**: All interactive elements animate on hover/press

159
API_AND_DB_SPEC.md Normal file
View File

@@ -0,0 +1,159 @@
# Eventify Command Center - API & Database Specification
This document outlines the required API endpoints and Database schema to support the current features of the Eventify Command Center (Admin Panel).
---
## 🏗 Database Schema
### 1. User Management (RBAC)
**`admin_users`** (Internal Staff)
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `email` | VARCHAR | Unique email |
| `password_hash` | VARCHAR | Hashed password |
| `full_name` | VARCHAR | Display name |
| `role_id` | UUID | FK to `roles` |
| `status` | ENUM | 'Active', 'Inactive' |
| `last_active_at` | TIMESTAMP | Last login time |
**`roles`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `name` | VARCHAR | e.g. "Super Admin", "Content Moderator" |
| `description` | TEXT | |
| `is_system` | BOOLEAN | If true, cannot be deleted |
**`permissions`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | VARCHAR | Primary Key (e.g. `manage_partners`) |
| `name` | VARCHAR | Human readable name |
| `group` | VARCHAR | e.g. "Finance", "Users" |
**`role_permissions`** (Junction Table)
| Column | Type | Description |
| :--- | :--- | :--- |
| `role_id` | UUID | FK to `roles` |
| `permission_id` | VARCHAR | FK to `permissions` |
---
### 2. Partner Management
**`partners`** (Organizations)
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `name` | VARCHAR | Business Name |
| `type` | ENUM | 'Venue', 'Promoter', 'Sponsor', 'Vendor' |
| `status` | ENUM | 'Active', 'Invited', 'Suspended' |
| `logo_url` | VARCHAR | |
| `verification_status` | ENUM | 'Pending', 'Verified', 'Rejected' |
| `total_revenue` | DECIMAL | Cache field for performance |
| `open_balance` | DECIMAL | Amount owed to/by partner |
| `joined_at` | TIMESTAMP | |
**`partner_contacts`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `partner_id` | UUID | FK to `partners` |
| `name` | VARCHAR | |
| `email` | VARCHAR | |
| `phone` | VARCHAR | |
| `is_primary` | BOOLEAN | |
**`partner_documents`** (KYC)
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `partner_id` | UUID | FK to `partners` |
| `type` | ENUM | 'Company_Reg', 'PAN', 'Cheque', 'Other' |
| `file_url` | VARCHAR | S3/Blob URL |
| `status` | ENUM | 'Pending', 'Verified', 'Rejected' |
| `uploaded_at` | TIMESTAMP | |
| `verified_at` | TIMESTAMP | |
---
### 3. End Users (B2C)
**`end_users`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `email` | VARCHAR | |
| `phone` | VARCHAR | |
| `full_name` | VARCHAR | |
| `status` | ENUM | 'Active', 'Banned' |
| `total_spent` | DECIMAL | Lifetime value |
| `created_at` | TIMESTAMP | |
---
### 4. Operations & Logs
**`audit_logs`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `actor_id` | UUID | FK to `admin_users` |
| `action` | VARCHAR | e.g. "APPROVED_KYC" |
| `target_resource` | VARCHAR | e.g. "partner_123" |
| `details` | JSONB | Metadata about changes |
| `created_at` | TIMESTAMP | |
**`notifications`**
| Column | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Primary Key |
| `recipient_id` | UUID | FK to `admin_users` |
| `type` | ENUM | 'Critical', 'Info', 'Success' |
| `title` | VARCHAR | |
| `message` | TEXT | |
| `is_read` | BOOLEAN | |
| `created_at` | TIMESTAMP | |
---
## 🔌 API Endpoints
### Authentication
- `POST /api/v1/auth/login` - Admin login (returns JWT)
- `POST /api/v1/auth/logout` - Invalidate session
- `GET /api/v1/auth/me` - Get current admin profile & permissions
### Dashboard
- `GET /api/v1/dashboard/metrics` - Aggregate stats (revenue, active partners, etc.)
- `GET /api/v1/dashboard/revenue-chart` - Data for the main revenue graph
- `GET /api/v1/dashboard/activity` - Recent system activity feed
### Partner Management
- `GET /api/v1/partners` - List partners (Supports filtering by status, type, search)
- `POST /api/v1/partners` - Invite/Create new partner
- `GET /api/v1/partners/:id` - Get full partner profile
- `GET /api/v1/partners/:id/documents` - List KYC documents
- `PATCH /api/v1/partners/:id/status` - Suspend/Activate partner
- `POST /api/v1/partners/:id/kyc/review` - Approve/Reject specific documents
### User Management (Command Center)
- **Internal Team**
- `GET /api/v1/admin/users` - List internal staff
- `POST /api/v1/admin/users` - Create staff account
- `PATCH /api/v1/admin/users/:id/role` - Assign role
- `DELETE /api/v1/admin/users/:id` - Revoke access
- **Roles & Permissions**
- `GET /api/v1/admin/roles` - List available roles
- `PUT /api/v1/admin/roles/:id/permissions` - Update permission matrix for a role
- **End Users**
- `GET /api/v1/users` - List B2C users
- `POST /api/v1/users/:id/ban` - Ban a user
- `POST /api/v1/users/:id/reset-2fa` - Reset 2FA
### Financials (Placeholder)
- `GET /api/v1/financials/entries` - List ledger entries
- `POST /api/v1/financials/payouts` - Trigger batch payouts

270
MASTER_API_INVENTORY.md Normal file
View File

@@ -0,0 +1,270 @@
# Eventify Master API Inventory
> **Version:** 1.0 | **Last Updated:** 2026-02-09 | **Status:** Definitive Reference
---
## 1. Auth & Identity
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| AUTH-01 | `/api/v1/auth/login/otp/request` | POST | Inbound | App/Web -> Server | Request OTP for phone login | High |
| AUTH-02 | `/api/v1/auth/login/otp/verify` | POST | Inbound | App/Web -> Server | Verify OTP and issue tokens | High |
| AUTH-03 | `/api/v1/auth/login/password` | POST | Inbound | App/Web -> Server | Email/password login | High |
| AUTH-04 | `/api/v1/auth/register` | POST | Inbound | App/Web -> Server | New user registration | High |
| AUTH-05 | `/api/v1/auth/logout` | POST | Inbound | App/Web -> Server | Invalidate session/tokens | Medium |
| AUTH-06 | `/api/v1/auth/refresh` | POST | Inbound | App/Web -> Server | Refresh access token | High |
| AUTH-07 | `/api/v1/auth/forgot-password` | POST | Inbound | App/Web -> Server | Initiate password reset | Medium |
| AUTH-08 | `/api/v1/auth/reset-password` | POST | Inbound | App/Web -> Server | Complete password reset | Medium |
| AUTH-09 | `/api/v1/auth/mfa/setup` | POST | Inbound | App/Web -> Server | Enable 2FA (TOTP/SMS) | Medium |
| AUTH-10 | `/api/v1/auth/mfa/verify` | POST | Inbound | App/Web -> Server | Verify 2FA code | High |
| AUTH-11 | `/api/v1/auth/oauth/google` | GET | Inbound | App/Web -> Server | Google OAuth redirect | Medium |
| AUTH-12 | `/api/v1/auth/oauth/google/callback` | GET | Inbound | Google -> Server | Google OAuth callback | Medium |
| AUTH-13 | `/api/v1/auth/oauth/apple` | GET | Inbound | App -> Server | Apple Sign-In redirect | Medium |
| AUTH-14 | `/api/v1/auth/oauth/apple/callback` | POST | Inbound | Apple -> Server | Apple Sign-In callback | Medium |
| AUTH-15 | `/api/v1/auth/sessions` | GET | Inbound | App/Web -> Server | List active sessions | Low |
| AUTH-16 | `/api/v1/auth/sessions/:id` | DELETE | Inbound | App/Web -> Server | Revoke specific session | Medium |
| AUTH-17 | `/api/v1/auth/sessions/all` | DELETE | Inbound | App/Web -> Server | Revoke all sessions (except current) | Medium |
| AUTH-18 | `/api/v1/partner/auth/login` | POST | Inbound | Partner -> Server | Partner/Organizer login | High |
| AUTH-19 | `/api/v1/admin/auth/login` | POST | Inbound | Control Center -> Server | Admin login | High |
| AUTH-20 | `/api/v1/admin/auth/impersonate/:userId` | POST | Inbound | Control Center -> Server | Impersonate user session | High |
---
## 2. User Entity (CRUD & Profile)
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| USER-01 | `/api/v1/users/me` | GET | Inbound | App/Web -> Server | Get current user profile | High |
| USER-02 | `/api/v1/users/me` | PATCH | Inbound | App/Web -> Server | Update profile (name, bio, avatar) | Medium |
| USER-03 | `/api/v1/users/me/phone` | PATCH | Inbound | App/Web -> Server | Update phone number | Medium |
| USER-04 | `/api/v1/users/me/email` | PATCH | Inbound | App/Web -> Server | Update email address | Medium |
| USER-05 | `/api/v1/users/me/preferences` | PATCH | Inbound | App/Web -> Server | Update notification/privacy preferences | Low |
| USER-06 | `/api/v1/users/me/avatar` | POST | Inbound | App/Web -> Server | Upload avatar image | Low |
| USER-07 | `/api/v1/users/me/delete` | POST | Inbound | App/Web -> Server | Request account deletion (GDPR) | Medium |
| USER-08 | `/api/v1/users/:id` | GET | Inbound | App/Web -> Server | Get public user profile | Low |
| USER-09 | `/api/v1/users/:id/follow` | POST | Inbound | App -> Server | Follow another user | Low |
| USER-10 | `/api/v1/users/:id/unfollow` | POST | Inbound | App -> Server | Unfollow user | Low |
| USER-11 | `/api/v1/users/me/followers` | GET | Inbound | App -> Server | List followers | Low |
| USER-12 | `/api/v1/users/me/following` | GET | Inbound | App -> Server | List following | Low |
| USER-13 | `/api/v1/admin/users` | GET | Inbound | Control Center -> Server | List all users (paginated, filterable) | High |
| USER-14 | `/api/v1/admin/users/:id` | GET | Inbound | Control Center -> Server | Get user details (admin view) | High |
| USER-15 | `/api/v1/admin/users/:id` | PATCH | Inbound | Control Center -> Server | Update user (admin override) | High |
| USER-16 | `/api/v1/admin/users/:id/suspend` | POST | Inbound | Control Center -> Server | Suspend user account | High |
| USER-17 | `/api/v1/admin/users/:id/ban` | POST | Inbound | Control Center -> Server | Ban user permanently | High |
| USER-18 | `/api/v1/admin/users/:id/reinstate` | POST | Inbound | Control Center -> Server | Reinstate suspended/banned user | High |
| USER-19 | `/api/v1/admin/users/:id/notes` | POST | Inbound | Control Center -> Server | Add internal admin note | Low |
| USER-20 | `/api/v1/admin/users/:id/tags` | PATCH | Inbound | Control Center -> Server | Update user tags | Low |
---
## 3. Events (Discovery, CRUD, Publishing)
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| EVT-01 | `/api/v1/events` | GET | Inbound | App/Web -> Server | List/search events (public) | High |
| EVT-02 | `/api/v1/events/:id` | GET | Inbound | App/Web -> Server | Get event details | High |
| EVT-03 | `/api/v1/events/:id/tickets` | GET | Inbound | App/Web -> Server | Get ticket types for event | High |
| EVT-04 | `/api/v1/events/featured` | GET | Inbound | App/Web -> Server | Get featured/promoted events | Medium |
| EVT-05 | `/api/v1/events/nearby` | GET | Inbound | App -> Server | Get events near user (geo) | Medium |
| EVT-06 | `/api/v1/events/categories` | GET | Inbound | App/Web -> Server | List event categories | Low |
| EVT-07 | `/api/v1/events/:id/similar` | GET | Inbound | App/Web -> Server | Get similar events | Low |
| EVT-08 | `/api/v1/partner/events` | GET | Inbound | Partner -> Server | List organizer's events | High |
| EVT-09 | `/api/v1/partner/events` | POST | Inbound | Partner -> Server | Create new event | High |
| EVT-10 | `/api/v1/partner/events/:id` | GET | Inbound | Partner -> Server | Get event details (owner) | High |
| EVT-11 | `/api/v1/partner/events/:id` | PATCH | Inbound | Partner -> Server | Update event | High |
| EVT-12 | `/api/v1/partner/events/:id` | DELETE | Inbound | Partner -> Server | Delete/cancel event | High |
| EVT-13 | `/api/v1/partner/events/:id/publish` | POST | Inbound | Partner -> Server | Publish event (make live) | High |
| EVT-14 | `/api/v1/partner/events/:id/unpublish` | POST | Inbound | Partner -> Server | Unpublish/draft event | Medium |
| EVT-15 | `/api/v1/partner/events/:id/duplicate` | POST | Inbound | Partner -> Server | Clone event | Low |
| EVT-16 | `/api/v1/partner/events/:id/tickets` | POST | Inbound | Partner -> Server | Create ticket type | High |
| EVT-17 | `/api/v1/partner/events/:id/tickets/:ticketId` | PATCH | Inbound | Partner -> Server | Update ticket type | High |
| EVT-18 | `/api/v1/partner/events/:id/tickets/:ticketId` | DELETE | Inbound | Partner -> Server | Delete ticket type | Medium |
| EVT-19 | `/api/v1/admin/events` | GET | Inbound | Control Center -> Server | List all events (admin) | High |
| EVT-20 | `/api/v1/admin/events/:id` | PATCH | Inbound | Control Center -> Server | Override event details | High |
| EVT-21 | `/api/v1/admin/events/:id/approve` | POST | Inbound | Control Center -> Server | Approve pending event | High |
| EVT-22 | `/api/v1/admin/events/:id/reject` | POST | Inbound | Control Center -> Server | Reject pending event | High |
| EVT-23 | `/api/v1/admin/events/:id/feature` | POST | Inbound | Control Center -> Server | Feature event on homepage | Medium |
---
## 4. Orders & Tickets (Commerce)
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| ORD-01 | `/api/v1/orders` | POST | Inbound | App/Web -> Server | Create order (cart checkout) | High |
| ORD-02 | `/api/v1/orders/:id` | GET | Inbound | App/Web -> Server | Get order details | High |
| ORD-03 | `/api/v1/orders/:id/pay` | POST | Inbound | App/Web -> Server | Initiate payment for order | High |
| ORD-04 | `/api/v1/orders/:id/cancel` | POST | Inbound | App/Web -> Server | Cancel pending order | Medium |
| ORD-05 | `/api/v1/users/me/orders` | GET | Inbound | App/Web -> Server | List user's orders | High |
| ORD-06 | `/api/v1/users/me/tickets` | GET | Inbound | App/Web -> Server | List user's tickets (wallet) | High |
| ORD-07 | `/api/v1/tickets/:id` | GET | Inbound | App/Web -> Server | Get ticket details (for wallet) | High |
| ORD-08 | `/api/v1/tickets/:id/qr` | GET | Inbound | App -> Server | Get QR code data for ticket | High |
| ORD-09 | `/api/v1/tickets/:id/transfer` | POST | Inbound | App -> Server | Transfer ticket to another user | Medium |
| ORD-10 | `/api/v1/tickets/:id/accept-transfer` | POST | Inbound | App -> Server | Accept incoming ticket transfer | Medium |
| ORD-11 | `/api/v1/partner/orders` | GET | Inbound | Partner -> Server | List orders for organizer's events | High |
| ORD-12 | `/api/v1/partner/orders/:id` | GET | Inbound | Partner -> Server | Get order details (organizer) | High |
| ORD-13 | `/api/v1/partner/orders/:id/refund` | POST | Inbound | Partner -> Server | Initiate refund | High |
| ORD-14 | `/api/v1/partner/attendees` | GET | Inbound | Partner -> Server | List attendees for event | High |
| ORD-15 | `/api/v1/admin/orders` | GET | Inbound | Control Center -> Server | List all orders (admin) | High |
| ORD-16 | `/api/v1/admin/orders/:id` | GET | Inbound | Control Center -> Server | Get order details (admin) | High |
| ORD-17 | `/api/v1/admin/orders/:id/refund` | POST | Inbound | Control Center -> Server | Force refund (admin override) | High |
| ORD-18 | `/api/v1/admin/orders/:id/receipt` | GET | Inbound | Control Center -> Server | Generate/download receipt | Medium |
---
## 5. Payments & Payouts
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| PAY-01 | `/api/v1/payments/razorpay/create-order` | POST | Inbound | App/Web -> Server | Create Razorpay order | High |
| PAY-02 | `/api/v1/payments/razorpay/verify` | POST | Inbound | App/Web -> Server | Verify Razorpay payment signature | High |
| PAY-03 | `/api/v1/payments/stripe/create-intent` | POST | Inbound | App/Web -> Server | Create Stripe PaymentIntent | High |
| PAY-04 | `/api/v1/payments/stripe/confirm` | POST | Inbound | App/Web -> Server | Confirm Stripe payment | High |
| PAY-05 | `/api/v1/webhooks/razorpay` | POST | Inbound | Razorpay -> Server | Razorpay webhook events | High |
| PAY-06 | `/api/v1/webhooks/stripe` | POST | Inbound | Stripe -> Server | Stripe webhook events | High |
| PAY-07 | `/api/v1/partner/payouts` | GET | Inbound | Partner -> Server | List payout history | High |
| PAY-08 | `/api/v1/partner/payouts/:id` | GET | Inbound | Partner -> Server | Get payout details | High |
| PAY-09 | `/api/v1/partner/bank-account` | GET | Inbound | Partner -> Server | Get linked bank account | High |
| PAY-10 | `/api/v1/partner/bank-account` | POST | Inbound | Partner -> Server | Link bank account for payouts | High |
| PAY-11 | `/api/v1/partner/bank-account` | PATCH | Inbound | Partner -> Server | Update bank account | High |
| PAY-12 | `/api/v1/admin/payouts` | GET | Inbound | Control Center -> Server | List all payouts (admin) | High |
| PAY-13 | `/api/v1/admin/payouts/:id/approve` | POST | Inbound | Control Center -> Server | Approve payout | High |
| PAY-14 | `/api/v1/admin/payouts/:id/hold` | POST | Inbound | Control Center -> Server | Hold payout | High |
| PAY-15 | `/api/v1/admin/payouts/:id/release` | POST | Inbound | Control Center -> Server | Release held payout | High |
| PAY-16 | `Razorpay Payout API` | POST | Outbound | Server -> Razorpay | Initiate payout to organizer | High |
| PAY-17 | `Stripe Connect Payout` | POST | Outbound | Server -> Stripe | Transfer funds to connected account | High |
---
## 6. Operations (Check-in, Scanning, Staff)
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| OPS-01 | `/api/v1/partner/scan/validate` | POST | Inbound | Partner App -> Server | Validate scanned QR code | High |
| OPS-02 | `/api/v1/partner/scan/checkin` | POST | Inbound | Partner App -> Server | Check-in attendee | High |
| OPS-03 | `/api/v1/partner/scan/checkout` | POST | Inbound | Partner App -> Server | Check-out attendee (optional) | Low |
| OPS-04 | `/api/v1/partner/events/:id/checkin-stats` | GET | Inbound | Partner -> Server | Get live check-in statistics | Medium |
| OPS-05 | `/api/v1/partner/staff` | GET | Inbound | Partner -> Server | List event staff | Medium |
| OPS-06 | `/api/v1/partner/staff` | POST | Inbound | Partner -> Server | Invite staff member | Medium |
| OPS-07 | `/api/v1/partner/staff/:id` | DELETE | Inbound | Partner -> Server | Remove staff member | Medium |
| OPS-08 | `/api/v1/partner/staff/:id/permissions` | PATCH | Inbound | Partner -> Server | Update staff permissions | Medium |
| OPS-09 | `/api/v1/staff/auth/login` | POST | Inbound | Staff App -> Server | Staff member login | High |
| OPS-10 | `/api/v1/staff/events` | GET | Inbound | Staff App -> Server | List assigned events | Medium |
---
## 7. Communication (Email, Push, SMS, Chat)
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| COM-01 | `Resend API - Transactional` | POST | Outbound | Server -> Resend | Send order confirmation email | High |
| COM-02 | `Resend API - Marketing` | POST | Outbound | Server -> Resend | Send marketing/promo email | Low |
| COM-03 | `FCM - Push Notification` | POST | Outbound | Server -> FCM | Send push notification to user | Medium |
| COM-04 | `APNs - Push Notification` | POST | Outbound | Server -> APNs | Send iOS push notification | Medium |
| COM-05 | `Twilio SMS` | POST | Outbound | Server -> Twilio | Send SMS (OTP, alerts) | High |
| COM-06 | `/api/v1/users/me/devices` | POST | Inbound | App -> Server | Register device for push | Medium |
| COM-07 | `/api/v1/users/me/devices/:id` | DELETE | Inbound | App -> Server | Unregister device | Low |
| COM-08 | `/api/v1/webhooks/resend` | POST | Inbound | Resend -> Server | Email delivery status webhook | Medium |
| COM-09 | `/api/v1/webhooks/twilio` | POST | Inbound | Twilio -> Server | SMS delivery status webhook | Medium |
| COM-10 | `/api/v1/admin/notifications/send` | POST | Inbound | Control Center -> Server | Send notification to user | Medium |
| COM-11 | `/api/v1/admin/notifications/broadcast` | POST | Inbound | Control Center -> Server | Broadcast to user segment | Medium |
| COM-12 | `/api/v1/chat/conversations` | GET | Inbound | App -> Server | List user's conversations | Low |
| COM-13 | `/api/v1/chat/conversations/:id/messages` | GET | Inbound | App -> Server | Get messages in conversation | Low |
| COM-14 | `/api/v1/chat/conversations/:id/messages` | POST | Inbound | App -> Server | Send message | Low |
| COM-15 | `WebSocket /ws/chat` | WS | Bidirectional | App <-> Server | Real-time chat connection | Low |
---
## 8. Data & Analytics
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| DAT-01 | `/api/v1/partner/analytics/overview` | GET | Inbound | Partner -> Server | Get sales overview dashboard | Medium |
| DAT-02 | `/api/v1/partner/analytics/sales` | GET | Inbound | Partner -> Server | Get detailed sales data | Medium |
| DAT-03 | `/api/v1/partner/analytics/attendees` | GET | Inbound | Partner -> Server | Get attendee demographics | Low |
| DAT-04 | `/api/v1/partner/analytics/traffic` | GET | Inbound | Partner -> Server | Get page view/traffic data | Low |
| DAT-05 | `/api/v1/partner/export/attendees` | GET | Outbound | Server -> Browser | Export attendee list (CSV) | Medium |
| DAT-06 | `/api/v1/partner/export/orders` | GET | Outbound | Server -> Browser | Export orders (CSV) | Medium |
| DAT-07 | `/api/v1/admin/analytics/overview` | GET | Inbound | Control Center -> Server | Platform-wide analytics | Medium |
| DAT-08 | `/api/v1/admin/analytics/revenue` | GET | Inbound | Control Center -> Server | Revenue breakdown | High |
| DAT-09 | `/api/v1/admin/analytics/users` | GET | Inbound | Control Center -> Server | User growth metrics | Medium |
| DAT-10 | `/api/v1/admin/export/users` | GET | Outbound | Server -> Browser | Export users (CSV) | Medium |
| DAT-11 | `/api/v1/admin/export/orders` | GET | Outbound | Server -> Browser | Export orders (CSV) | Medium |
| DAT-12 | `/api/v1/admin/export/payouts` | GET | Outbound | Server -> Browser | Export payouts (CSV) | Medium |
| DAT-13 | `/api/v1/admin/audit-logs` | GET | Inbound | Control Center -> Server | Get admin activity logs | High |
---
## 9. Third-Party Integrations
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| INT-01 | `AWS S3 - Presigned URL` | POST | Outbound | Server -> S3 | Generate upload URL | Medium |
| INT-02 | `/api/v1/uploads/presigned` | GET | Inbound | App/Web -> Server | Request presigned URL for upload | Medium |
| INT-03 | `Google Maps Geocoding API` | GET | Outbound | Server -> Google | Geocode event address | Low |
| INT-04 | `Google Maps Places API` | GET | Outbound | Server -> Google | Autocomplete venue search | Low |
| INT-05 | `/api/v1/geo/ip` | GET | Inbound | App/Web -> Server | Get location from IP | Low |
| INT-06 | `MaxMind GeoIP` | GET | Internal | Server -> MaxMind DB | IP to location lookup | Low |
| INT-07 | `/api/v1/calendar/google/export` | GET | Inbound | App/Web -> Server | Generate Google Calendar link | Low |
| INT-08 | `/api/v1/calendar/ical/export` | GET | Inbound | App/Web -> Server | Generate iCal file | Low |
| INT-09 | `Sentry - Error Reporting` | POST | Outbound | Server -> Sentry | Log errors | Medium |
| INT-10 | `Slack Webhook` | POST | Outbound | Server -> Slack | Alert on critical events | Medium |
| INT-11 | `PagerDuty API` | POST | Outbound | Server -> PagerDuty | Trigger on-call alerts | High |
---
## 10. Support & Moderation
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| SUP-01 | `/api/v1/admin/tickets` | GET | Inbound | Control Center -> Server | List support tickets | Medium |
| SUP-02 | `/api/v1/admin/tickets/:id` | GET | Inbound | Control Center -> Server | Get ticket details | Medium |
| SUP-03 | `/api/v1/admin/tickets` | POST | Inbound | Control Center -> Server | Create escalation ticket | Medium |
| SUP-04 | `/api/v1/admin/tickets/:id/reply` | POST | Inbound | Control Center -> Server | Reply to ticket | Medium |
| SUP-05 | `/api/v1/admin/tickets/:id/close` | POST | Inbound | Control Center -> Server | Close ticket | Medium |
| SUP-06 | `/api/v1/admin/tickets/:id/assign` | POST | Inbound | Control Center -> Server | Assign ticket to agent | Medium |
| SUP-07 | `/api/v1/users/me/tickets` | GET | Inbound | App/Web -> Server | List user's support tickets | Low |
| SUP-08 | `/api/v1/users/me/tickets` | POST | Inbound | App/Web -> Server | Create support ticket | Low |
| SUP-09 | `/api/v1/admin/moderation/reports` | GET | Inbound | Control Center -> Server | List content reports | Medium |
| SUP-10 | `/api/v1/admin/moderation/reports/:id/action` | POST | Inbound | Control Center -> Server | Take action on report | Medium |
---
## 11. System & Utilities
| ID | Endpoint / Action Name | Method | Direction | Source -> Target | Purpose | Criticality |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| SYS-01 | `/api/v1/health` | GET | Inbound | Any -> Server | Health check | High |
| SYS-02 | `/api/v1/health/db` | GET | Inbound | Internal -> Server | Database connectivity check | High |
| SYS-03 | `/api/v1/health/redis` | GET | Inbound | Internal -> Server | Redis connectivity check | High |
| SYS-04 | `/api/v1/config/mobile` | GET | Inbound | App -> Server | Get mobile app config (feature flags) | Medium |
| SYS-05 | `/api/v1/config/web` | GET | Inbound | Web -> Server | Get web config (feature flags) | Medium |
| SYS-06 | `/api/v1/version` | GET | Inbound | Any -> Server | Get API version info | Low |
| SYS-07 | `Cron: Payout Processor` | CRON | Internal | Scheduler -> Server | Process pending payouts | High |
| SYS-08 | `Cron: Reminder Emails` | CRON | Internal | Scheduler -> Server | Send event reminder emails | Medium |
| SYS-09 | `Cron: Expired Orders Cleanup` | CRON | Internal | Scheduler -> Server | Cancel expired pending orders | Medium |
| SYS-10 | `Cron: Analytics Aggregation` | CRON | Internal | Scheduler -> Server | Pre-compute analytics | Low |
---
## Summary Statistics
| Category | Total Endpoints |
| :--- | :---: |
| Auth & Identity | 20 |
| User Entity | 20 |
| Events | 23 |
| Orders & Tickets | 18 |
| Payments & Payouts | 17 |
| Operations | 10 |
| Communication | 15 |
| Data & Analytics | 13 |
| Third-Party Integrations | 11 |
| Support & Moderation | 10 |
| System & Utilities | 10 |
| **TOTAL** | **167** |
---
> **Note:** This inventory represents the definitive API surface for the Eventify ecosystem. All development should reference this document for endpoint naming, method conventions, and integration points.

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# Eventify Command Center 🚀
![Status](https://img.shields.io/badge/Status-Active-success)
![Version](https://img.shields.io/badge/Version-1.0.0-blue)
![Tech](https://img.shields.io/badge/Built%20With-Vite%20%7C%20React%20%7C%20Shadcn-purple)
The **Eventify Command Center** is the central administration dashboard for the Eventify platform. It provides sophisticated tools for User Management, Event Analytics, Support CRM, and Platform Moderation. Designed with a premium **Neumorphic** aesthetic, it serves as the cockpit for platform administrators.
---
## 📸 Overview
The Command Center is built to be an "Operating System for Events", offering high-density information displays and quick actions.
### Key Modules
- **User Management (CRM)**: comprehensive 360° view of users, bookings, and LTV.
- **Moderation Tools**: Suspensions, Bans, and "Refund Risk" analysis.
- **Notification System**: Push notifications and email communication to users.
- **Analytics Dashboard**: Real-time sales and engagement metrics.
---
## 🏗 Architecture
The application follows a **Feature-Based Architecture** ensuring scalability and maintainability.
```mermaid
graph TD
Client[Client UI (React/Vite)] -->|User Actions| Actions[Action Handlers]
Actions -->|Validate| Zod[Zod Schemas]
Actions -->|Execute| Service[Mock Backend Services]
Service -->|Log| Audit[Audit Logger]
subgraph UI Layer
Client
Components[Shadcn UI Components]
end
subgraph Logic Layer
Actions
Hooks[Custom Hooks / Nuqs]
end
subgraph Data Layer
Service
Types[TypeScript Interfaces]
end
```
### Tech Stack
- **Framework**: [Vite](https://vitejs.dev/) + React 18
- **Language**: TypeScript
- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/)
- **UI Components**: [Shadcn UI](https://ui.shadcn.com/) + Radix Primitives
- **State Management**: URL-based state with `nuqs`
- **Icons**: `lucide-react`
- **Validation**: `zod`
---
## 🚀 Getting Started
### Prerequisites
- Node.js 18+
- npm 9+
### Installation
1. **Clone the repository**
```bash
git clone https://code.bshtech.net/Sicherhaven/eventify-command-center.git
cd eventify-command-center
```
2. **Install dependencies**
```bash
npm install
```
3. **Run Development Server**
```bash
npm run dev
```
Access the app at `http://localhost:8080` (or the port shown in terminal).
---
## 📂 Project Structure
```text
src/
├── features/ # Feature-based modules
│ └── users/ # User Management specific code
│ ├── components/ # UI Components (Inspector, Table, etc.)
│ └── data/ # Mock data and services
├── components/ # Shared global components (ui/ folders)
├── lib/ # Core utilities
│ ├── actions/ # Server-style actions (Action handlers)
│ ├── types/ # TypeScript definitions
│ └── utils.ts # Helper functions
├── pages/ # Main route pages
└── styles/ # Global CSS and Tailwind config
```
---
## 🛠 Deployment
The application is deployed on the **Sicherh** infrastructure.
- **Primary URL**: [https://admin.prototype.eventifyplus.com](https://admin.prototype.eventifyplus.com)
- **Server**: `sicherh` (Managed via SSH)
- **Process Manager**: PM2
### Deployment Command
To deploy the latest changes from `main`:
```bash
# SSH into the server
ssh sicherh
# Navigate and Pull
cd eventify-command-center
git pull
# Build and Restart
npm install
npm run build
pm2 restart next-server
```
---
## 🛡 Security & Permissions
- **Authentication**: Stubbed for prototype (Admin verification mocks).
- **Audit Logs**: All administrative actions (Ban, Suspend, Impersonate) are logged to the console/server logs via `lib/audit-logger.ts`.
- **RBAC**: Role-base access control logic is implemented in the `verifyAdmin` helper.
---
> Built with ❤️ by **BSH Technologies** for the Eventify Platform.

BIN
bun.lockb Executable file

Binary file not shown.

20
components.json Normal file
View File

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

26
eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
);

28
index.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<!-- TODO: Set the document title to the name of your application -->
<title>Eventify Command Center</title>
<meta name="description" content="Eventify Command Center Admin Panel" />
<meta name="author" content="Eventify" />
<meta property="og:title" content="Eventify Command Center" />
<meta property="og:description" content="Eventify Command Center Admin Panel" />
<meta property="og:type" content="website" />
<meta property="og:image" content="/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Eventify" />
<meta name="twitter:image" content="/og-image.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
nginx_admin_config Normal file
View File

@@ -0,0 +1,27 @@
server {
server_name admin.prototype.eventifyplus.com;
root /var/www/admin.prototype.eventifyplus.com;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/admin.prototype.eventifyplus.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/admin.prototype.eventifyplus.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = admin.prototype.eventifyplus.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name admin.prototype.eventifyplus.com;
return 404; # managed by Certbot
}

8347
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

92
package.json Normal file
View File

@@ -0,0 +1,92 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.31.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"nuqs": "^2.8.8",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19",
"vitest": "^3.2.4"
}
}

6
postcss.config.js Normal file
View File

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

217
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,217 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ===== ENUMS =====
enum CampaignStatus {
DRAFT
IN_REVIEW
ACTIVE
PAUSED
ENDED
REJECTED
}
enum BillingModel {
FIXED
CPM
CPC
}
enum CampaignObjective {
AWARENESS
SALES
}
enum SurfaceKey {
HOME_FEATURED_CAROUSEL
HOME_TOP_EVENTS
CATEGORY_FEATURED
CITY_TRENDING
SEARCH_BOOSTED
}
enum TrackingEventType {
IMPRESSION
CLICK
}
// ===== MODELS =====
model Campaign {
id String @id @default(uuid())
partnerId String
name String
objective CampaignObjective
status CampaignStatus @default(DRAFT)
startAt DateTime
endAt DateTime
billingModel BillingModel
totalBudget Decimal @db.Decimal(10, 2)
dailyCap Decimal? @db.Decimal(10, 2)
spent Decimal @default(0) @db.Decimal(10, 2)
// Targeting (stored as JSON)
targeting Json // { cityIds: [], categoryIds: [], countryCodes: [] }
frequencyCap Int @default(0) // 0 = unlimited
// Relations
placements SponsoredPlacement[]
events CampaignEvent[] // Many-to-many via join table or array of IDs
approvedBy String?
rejectedReason String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs CampaignAuditLog[]
}
model SponsoredPlacement {
id String @id @default(uuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
eventId String
surfaceKey SurfaceKey
priority String @default("SPONSORED")
bid Decimal @db.Decimal(10, 2)
status String @default("ACTIVE") // ACTIVE, PAUSED
rank Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([campaignId, eventId, surfaceKey])
}
model CampaignEvent {
id String @id @default(uuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
eventId String // Reference to Event table (not shown here)
@@unique([campaignId, eventId])
}
model AdTrackingEvent {
id String @id @default(uuid())
type TrackingEventType
placementId String
campaignId String
surfaceKey SurfaceKey
eventId String
userId String?
anonId String
sessionId String
timestamp DateTime @default(now())
device String?
cityId String?
@@index([campaignId, type, timestamp])
@@index([anonId, timestamp]) // For frequency capping queries
}
model PlacementDailyStats {
id String @id @default(uuid())
campaignId String
placementId String
surfaceKey SurfaceKey
date DateTime @db.Date
impressions Int @default(0)
clicks Int @default(0)
spend Decimal @default(0) @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([campaignId, placementId, surfaceKey, date])
}
model CampaignAuditLog {
id String @id @default(uuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
actorId String
action String // CREATED, UPDATED, SUBMITTED, APPROVED, REJECTED, PAUSED, RESUMED
details Json? // Changed fields, reason, etc.
createdAt DateTime @default(now())
}
// ===== PARTNER GOVERNANCE =====
enum KYCStatus {
PENDING
VERIFIED
REJECTED
}
enum KYCDocStatus {
PENDING
APPROVED
REJECTED
}
enum PartnerEventStatus {
PENDING_REVIEW
LIVE
DRAFT
COMPLETED
CANCELLED
REJECTED
}
model PartnerProfile {
id String @id @default(cuid())
userId String @unique // FK to User table
verification KYCStatus @default(PENDING)
riskScore Int @default(0)
documents PartnerDoc[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PartnerDoc {
id String @id @default(cuid())
partnerId String
partner PartnerProfile @relation(fields: [partnerId], references: [id])
type String // "PAN", "GST", "AADHAAR", "CANCELLED_CHEQUE", "BUSINESS_REG"
name String
url String
status KYCDocStatus @default(PENDING)
mandatory Boolean @default(true)
adminNote String?
reviewedBy String?
reviewedAt DateTime?
uploadedBy String
uploadedAt DateTime @default(now())
@@index([partnerId, status])
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

1
public/placeholder.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

14
public/robots.txt Normal file
View File

@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

42
src/App.css Normal file
View File

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

141
src/App.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider } from "@/contexts/AuthContext";
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
import { PageLoader } from "@/components/ui/PageLoader"; // Added import for PageLoader
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import PartnerDirectory from "./features/partners/PartnerDirectory";
import PartnerProfile from "./features/partners/PartnerProfile";
import Events from "./pages/Events";
import Users from "./pages/Users";
import AdControl from "./pages/AdControl";
import SponsoredAds from "./pages/SponsoredAds";
import NewCampaign from "./pages/NewCampaign";
import CampaignReport from "./pages/CampaignReport";
import Financials from "./pages/Financials";
import Settings from "./pages/Settings";
import Reviews from "./pages/Reviews";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<AuthProvider>
<PageLoader /> {/* Rendered PageLoader here */}
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/partners"
element={
<ProtectedRoute>
<PartnerDirectory />
</ProtectedRoute>
}
/>
<Route
path="/partners/:id"
element={
<ProtectedRoute>
<PartnerProfile />
</ProtectedRoute>
}
/>
<Route
path="/events"
element={
<ProtectedRoute>
<Events />
</ProtectedRoute>
}
/>
<Route
path="/users"
element={
<ProtectedRoute>
<Users />
</ProtectedRoute>
}
/>
<Route
path="/ad-control"
element={
<ProtectedRoute>
<AdControl />
</ProtectedRoute>
}
/>
<Route
path="/financials"
element={
<ProtectedRoute>
<Financials />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/reviews"
element={
<ProtectedRoute>
<Reviews />
</ProtectedRoute>
}
/>
<Route
path="/ad-control/sponsored"
element={
<ProtectedRoute>
<SponsoredAds />
</ProtectedRoute>
}
/>
<Route
path="/ad-control/sponsored/new"
element={
<ProtectedRoute>
<NewCampaign />
</ProtectedRoute>
}
/>
<Route
path="/ad-control/sponsored/:id/report"
element={
<ProtectedRoute>
<CampaignReport />
</ProtectedRoute>
}
/>
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
);
export default App;

View File

@@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };

View File

@@ -0,0 +1,27 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<div className="h-12 w-12 border-4 border-accent border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,86 @@
import { Link } from 'react-router-dom';
import {
UserCheck,
Flag,
Wallet,
AlertTriangle,
ArrowRight
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { ActionItem } from '@/types/dashboard';
import { formatCurrency } from '@/data/mockData';
interface ActionItemsPanelProps {
items: ActionItem[];
}
const iconMap = {
kyc: UserCheck,
flagged: Flag,
payout: Wallet,
stripe: AlertTriangle,
};
const colorMap = {
kyc: 'text-warning bg-warning/10',
flagged: 'text-error bg-error/10',
payout: 'text-success bg-success/10',
stripe: 'text-warning bg-warning/10',
};
export function ActionItemsPanel({ items }: ActionItemsPanelProps) {
return (
<div className="neu-card p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-bold text-foreground">Pending Actions</h2>
<p className="text-sm text-muted-foreground">Items requiring your attention</p>
</div>
<span className="h-8 px-3 flex items-center justify-center rounded-full bg-error/10 text-error text-sm font-semibold">
{items.length} urgent
</span>
</div>
<div className="space-y-3">
{items.map((item) => {
const Icon = iconMap[item.type];
const colorClass = colorMap[item.type];
return (
<Link
key={item.id}
to={item.href}
className={cn(
"flex items-center gap-4 p-4 rounded-xl",
"bg-secondary/50 hover:bg-secondary",
"transition-all duration-200 group"
)}
>
<div className={cn(
"h-12 w-12 rounded-xl flex items-center justify-center",
colorClass
)}>
<Icon className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex h-6 min-w-6 px-2 items-center justify-center rounded-md text-xs font-bold",
item.priority === 'high' ? "bg-error text-error-foreground" : "bg-warning text-warning-foreground"
)}>
{item.type === 'payout' ? formatCurrency(item.count) : item.count}
</span>
<h3 className="font-semibold text-foreground">{item.title}</h3>
</div>
<p className="text-sm text-muted-foreground truncate">{item.description}</p>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground opacity-0 group-hover:opacity-100 group-hover:translate-x-1 transition-all duration-200" />
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import {
Users,
Calendar,
Wallet,
User,
CheckCircle2,
Clock,
AlertTriangle,
XCircle
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { ActivityItem } from '@/types/dashboard';
import { formatRelativeTime } from '@/data/mockData';
interface ActivityFeedProps {
items: ActivityItem[];
}
const typeIconMap = {
partner: Users,
event: Calendar,
payout: Wallet,
user: User,
};
const statusIconMap = {
success: CheckCircle2,
pending: Clock,
warning: AlertTriangle,
error: XCircle,
};
const statusColorMap = {
success: 'text-success',
pending: 'text-warning',
warning: 'text-warning',
error: 'text-error',
};
export function ActivityFeed({ items }: ActivityFeedProps) {
return (
<div className="neu-card p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-bold text-foreground">Recent Activity</h2>
<p className="text-sm text-muted-foreground">Latest platform updates</p>
</div>
</div>
<div className="space-y-4">
{items.map((item, index) => {
const TypeIcon = typeIconMap[item.type];
const StatusIcon = statusIconMap[item.status];
const statusColor = statusColorMap[item.status];
return (
<div
key={item.id}
className={cn(
"flex items-start gap-4 pb-4",
index !== items.length - 1 && "border-b border-border/50"
)}
>
<div className="h-10 w-10 rounded-xl bg-secondary shadow-neu-inset-sm flex items-center justify-center">
<TypeIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-foreground">{item.title}</h3>
<StatusIcon className={cn("h-4 w-4", statusColor)} />
</div>
<p className="text-sm text-muted-foreground truncate">{item.description}</p>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelativeTime(item.timestamp)}
</span>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { LucideIcon, TrendingUp, TrendingDown } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DashboardMetricCardProps {
title: string;
value: string;
subtitle?: string;
icon: LucideIcon;
trend?: {
value: number;
label: string;
positive?: boolean;
};
iconColor?: string;
}
export function DashboardMetricCard({
title,
value,
subtitle,
icon: Icon,
trend,
iconColor = "text-accent"
}: DashboardMetricCardProps) {
return (
<div className="neu-card neu-card-hover p-6 group">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-muted-foreground mb-1">{title}</p>
<p className="text-3xl font-bold text-foreground mb-2">{value}</p>
{trend && (
<div className="flex items-center gap-1.5">
{trend.positive !== false ? (
<TrendingUp className="h-4 w-4 text-success" />
) : (
<TrendingDown className="h-4 w-4 text-error" />
)}
<span className={cn(
"text-sm font-medium",
trend.positive !== false ? "text-success" : "text-error"
)}>
{trend.positive !== false ? '+' : ''}{trend.value}%
</span>
<span className="text-sm text-muted-foreground">{trend.label}</span>
</div>
)}
{subtitle && !trend && (
<p className="text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
<div className={cn(
"h-14 w-14 rounded-xl flex items-center justify-center",
"bg-secondary shadow-neu-inset-sm",
"group-hover:shadow-neu-inset transition-shadow duration-200"
)}>
<Icon className={cn("h-7 w-7", iconColor)} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { RevenueDataPoint } from '@/types/dashboard';
import { formatCurrency } from '@/data/mockData';
interface RevenueChartProps {
data: RevenueDataPoint[];
}
export function RevenueChart({ data }: RevenueChartProps) {
const maxValue = Math.max(...data.map(d => Math.max(d.revenue, d.payouts)));
return (
<div className="neu-card p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-bold text-foreground">Revenue vs Payouts</h2>
<p className="text-sm text-muted-foreground">Last 7 days comparison</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-sm bg-accent" />
<span className="text-sm text-muted-foreground">Revenue</span>
</div>
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-sm bg-success" />
<span className="text-sm text-muted-foreground">Payouts</span>
</div>
</div>
</div>
{/* Chart Area */}
<div className="h-64 flex items-end gap-4 pt-8 pb-4">
{data.map((point, index) => (
<div key={index} className="flex-1 flex flex-col items-center gap-2">
<div className="w-full flex items-end justify-center gap-1 h-48">
{/* Revenue Bar */}
<div
className="w-5 bg-accent rounded-t-md transition-all duration-300 hover:bg-ocean-blue"
style={{
height: `${(point.revenue / maxValue) * 100}%`,
minHeight: '8px'
}}
title={formatCurrency(point.revenue)}
/>
{/* Payout Bar */}
<div
className="w-5 bg-success rounded-t-md transition-all duration-300 hover:opacity-80"
style={{
height: `${(point.payouts / maxValue) * 100}%`,
minHeight: '8px'
}}
title={formatCurrency(point.payouts)}
/>
</div>
<span className="text-xs font-medium text-muted-foreground">{point.day}</span>
</div>
))}
</div>
{/* Summary */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/50">
<div className="text-center">
<p className="text-2xl font-bold text-foreground">
{formatCurrency(data.reduce((sum, d) => sum + d.revenue, 0))}
</p>
<p className="text-sm text-muted-foreground">Total Revenue</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-foreground">
{formatCurrency(data.reduce((sum, d) => sum + d.payouts, 0))}
</p>
<p className="text-sm text-muted-foreground">Total Payouts</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
interface AppLayoutProps {
children: ReactNode;
title: string;
description?: string;
}
export function AppLayout({ children, title, description }: AppLayoutProps) {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="pl-[264px]">
<TopBar title={title} description={description} />
<main className="p-8">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Users,
Calendar,
User,
DollarSign,
Settings,
Ticket,
Megaphone,
Zap,
MessageSquareText
} from 'lucide-react';
import { cn } from '@/lib/utils';
const navItems = [
{ title: 'Dashboard', href: '/', icon: LayoutDashboard },
{ title: 'Partner Management', href: '/partners', icon: Users },
{ title: 'Events', href: '/events', icon: Calendar },
{ title: 'Ad Control', href: '/ad-control', icon: Megaphone },
{ title: 'Sponsored Ads', href: '/ad-control/sponsored', icon: Zap },
{ title: 'Users', href: '/users', icon: User },
{ title: 'Review Management', href: '/reviews', icon: MessageSquareText, badge: '12' },
{ title: 'Financials', href: '/financials', icon: DollarSign },
{ title: 'Settings', href: '/settings', icon: Settings },
] as const;
export function Sidebar() {
const location = useLocation();
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-[264px] bg-card shadow-neu">
{/* Logo Section */}
<div className="flex h-20 items-center gap-3 px-6 border-b border-border/50">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary shadow-neu-inset-sm">
<Ticket className="h-5 w-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-bold text-foreground">Eventify</h1>
<span className="text-xs font-medium tracking-widest text-muted-foreground">BACKOFFICE</span>
</div>
</div>
{/* Navigation */}
<nav className="flex flex-col gap-2 p-4">
{navItems.map((item) => {
const isActive = location.pathname === item.href ||
(item.href !== '/' && location.pathname.startsWith(item.href));
return (
<NavLink
key={item.href}
to={item.href}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200",
isActive
? "neu-button-active"
: "neu-button hover:shadow-neu-lg"
)}
>
<item.icon className={cn(
"h-5 w-5 transition-colors",
isActive ? "text-primary-foreground" : "text-muted-foreground"
)} />
<span className={cn(
"font-medium transition-colors flex-1",
isActive ? "text-primary-foreground" : "text-foreground"
)}>
{item.title}
</span>
{'badge' in item && item.badge && (
<span className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-bold font-mono",
isActive
? "bg-white/20 text-primary-foreground"
: "bg-amber-500 text-white"
)}>
{item.badge}
</span>
)}
</NavLink>
);
})}
</nav>
{/* Bottom Section */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-border/50">
<div className="neu-card p-4">
<p className="text-xs text-muted-foreground mb-2">Platform Status</p>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-success animate-pulse-soft" />
<span className="text-sm font-medium text-foreground">All Systems Operational</span>
</div>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,93 @@
import { Search, ChevronDown, LogOut } from 'lucide-react';
import { NotificationPopover } from '@/components/notifications/NotificationPopover';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface TopBarProps {
title: string;
description?: string;
}
export function TopBar({ title, description }: TopBarProps) {
const { user, logout } = useAuth();
const getInitials = () => {
if (user?.first_name && user?.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return user?.username?.substring(0, 2).toUpperCase() || 'AD';
};
const getDisplayName = () => {
if (user?.first_name && user?.last_name) {
return `${user.first_name} ${user.last_name}`;
}
return user?.username || 'Admin User';
};
return (
<header className="sticky top-0 z-30 flex h-20 items-center justify-between bg-background/80 backdrop-blur-sm px-8 border-b border-border/30">
{/* Page Title */}
<div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{/* Right Section */}
<div className="flex items-center gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search partners, events..."
className={cn(
"h-10 w-64 pl-11 pr-4 rounded-xl text-sm",
"bg-secondary text-foreground placeholder:text-muted-foreground",
"shadow-neu-inset focus:outline-none focus:ring-2 focus:ring-accent/50",
"transition-all duration-200"
)}
/>
</div>
{/* Notifications */}
<NotificationPopover />
{/* Profile Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 pl-4 pr-3 py-2 rounded-xl neu-button">
<div className="h-8 w-8 rounded-lg bg-primary shadow-neu-inset-sm flex items-center justify-center">
<span className="text-sm font-bold text-primary-foreground">{getInitials()}</span>
</div>
<div className="text-left">
<p className="text-sm font-medium text-foreground">{getDisplayName()}</p>
<p className="text-xs text-muted-foreground">Admin</p>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()} className="text-error cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@@ -0,0 +1,273 @@
import { useState } from 'react';
import {
Bell,
Check,
CheckCircle2,
AlertTriangle,
Banknote,
Info,
X,
CreditCard
} from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
// --- Types ---
export type NotificationType = 'critical' | 'info' | 'success' | 'warning';
export type NotificationCategory = 'finance' | 'security' | 'system';
export interface Notification {
id: string;
type: NotificationType;
category: NotificationCategory;
title: string;
message: string;
time: string;
actionLabel?: string;
isRead: boolean;
}
// --- Mock Data ---
const initialNotifications: Notification[] = [
{
id: '1',
type: 'critical',
category: 'security',
title: 'Suspicious Login Detected',
message: 'New login from IP 192.168.1.1 (Hamburg, DE).',
time: '2m ago',
actionLabel: 'Block',
isRead: false,
},
{
id: '2',
type: 'info',
category: 'finance',
title: 'Payment Received',
message: 'Partner "Neon Arena" settled invoice #KB-902.',
time: '1h ago',
actionLabel: 'View',
isRead: false,
},
{
id: '3',
type: 'warning',
category: 'system',
title: 'High Server Load',
message: 'CPU usage is continuously above 85%.',
time: '3h ago',
isRead: true,
},
{
id: '4',
type: 'success',
category: 'finance',
title: 'Payout Processed',
message: 'Monthly payouts for 12 partners completed.',
time: '5h ago',
isRead: true,
},
{
id: '5',
type: 'info',
category: 'system',
title: 'System Update',
message: 'Eventify Core v2.4.0 is now live.',
time: '1d ago',
isRead: true,
},
];
export function NotificationPopover() {
const [notifications, setNotifications] = useState<Notification[]>(initialNotifications);
const [isOpen, setIsOpen] = useState(false);
const unreadCount = notifications.filter(n => !n.isRead && n.type === 'critical').length;
const allUnreadCount = notifications.filter(n => !n.isRead).length;
const markAsRead = (id: string) => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
};
const markAllAsRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
};
const deleteNotification = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setNotifications(prev => prev.filter(n => n.id !== id));
};
const getIcon = (type: NotificationType, category: NotificationCategory) => {
if (category === 'finance') return <Banknote className="h-4 w-4" />;
if (type === 'critical') return <AlertTriangle className="h-4 w-4" />;
if (type === 'success') return <CheckCircle2 className="h-4 w-4" />;
return <Info className="h-4 w-4" />;
};
const getStyles = (n: Notification) => {
if (n.type === 'critical') return "bg-red-500/10 border-l-4 border-red-500 hover:bg-red-500/15";
if (n.category === 'finance') return "bg-blue-500/10 border-l-4 border-blue-500 hover:bg-blue-500/15";
if (n.type === 'success') return "bg-green-500/10 border-l-4 border-green-500 hover:bg-green-500/15";
return "bg-secondary/30 border-l-4 border-secondary hover:bg-secondary/50";
};
const NotificationList = ({ items }: { items: Notification[] }) => {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-[300px] text-center p-4">
<div className="h-16 w-16 bg-secondary/50 rounded-full flex items-center justify-center mb-4">
<Check className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-semibold text-foreground">All caught up!</h3>
<p className="text-sm text-muted-foreground">No new alerts to review at this time.</p>
</div>
);
}
return (
<div className="flex flex-col gap-2 p-1">
{items.map((n) => (
<div
key={n.id}
className={cn(
"relative p-3 rounded-md transition-all cursor-pointer group",
getStyles(n),
n.isRead && "opacity-60 bg-transparent border-border hover:bg-secondary/10"
)}
onClick={() => markAsRead(n.id)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center shrink-0",
n.type === 'critical' ? "text-red-500 bg-red-500/20" :
n.category === 'finance' ? "text-blue-500 bg-blue-500/20" :
"text-muted-foreground bg-secondary"
)}>
{getIcon(n.type, n.category)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<p className={cn("text-sm font-semibold", !n.isRead && "text-foreground")}>
{n.title}
</p>
<span className="text-[10px] text-muted-foreground">{n.time}</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{n.message}
</p>
{n.actionLabel && (
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] mt-2 px-2 border-primary/20 hover:bg-primary/5 hover:text-primary"
onClick={(e) => {
e.stopPropagation();
markAsRead(n.id);
}}
>
{n.actionLabel}
</Button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity absolute top-2 right-2 md:static md:opacity-100"
onClick={(e) => deleteNotification(n.id, e)}
>
<X className="h-3 w-3 text-muted-foreground" />
</Button>
</div>
{!n.isRead && (
<span className="absolute top-3 right-3 h-2 w-2 rounded-full bg-primary animate-pulse md:hidden" />
)}
</div>
))}
</div>
);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button className="relative h-10 w-10 flex items-center justify-center rounded-xl neu-button outline-none">
<Bell className="h-5 w-5 text-muted-foreground" />
{unreadCount > 0 && (
<span className="absolute top-2 right-2 h-2.5 w-2.5 rounded-full bg-red-500 border-2 border-background flex items-center justify-center">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-[380px] p-0 mr-4" align="end">
<Tabs defaultValue="all" className="w-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
<h4 className="font-semibold">Notifications</h4>
{allUnreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-0.5 text-xs text-primary hover:text-primary/80"
onClick={markAllAsRead}
>
Mark all as read
</Button>
)}
</div>
<TabsList className="w-full justify-start rounded-none border-b border-border/50 bg-transparent p-0 h-auto">
<TabsTrigger
value="all"
className="rounded-none border-b-2 border-transparent px-4 py-2 hover:bg-secondary/20 data-[state=active]:border-primary data-[state=active]:bg-secondary/10"
>
All
</TabsTrigger>
<TabsTrigger
value="alerts"
className="rounded-none border-b-2 border-transparent px-4 py-2 hover:bg-secondary/20 data-[state=active]:border-red-500 data-[state=active]:text-red-500 data-[state=active]:bg-red-500/5"
>
Alerts
{unreadCount > 0 && (
<Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] bg-red-500 text-white hover:bg-red-500">
{unreadCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger
value="finance"
className="rounded-none border-b-2 border-transparent px-4 py-2 hover:bg-secondary/20 data-[state=active]:border-blue-500 data-[state=active]:text-blue-500 data-[state=active]:bg-blue-500/5"
>
Finance
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[400px]">
<TabsContent value="all" className="m-0 p-2">
<NotificationList items={notifications} />
</TabsContent>
<TabsContent value="alerts" className="m-0 p-2">
<NotificationList items={notifications.filter(n => n.type === 'critical')} />
</TabsContent>
<TabsContent value="finance" className="m-0 p-2">
<NotificationList items={notifications.filter(n => n.category === 'finance')} />
</TabsContent>
</ScrollArea>
</Tabs>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { animate, transform } from "framer-motion";
export function PageLoader() {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const textFill = document.querySelector(".text-fill") as HTMLElement;
if (textFill) {
let progress = 0;
const mapProgressToClipPath = transform(
[0, 1],
["inset(0 100% 0 0)", "inset(0 0% 0 0)"]
);
const interval = setInterval(() => {
progress = progress + Math.random() * 0.2;
if (progress > 1) progress = 1;
animate(
textFill,
{ clipPath: mapProgressToClipPath(progress) },
{ type: "spring", stiffness: 100, damping: 10 }
);
if (progress >= 1) {
clearInterval(interval);
setTimeout(() => setIsLoaded(true), 500); // Wait a bit before hiding
}
}, 500);
return () => clearInterval(interval);
}
}, []);
if (isLoaded) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background pointer-events-none">
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@700;800&display=swap');
.loader-text {
font-family: "Outfit", sans-serif;
font-weight: 800;
text-transform: uppercase;
font-size: 64px;
letter-spacing: -0.02em;
position: relative;
color: #4361ee; /* Royal Blue */
}
.text-bg {
position: absolute;
top: 0;
left: 0;
opacity: 0.2;
}
.text-fill {
position: relative;
clip-path: inset(0 100% 0 0);
will-change: clip-path;
z-index: 1;
color: #4361ee; /* Royal Blue */
}
`}</style>
<div className="relative font-bold text-4xl sm:text-6xl">
<div className="loader-text text-bg" aria-hidden="true">EVENTIFY</div>
<div className="loader-text text-fill">EVENTIFY</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,104 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

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

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

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

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

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

View File

@@ -0,0 +1,224 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

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

@@ -0,0 +1,303 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

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

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,132 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,178 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

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

View File

@@ -0,0 +1,87 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,179 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

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

@@ -0,0 +1,129 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,207 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@@ -0,0 +1,120 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@@ -0,0 +1,81 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

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

View File

@@ -0,0 +1,143 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

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

@@ -0,0 +1,107 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@@ -0,0 +1,637 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
},
);
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,27 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

111
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,111 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-2 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,24 @@
import { useToast } from "@/hooks/use-toast";
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root ref={ref} className={cn("flex items-center justify-center gap-1", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} />
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

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

View File

@@ -0,0 +1,3 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };

View File

@@ -0,0 +1,124 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { login as authLogin, logout as authLogout, checkUserStatus, getStoredAuth, storeAuth, clearAuth, AuthUser, AuthError } from '@/services/auth';
import { useToast } from '@/hooks/use-toast';
interface AuthContextType {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
// Check for existing auth on mount
useEffect(() => {
const checkAuth = async () => {
const storedAuth = getStoredAuth();
if (storedAuth) {
try {
// Verify token is still valid
await checkUserStatus(storedAuth.username, storedAuth.token);
setUser(storedAuth);
} catch (error) {
console.error('Auth verification failed:', error);
if (error instanceof AuthError && error.isInvalidToken) {
clearAuth();
toast({
title: 'Session Expired',
description: 'Your session has expired. Please login again.',
variant: 'destructive',
});
}
}
}
setIsLoading(false);
};
checkAuth();
}, []);
const login = async (username: string, password: string) => {
try {
setIsLoading(true);
const response = await authLogin(username, password);
// Store auth data
storeAuth(response);
// Set user state
const authUser: AuthUser = {
username: response.username,
token: response.token,
first_name: response.user?.first_name,
last_name: response.user?.last_name,
email: response.user?.email,
profile_photo: response.user?.profile_photo,
};
setUser(authUser);
toast({
title: 'Login Successful',
description: `Welcome back, ${username}!`,
});
} catch (error) {
console.error('Login error:', error);
const errorMessage = error instanceof Error ? error.message : 'Login failed';
toast({
title: 'Login Failed',
description: errorMessage,
variant: 'destructive',
});
throw error;
} finally {
setIsLoading(false);
}
};
const logout = async () => {
try {
if (user) {
await authLogout(user.username, user.token);
}
} catch (error) {
console.error('Logout error:', error);
} finally {
clearAuth();
setUser(null);
toast({
title: 'Logged Out',
description: 'You have been successfully logged out.',
});
}
};
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

216
src/data/mockData.ts Normal file
View File

@@ -0,0 +1,216 @@
import {
DashboardMetrics,
ActionItem,
ActivityItem,
RevenueDataPoint,
Partner,
Event
} from '@/types/dashboard';
export const mockDashboardMetrics: DashboardMetrics = {
totalRevenue: 2450000,
revenueGrowth: 12.5,
activePartners: 156,
pendingPartners: 12,
liveEvents: 43,
eventsToday: 8,
ticketSales: 12847,
ticketGrowth: 8.3,
};
export const mockActionItems: ActionItem[] = [
{
id: '1',
type: 'kyc',
count: 5,
title: 'Partner Approval Queue',
description: 'Partners awaiting KYC verification',
href: '/partners?filter=pending',
priority: 'high',
},
{
id: '2',
type: 'flagged',
count: 3,
title: 'Flagged Events',
description: 'Events reported for review',
href: '/events?filter=flagged',
priority: 'high',
},
{
id: '3',
type: 'payout',
count: 845000,
title: 'Pending Payouts',
description: 'Ready for release',
href: '/financials?tab=payouts',
priority: 'medium',
},
{
id: '4',
type: 'stripe',
count: 2,
title: 'Stripe Issues',
description: 'Connected accounts need attention',
href: '/partners?filter=stripe-issues',
priority: 'medium',
},
];
export const mockActivityItems: ActivityItem[] = [
{
id: '1',
type: 'partner',
title: 'New Partner Registered',
description: 'EventPro Solutions submitted KYC documents',
timestamp: new Date(Date.now() - 1000 * 60 * 15),
status: 'pending',
},
{
id: '2',
type: 'event',
title: 'Event Approved',
description: 'Mumbai Music Festival is now live',
timestamp: new Date(Date.now() - 1000 * 60 * 45),
status: 'success',
},
{
id: '3',
type: 'payout',
title: 'Payout Completed',
description: '₹2,45,000 transferred to TechConf India',
timestamp: new Date(Date.now() - 1000 * 60 * 120),
status: 'success',
},
{
id: '4',
type: 'event',
title: 'Event Flagged',
description: 'Reported content in "Night Club Party"',
timestamp: new Date(Date.now() - 1000 * 60 * 180),
status: 'warning',
},
{
id: '5',
type: 'user',
title: 'Admin Login',
description: 'Priya Sharma logged in from Mumbai',
timestamp: new Date(Date.now() - 1000 * 60 * 240),
status: 'success',
},
];
export const mockRevenueData: RevenueDataPoint[] = [
{ day: 'Mon', revenue: 245000, payouts: 180000 },
{ day: 'Tue', revenue: 312000, payouts: 220000 },
{ day: 'Wed', revenue: 289000, payouts: 195000 },
{ day: 'Thu', revenue: 378000, payouts: 280000 },
{ day: 'Fri', revenue: 456000, payouts: 340000 },
{ day: 'Sat', revenue: 523000, payouts: 390000 },
{ day: 'Sun', revenue: 247000, payouts: 185000 },
];
export const mockPartners: Partner[] = [
{
id: '1',
name: 'EventPro Solutions',
email: 'contact@eventpro.in',
kycStatus: 'pending',
stripeStatus: 'pending',
totalRevenue: 0,
eventsCount: 0,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
},
{
id: '2',
name: 'TechConf India',
email: 'hello@techconf.in',
kycStatus: 'approved',
stripeStatus: 'connected',
totalRevenue: 1250000,
eventsCount: 15,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 90),
},
{
id: '3',
name: 'Music Nights',
email: 'booking@musicnights.com',
kycStatus: 'approved',
stripeStatus: 'connected',
totalRevenue: 890000,
eventsCount: 28,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 45),
},
];
export const mockEvents: Event[] = [
{
id: '1',
title: 'Mumbai Music Festival',
partnerId: '3',
partnerName: 'Music Nights',
date: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
status: 'live',
ticketsSold: 2450,
revenue: 489000,
},
{
id: '2',
title: 'Tech Summit 2024',
partnerId: '2',
partnerName: 'TechConf India',
date: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
status: 'published',
ticketsSold: 890,
revenue: 445000,
},
{
id: '3',
title: 'Night Club Party',
partnerId: '3',
partnerName: 'Music Nights',
date: new Date(Date.now() + 1000 * 60 * 60 * 24 * 3),
status: 'flagged',
ticketsSold: 120,
revenue: 24000,
},
];
// Helper function to format currency in Indian format
export function formatCurrency(amount: number): string {
if (amount >= 10000000) {
return `${(amount / 10000000).toFixed(2)}Cr`;
} else if (amount >= 100000) {
return `${(amount / 100000).toFixed(2)}L`;
} else if (amount >= 1000) {
return `${(amount / 1000).toFixed(1)}K`;
}
return `${amount.toLocaleString('en-IN')}`;
}
// Helper to format large numbers
export function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
} else if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString('en-IN');
}
// Helper to format relative time
export function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else {
return `${diffDays}d ago`;
}
}

View File

@@ -0,0 +1,120 @@
export interface Settlement {
id: string;
partnerName: string;
eventName: string;
amount: number;
dueDate: string;
status: 'Ready' | 'On Hold' | 'Overdue';
}
export interface Transaction {
id: string;
title: string;
partner: string;
amount: number;
date: string; // ISO string
type: 'in' | 'out';
method: 'Stripe' | 'Bank Transfer' | 'Razorpay';
fees: number;
net: number;
status: 'Completed' | 'Pending' | 'Failed';
}
export const mockSettlements: Settlement[] = [
{
id: 's1',
partnerName: 'Neon Arena',
eventName: 'Summer Music Festival',
amount: 125000,
dueDate: '2026-02-05',
status: 'Ready',
},
{
id: 's2',
partnerName: 'TopTier Promoters',
eventName: 'Comedy Night',
amount: 45000,
dueDate: '2026-02-06',
status: 'On Hold',
},
{
id: 's3',
partnerName: 'TechFlow Solutions',
eventName: 'Tech Summit 2026',
amount: 85000,
dueDate: '2026-02-02', // Past date
status: 'Overdue',
},
{
id: 's4',
partnerName: 'Global Sponsors Inc',
eventName: 'Corporate Gala',
amount: 250000,
dueDate: '2026-02-10',
status: 'Ready',
},
];
export const mockTransactions: Transaction[] = [
{
id: 't1',
title: 'Ticket Sales - Summer Fest',
partner: 'Neon Arena',
amount: 25000,
date: new Date().toISOString(),
type: 'in',
method: 'Razorpay',
fees: 1250,
net: 23750,
status: 'Completed',
},
{
id: 't2',
title: 'Payout - Neon Arena',
partner: 'Neon Arena',
amount: 15000,
date: new Date().toISOString(),
type: 'out',
method: 'Bank Transfer',
fees: 0,
net: 15000,
status: 'Completed',
},
{
id: 't3',
title: 'Ticket Sales - Comedy Night',
partner: 'TopTier Promoters',
amount: 4500,
date: new Date(Date.now() - 86400000).toISOString(), // Yesterday
type: 'in',
method: 'Stripe',
fees: 225,
net: 4275,
status: 'Completed',
},
{
id: 't4',
title: 'Refund - User #442',
partner: 'Neon Arena',
amount: 1500,
date: new Date(Date.now() - 86400000).toISOString(),
type: 'out',
method: 'Razorpay',
fees: 0,
net: 1500,
status: 'Completed',
},
{
id: 't5',
title: 'Ticket Sales - Tech Summit',
partner: 'TechFlow Solutions',
amount: 12000,
date: '2026-02-01T10:00:00Z',
type: 'in',
method: 'Razorpay',
fees: 600,
net: 11400,
status: 'Completed',
},
];

310
src/data/mockPartnerData.ts Normal file
View File

@@ -0,0 +1,310 @@
import { Partner, DealTerm, LedgerEntry, PartnerDocument, KYCDocument, PartnerEvent } from '../types/partner';
import { subDays, subMonths, subHours, addDays } from 'date-fns';
export const mockPartners: Partner[] = [
{
id: 'p1',
name: 'Neon Arena',
type: 'Venue',
status: 'Active',
logo: 'https://ui-avatars.com/api/?name=Neon+Arena&background=0D8ABC&color=fff',
primaryContact: {
name: 'Alex Rivera',
email: 'alex@neonarena.com',
phone: '+91 98765 43210',
role: 'Venue Manager',
},
metrics: {
activeDeals: 2,
totalRevenue: 4500000,
openBalance: 125000,
lastActivity: new Date().toISOString(),
eventsCount: 12,
},
tags: ['Premium', 'Indoor', 'Capacity: 5000'],
joinedAt: subMonths(new Date(), 6).toISOString(),
verificationStatus: 'Verified',
riskScore: 12,
},
{
id: 'p2',
name: 'TopTier Promoters',
type: 'Promoter',
status: 'Active',
logo: 'https://ui-avatars.com/api/?name=Top+Tier&background=F59E0B&color=fff',
primaryContact: {
name: 'Sarah Chen',
email: 'sarah@toptier.com',
role: 'Head of Marketing',
},
metrics: {
activeDeals: 5,
totalRevenue: 850000,
openBalance: 45000,
lastActivity: subDays(new Date(), 2).toISOString(),
eventsCount: 8,
},
tags: ['Influencer Network', 'Social Media'],
joinedAt: subMonths(new Date(), 3).toISOString(),
verificationStatus: 'Verified',
riskScore: 45,
},
{
id: 'p3',
name: 'TechFlow Solutions',
type: 'Vendor',
status: 'Suspended',
logo: 'https://ui-avatars.com/api/?name=Tech+Flow&background=EF4444&color=fff',
primaryContact: {
name: 'Mike Ross',
email: 'mike@techflow.io',
role: 'Operations',
},
metrics: {
activeDeals: 0,
totalRevenue: 120000,
openBalance: 0,
lastActivity: subMonths(new Date(), 1).toISOString(),
eventsCount: 3,
},
tags: ['AV Equipment', 'Lighting'],
notes: 'Suspended due to breach of contract on Event #402',
joinedAt: subMonths(new Date(), 8).toISOString(),
verificationStatus: 'Rejected',
riskScore: 78,
},
{
id: 'p4',
name: 'Global Sponsors Inc',
type: 'Sponsor',
status: 'Active',
logo: 'https://ui-avatars.com/api/?name=Global+Sponsors&background=10B981&color=fff',
primaryContact: {
name: 'Jessica Pearson',
email: 'jessica@globalsponsors.com',
role: 'Brand Director',
},
metrics: {
activeDeals: 3,
totalRevenue: 2200000,
openBalance: 0,
lastActivity: subDays(new Date(), 5).toISOString(),
eventsCount: 6,
},
tags: ['Corporate', 'High Value'],
joinedAt: subDays(new Date(), 5).toISOString(),
verificationStatus: 'Verified',
riskScore: 8,
},
{
id: 'p5',
name: 'New Age Vendors',
type: 'Vendor',
status: 'Active',
logo: '',
primaryContact: {
name: 'John Doe',
email: 'john@newage.com',
role: 'Owner',
},
metrics: {
activeDeals: 0,
totalRevenue: 0,
openBalance: 0,
lastActivity: new Date().toISOString(),
eventsCount: 0,
},
tags: ['New'],
joinedAt: new Date().toISOString(),
verificationStatus: 'Pending',
riskScore: 0,
},
{
id: 'p6',
name: 'VibeCheck Events',
type: 'Promoter',
status: 'Active',
logo: 'https://ui-avatars.com/api/?name=Vibe+Check&background=8B5CF6&color=fff',
primaryContact: {
name: 'Priya Sharma',
email: 'priya@vibecheck.in',
phone: '+91 99887 66554',
role: 'Founder',
},
metrics: {
activeDeals: 1,
totalRevenue: 320000,
openBalance: 18000,
lastActivity: subDays(new Date(), 1).toISOString(),
eventsCount: 4,
},
tags: ['College Events', 'Music'],
joinedAt: subDays(new Date(), 3).toISOString(),
verificationStatus: 'Pending',
riskScore: 65,
},
];
// ── KYC Documents ───────────────────────────────────────────────────
export const mockKYCDocuments: KYCDocument[] = [
// Neon Arena (Verified) — all approved
{ id: 'kyc-1', partnerId: 'p1', type: 'PAN', name: 'PAN Card - Neon Arena Pvt Ltd', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 5).toISOString(), uploadedBy: 'Alex Rivera', uploadedAt: subMonths(new Date(), 6).toISOString() },
{ id: 'kyc-2', partnerId: 'p1', type: 'GST', name: 'GST Certificate', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 5).toISOString(), uploadedBy: 'Alex Rivera', uploadedAt: subMonths(new Date(), 6).toISOString() },
{ id: 'kyc-3', partnerId: 'p1', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque - HDFC', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 5).toISOString(), uploadedBy: 'Alex Rivera', uploadedAt: subMonths(new Date(), 6).toISOString() },
// TopTier (Verified)
{ id: 'kyc-4', partnerId: 'p2', type: 'PAN', name: 'PAN Card - TopTier Marketing', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 2).toISOString(), uploadedBy: 'Sarah Chen', uploadedAt: subMonths(new Date(), 3).toISOString() },
{ id: 'kyc-5', partnerId: 'p2', type: 'GST', name: 'GST Certificate', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 2).toISOString(), uploadedBy: 'Sarah Chen', uploadedAt: subMonths(new Date(), 3).toISOString() },
{ id: 'kyc-6', partnerId: 'p2', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque - ICICI', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 2).toISOString(), uploadedBy: 'Sarah Chen', uploadedAt: subMonths(new Date(), 3).toISOString() },
// TechFlow (Rejected)
{ id: 'kyc-7', partnerId: 'p3', type: 'PAN', name: 'PAN Card - TechFlow', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 7).toISOString(), uploadedBy: 'Mike Ross', uploadedAt: subMonths(new Date(), 8).toISOString() },
{ id: 'kyc-8', partnerId: 'p3', type: 'GST', name: 'GST Certificate', url: '#', status: 'REJECTED', mandatory: true, adminNote: 'GST number expired. Please re-upload a valid certificate.', reviewedBy: 'Admin', reviewedAt: subMonths(new Date(), 1).toISOString(), uploadedBy: 'Mike Ross', uploadedAt: subMonths(new Date(), 8).toISOString() },
// New Age (Pending)
{ id: 'kyc-9', partnerId: 'p5', type: 'PAN', name: 'PAN Card Copy', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() },
{ id: 'kyc-10', partnerId: 'p5', type: 'GST', name: 'GST Registration', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() },
{ id: 'kyc-11', partnerId: 'p5', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() },
{ id: 'kyc-12', partnerId: 'p5', type: 'BUSINESS_REG', name: 'Company Registration', url: '#', status: 'PENDING', mandatory: false, uploadedBy: 'John Doe', uploadedAt: subDays(new Date(), 1).toISOString() },
// VibeCheck (Pending — partial)
{ id: 'kyc-13', partnerId: 'p6', type: 'PAN', name: 'PAN Card - Priya Sharma', url: '#', status: 'APPROVED', mandatory: true, reviewedBy: 'Admin', reviewedAt: subDays(new Date(), 2).toISOString(), uploadedBy: 'Priya Sharma', uploadedAt: subDays(new Date(), 3).toISOString() },
{ id: 'kyc-14', partnerId: 'p6', type: 'GST', name: 'GST Certificate', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'Priya Sharma', uploadedAt: subDays(new Date(), 3).toISOString() },
{ id: 'kyc-15', partnerId: 'p6', type: 'CANCELLED_CHEQUE', name: 'Cancelled Cheque - SBI', url: '#', status: 'PENDING', mandatory: true, uploadedBy: 'Priya Sharma', uploadedAt: subDays(new Date(), 3).toISOString() },
];
// ── Partner Events ──────────────────────────────────────────────────
export const mockPartnerEvents: PartnerEvent[] = [
// Neon Arena events
{ id: 'evt-1', partnerId: 'p1', title: 'Neon Nights NYE 2026', date: addDays(new Date(), 30).toISOString(), time: '20:00', venue: 'Neon Arena - Main Hall', category: 'Music', ticketPrice: 2500, totalTickets: 5000, ticketsSold: 3200, revenue: 8000000, status: 'LIVE', submittedAt: subDays(new Date(), 15).toISOString(), createdAt: subDays(new Date(), 20).toISOString() },
{ id: 'evt-2', partnerId: 'p1', title: 'Tech Conference 2026', date: addDays(new Date(), 45).toISOString(), time: '09:00', venue: 'Neon Arena - Conference Wing', category: 'Technology', ticketPrice: 1500, totalTickets: 800, ticketsSold: 0, revenue: 0, status: 'PENDING_REVIEW', submittedAt: subHours(new Date(), 6).toISOString(), createdAt: subDays(new Date(), 3).toISOString() },
{ id: 'evt-3', partnerId: 'p1', title: 'Summer Music Fest', date: addDays(new Date(), 60).toISOString(), time: '16:00', venue: 'Neon Arena - Open Air', category: 'Music', ticketPrice: 1800, totalTickets: 10000, ticketsSold: 0, revenue: 0, status: 'DRAFT', submittedAt: subDays(new Date(), 1).toISOString(), createdAt: subDays(new Date(), 5).toISOString() },
// TopTier events
{ id: 'evt-4', partnerId: 'p2', title: 'Influencer Meetup Mumbai', date: addDays(new Date(), 10).toISOString(), time: '18:00', venue: 'The Grand Ballroom', category: 'Networking', ticketPrice: 500, totalTickets: 300, ticketsSold: 280, revenue: 140000, status: 'LIVE', submittedAt: subDays(new Date(), 20).toISOString(), createdAt: subDays(new Date(), 25).toISOString() },
{ id: 'evt-5', partnerId: 'p2', title: 'Creator Economy Summit', date: addDays(new Date(), 25).toISOString(), time: '10:00', venue: 'Convention Center Hall B', category: 'Business', ticketPrice: 3000, totalTickets: 500, ticketsSold: 0, revenue: 0, status: 'PENDING_REVIEW', submittedAt: subHours(new Date(), 12).toISOString(), createdAt: subDays(new Date(), 4).toISOString() },
// VibeCheck events
{ id: 'evt-6', partnerId: 'p6', title: 'College Beats Festival', date: addDays(new Date(), 15).toISOString(), time: '17:00', venue: 'University Grounds', category: 'Music', ticketPrice: 200, totalTickets: 2000, ticketsSold: 0, revenue: 0, status: 'PENDING_REVIEW', submittedAt: subHours(new Date(), 3).toISOString(), createdAt: subDays(new Date(), 2).toISOString() },
{ id: 'evt-7', partnerId: 'p6', title: 'Stand-Up Comedy Night', date: addDays(new Date(), 8).toISOString(), time: '20:00', venue: 'The Laughing Bar', category: 'Comedy', ticketPrice: 350, totalTickets: 150, ticketsSold: 120, revenue: 42000, status: 'LIVE', submittedAt: subDays(new Date(), 10).toISOString(), createdAt: subDays(new Date(), 12).toISOString() },
// Completed / Cancelled
{ id: 'evt-8', partnerId: 'p1', title: 'New Year Bash 2025', date: subMonths(new Date(), 2).toISOString(), time: '21:00', venue: 'Neon Arena', category: 'Music', ticketPrice: 2000, totalTickets: 5000, ticketsSold: 4800, revenue: 9600000, status: 'COMPLETED', submittedAt: subMonths(new Date(), 4).toISOString(), createdAt: subMonths(new Date(), 5).toISOString() },
{ id: 'evt-9', partnerId: 'p3', title: 'AV Tech Expo (Cancelled)', date: subDays(new Date(), 10).toISOString(), time: '10:00', venue: 'Exhibition Centre', category: 'Technology', ticketPrice: 0, totalTickets: 500, ticketsSold: 0, revenue: 0, status: 'CANCELLED', submittedAt: subMonths(new Date(), 2).toISOString(), createdAt: subMonths(new Date(), 3).toISOString() },
];
// ── Legacy Deal Terms ───────────────────────────────────────────────
export const mockDealTerms: DealTerm[] = [
{
id: 'dt1',
partnerId: 'p1',
type: 'RevenueShare',
name: 'Standard Venue Split',
params: {
percentage: 15,
currency: 'INR',
conditions: 'Net revenue after tax and platform fees',
},
effectiveFrom: subMonths(new Date(), 6).toISOString(),
status: 'Active',
version: 1,
},
{
id: 'dt2',
partnerId: 'p2',
type: 'CommissionPerTicket',
name: 'Promoter Commission',
params: {
amount: 150,
currency: 'INR',
},
effectiveFrom: subMonths(new Date(), 3).toISOString(),
status: 'Active',
version: 2,
},
];
// ── Legacy Ledger ───────────────────────────────────────────────────
export const mockLedger: LedgerEntry[] = [
{
id: 'le1',
partnerId: 'p1',
eventId: 'evt_123',
type: 'Credit',
description: 'Revenue Share - Neon Nights Event',
amount: 75000,
currency: 'INR',
createdAt: subDays(new Date(), 2).toISOString(),
status: 'Pending',
},
{
id: 'le2',
partnerId: 'p1',
type: 'Payout',
description: 'Monthly Settlement - Jan 2026',
amount: -50000,
currency: 'INR',
referenceId: 'TXN_987654',
createdAt: subDays(new Date(), 10).toISOString(),
status: 'Cleared',
},
{
id: 'le3',
partnerId: 'p2',
eventId: 'evt_124',
type: 'Credit',
description: 'Ticket Commission - Summer Fest',
amount: 12500,
currency: 'INR',
createdAt: subDays(new Date(), 1).toISOString(),
status: 'Pending',
},
];
// ── Legacy Documents ────────────────────────────────────────────────
export const mockDocuments: PartnerDocument[] = [
{
id: 'doc1',
partnerId: 'p1',
type: 'Contract',
name: 'Venue Agreement 2026',
url: '#',
status: 'Signed',
uploadedBy: 'Admin User',
uploadedAt: subMonths(new Date(), 6).toISOString(),
expiresAt: subMonths(new Date(), -6).toISOString(),
},
{
id: 'doc2',
partnerId: 'p1',
type: 'Tax',
name: 'GST Registration',
url: '#',
status: 'Verified',
uploadedBy: 'Alex Rivera',
uploadedAt: subMonths(new Date(), 6).toISOString(),
},
{
id: 'doc3',
partnerId: 'p5',
type: 'Compliance',
name: 'Company Registration (Pending)',
url: '#',
status: 'Pending',
uploadedBy: 'John Doe',
uploadedAt: subDays(new Date(), 1).toISOString(),
},
{
id: 'doc4',
partnerId: 'p5',
type: 'Tax',
name: 'PAN Card Copy',
url: '#',
status: 'Pending',
uploadedBy: 'John Doe',
uploadedAt: subDays(new Date(), 1).toISOString(),
},
{
id: 'doc5',
partnerId: 'p5',
type: 'Other',
name: 'Cancelled Cheque',
url: '#',
status: 'Pending',
uploadedBy: 'John Doe',
uploadedAt: subDays(new Date(), 1).toISOString(),
},
];

View File

@@ -0,0 +1,167 @@
import { useState, useMemo } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Search, MapPin, Calendar, AlertTriangle, CheckCircle2,
ImageOff, Clock, Users,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PickerEvent } from '@/lib/types/ad-control';
interface EventPickerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
events: PickerEvent[];
onSelectEvent: (event: PickerEvent) => void;
alreadyPlacedEventIds: string[];
}
export function EventPickerModal({ open, onOpenChange, events, onSelectEvent, alreadyPlacedEventIds }: EventPickerModalProps) {
const [query, setQuery] = useState('');
const now = new Date();
const filtered = useMemo(() => {
if (!query.trim()) return events;
const q = query.toLowerCase();
return events.filter(e =>
e.title.toLowerCase().includes(q) ||
e.id.toLowerCase().includes(q) ||
e.organizer.toLowerCase().includes(q) ||
e.city.toLowerCase().includes(q) ||
e.category.toLowerCase().includes(q)
);
}, [events, query]);
const getWarnings = (event: PickerEvent): { label: string; severity: 'warning' | 'error' }[] => {
const warns: { label: string; severity: 'warning' | 'error' }[] = [];
if (event.approvalStatus === 'PENDING') warns.push({ label: 'Pending Approval', severity: 'warning' });
if (event.approvalStatus === 'REJECTED') warns.push({ label: 'Rejected', severity: 'error' });
if (new Date(event.endDate) < now) warns.push({ label: 'Event Ended', severity: 'error' });
if (!event.coverImage) warns.push({ label: 'No Cover Image', severity: 'warning' });
return warns;
};
const handleSelect = (event: PickerEvent) => {
const warnings = getWarnings(event);
const hasErrors = warnings.some(w => w.severity === 'error');
if (hasErrors) {
const msg = warnings.filter(w => w.severity === 'error').map(w => w.label).join(', ');
if (!confirm(`This event has issues: ${msg}. Proceed anyway?`)) return;
}
onSelectEvent(event);
onOpenChange(false);
setQuery('');
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Event</DialogTitle>
<DialogDescription>Search for an event to place on this surface.</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search by name, ID, organizer, city, or category..."
className="pl-10"
autoFocus
/>
</div>
{/* Event List */}
<div className="flex-1 overflow-y-auto space-y-2 pr-1 min-h-0">
{filtered.length === 0 && (
<div className="text-center py-12 text-muted-foreground text-sm">
No events found matching "{query}"
</div>
)}
{filtered.map(event => {
const warnings = getWarnings(event);
const isPlaced = alreadyPlacedEventIds.includes(event.id);
const fillPercent = Math.round((event.ticketsSold / event.capacity) * 100);
return (
<button
key={event.id}
onClick={() => !isPlaced && handleSelect(event)}
disabled={isPlaced}
className={cn(
'w-full flex items-center gap-3 rounded-xl border p-3 text-left transition-all',
isPlaced
? 'opacity-50 cursor-not-allowed bg-muted/20'
: 'hover:shadow-md hover:border-primary/30 bg-card cursor-pointer',
)}
>
{/* Cover */}
{event.coverImage ? (
<img src={event.coverImage} alt="" className="h-16 w-24 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="h-16 w-24 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<ImageOff className="h-5 w-5 text-muted-foreground/30" />
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h4 className="font-semibold text-sm truncate">{event.title}</h4>
{event.approvalStatus === 'APPROVED' && (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><MapPin className="h-3 w-3" />{event.city}</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(event.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
<span>{event.organizer}</span>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-[10px] h-4">{event.category}</Badge>
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Users className="h-3 w-3" /> {event.ticketsSold.toLocaleString()}/{event.capacity.toLocaleString()} ({fillPercent}%)
</span>
</div>
</div>
{/* Warnings */}
<div className="flex flex-col gap-1 flex-shrink-0 items-end">
{isPlaced && (
<Badge variant="secondary" className="text-[10px]">Already Placed</Badge>
)}
{warnings.map((w, i) => (
<Badge
key={i}
variant="outline"
className={cn(
'text-[10px] gap-1',
w.severity === 'error'
? 'bg-red-50 text-red-600 border-red-200'
: 'bg-amber-50 text-amber-600 border-amber-200'
)}
>
<AlertTriangle className="h-3 w-3" />
{w.label}
</Badge>
))}
</div>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,282 @@
import { useState, useEffect } from 'react';
import {
Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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 { Loader2, ExternalLink, X, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { createPlacement, updatePlacement, publishPlacement } from '@/lib/actions/ad-control';
import { MOCK_CITIES, MOCK_CATEGORIES } from '../data/mockAdData';
import type { PickerEvent, PlacementWithEvent, PlacementPriority, PlacementConfigData } from '@/lib/types/ad-control';
interface PlacementConfigDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
event: PickerEvent | null;
surfaceId: string;
editingPlacement: PlacementWithEvent | null; // null = create mode
onComplete: () => void;
}
export function PlacementConfigDrawer({
open, onOpenChange, event, surfaceId, editingPlacement, onComplete,
}: PlacementConfigDrawerProps) {
const isEdit = !!editingPlacement;
const displayEvent = editingPlacement?.event || event;
const [startAt, setStartAt] = useState('');
const [endAt, setEndAt] = useState('');
const [selectedCities, setSelectedCities] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [boostLabel, setBoostLabel] = useState<string>('none');
const [priority, setPriority] = useState<PlacementPriority>('MANUAL');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState<'draft' | 'publish' | null>(null);
// Populate from editing placement
useEffect(() => {
if (editingPlacement) {
setStartAt(editingPlacement.startAt ? editingPlacement.startAt.slice(0, 16) : '');
setEndAt(editingPlacement.endAt ? editingPlacement.endAt.slice(0, 16) : '');
setSelectedCities(editingPlacement.targeting.cityIds);
setSelectedCategories(editingPlacement.targeting.categoryIds);
setBoostLabel(editingPlacement.boostLabel || 'none');
setPriority(editingPlacement.priority);
setNotes(editingPlacement.notes || '');
} else {
setStartAt('');
setEndAt('');
setSelectedCities([]);
setSelectedCategories([]);
setBoostLabel('none');
setPriority('MANUAL');
setNotes('');
}
}, [editingPlacement, open]);
const buildConfig = (): PlacementConfigData => ({
startAt: startAt ? new Date(startAt).toISOString() : null,
endAt: endAt ? new Date(endAt).toISOString() : null,
targeting: {
cityIds: selectedCities,
categoryIds: selectedCategories,
countryCodes: ['IN'],
},
boostLabel: boostLabel === 'none' ? null : boostLabel,
priority,
notes: notes.trim() || null,
});
const handleSaveDraft = async () => {
setLoading('draft');
try {
if (isEdit) {
const res = await updatePlacement(editingPlacement!.id, buildConfig());
res.success ? toast.success('Placement updated') : toast.error(res.message);
} else {
const res = await createPlacement(surfaceId, displayEvent!.id, buildConfig());
res.success ? toast.success(res.message) : toast.error(res.message);
}
onOpenChange(false);
onComplete();
} catch { toast.error('Save failed'); }
finally { setLoading(null); }
};
const handlePublish = async () => {
if (!confirm('Publish this placement? It will become visible on the public app.')) return;
setLoading('publish');
try {
if (isEdit) {
// Update first, then publish
await updatePlacement(editingPlacement!.id, buildConfig());
const res = await publishPlacement(editingPlacement!.id);
res.success ? toast.success(res.message) : toast.error(res.message);
} else {
const createRes = await createPlacement(surfaceId, displayEvent!.id, buildConfig());
if (createRes.success && createRes.data) {
const pubRes = await publishPlacement(createRes.data.id);
pubRes.success ? toast.success(pubRes.message) : toast.error(pubRes.message);
} else {
toast.error(createRes.message);
setLoading(null);
return;
}
}
onOpenChange(false);
onComplete();
} catch { toast.error('Publish failed'); }
finally { setLoading(null); }
};
const toggleCity = (id: string) => {
setSelectedCities(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
};
const toggleCategory = (id: string) => {
setSelectedCategories(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader className="pb-4">
<SheetTitle>{isEdit ? 'Edit Placement' : 'Configure Placement'}</SheetTitle>
<SheetDescription>
{isEdit ? 'Update schedule, targeting, and settings.' : 'Set up schedule, targeting, and publish.'}
</SheetDescription>
</SheetHeader>
{/* Event Preview */}
{displayEvent && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-muted/30 border mb-6">
{displayEvent.coverImage ? (
<img src={displayEvent.coverImage} alt="" className="h-14 w-20 rounded-lg object-cover" />
) : (
<div className="h-14 w-20 rounded-lg bg-muted" />
)}
<div className="min-w-0">
<h4 className="font-semibold text-sm truncate">{displayEvent.title}</h4>
<p className="text-xs text-muted-foreground">{displayEvent.city} · {displayEvent.organizer}</p>
<p className="text-xs text-muted-foreground">
{new Date(displayEvent.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
</div>
</div>
)}
<div className="space-y-6">
{/* Schedule */}
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Schedule</Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Start Date/Time</Label>
<Input type="datetime-local" value={startAt} onChange={e => setStartAt(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">End Date/Time</Label>
<Input type="datetime-local" value={endAt} onChange={e => setEndAt(e.target.value)} />
</div>
</div>
<p className="text-[11px] text-muted-foreground">Leave empty for no schedule constraints (always active when published).</p>
</div>
{/* Targeting — Cities */}
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">City Targeting</Label>
<div className="flex flex-wrap gap-1.5">
{MOCK_CITIES.map(city => (
<Badge
key={city.id}
variant="outline"
className={cn(
'cursor-pointer text-xs py-1 px-2 transition-all',
selectedCities.includes(city.id) && 'bg-primary text-primary-foreground border-primary'
)}
onClick={() => toggleCity(city.id)}
>
{selectedCities.includes(city.id) && '✓ '}
{city.name}
</Badge>
))}
</div>
<p className="text-[11px] text-muted-foreground">No selection = all cities (nationwide).</p>
</div>
{/* Targeting — Categories */}
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Category Targeting</Label>
<div className="flex flex-wrap gap-1.5">
{MOCK_CATEGORIES.map(cat => (
<Badge
key={cat.id}
variant="outline"
className={cn(
'cursor-pointer text-xs py-1 px-2 transition-all',
selectedCategories.includes(cat.id) && 'bg-primary text-primary-foreground border-primary'
)}
onClick={() => toggleCategory(cat.id)}
>
{selectedCategories.includes(cat.id) && '✓ '}
{cat.name}
</Badge>
))}
</div>
</div>
{/* Boost Label + Priority */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Boost Label</Label>
<Select value={boostLabel} onValueChange={setBoostLabel}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="Featured">Featured</SelectItem>
<SelectItem value="Top">Top</SelectItem>
<SelectItem value="Sponsored">Sponsored</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as PlacementPriority)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="SPONSORED">Sponsored</SelectItem>
<SelectItem value="MANUAL">Manual Curated</SelectItem>
<SelectItem value="ALGO">Algorithmic</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Notes */}
<div className="space-y-1.5">
<Label className="text-xs">Internal Notes</Label>
<Textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="e.g. Sponsor deal #1234, approved by marketing team..."
rows={3}
/>
</div>
{/* Preview Link (mock) */}
<Button variant="outline" className="w-full gap-2 text-sm" onClick={() => toast.info('Preview URL copied!', { description: `https://eventifyplus.com/preview?placement=${editingPlacement?.id || 'new'}&token=mock-preview-token` })}>
<ExternalLink className="h-4 w-4" />
Preview in App
</Button>
</div>
{/* Actions */}
<SheetFooter className="pt-6 gap-2 flex-row">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={!!loading} className="flex-1">
Cancel
</Button>
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!!loading}
className="flex-1"
>
{loading === 'draft' ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</> : 'Save as Draft'}
</Button>
<Button
onClick={handlePublish}
disabled={!!loading}
className="flex-1 bg-emerald-600 hover:bg-emerald-700"
>
{loading === 'publish' ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</> : 'Publish'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,257 @@
import { useState, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
GripVertical, MoreHorizontal, Eye, Pencil, Power, PowerOff,
Trash2, Calendar, MapPin, Loader2, Save, Sparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { publishPlacement, unpublishPlacement, deletePlacement, reorderPlacements } from '@/lib/actions/ad-control';
import type { PlacementWithEvent, PlacementStatus, PlacementPriority } from '@/lib/types/ad-control';
const STATUS_CONFIG: Record<PlacementStatus, { label: string; color: string; dot: string }> = {
ACTIVE: { label: 'Active', color: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
SCHEDULED: { label: 'Scheduled', color: 'bg-blue-50 text-blue-700 border-blue-200', dot: 'bg-blue-500' },
DRAFT: { label: 'Draft', color: 'bg-slate-50 text-slate-600 border-slate-200', dot: 'bg-slate-400' },
EXPIRED: { label: 'Expired', color: 'bg-red-50 text-red-600 border-red-200', dot: 'bg-red-400' },
DISABLED: { label: 'Disabled', color: 'bg-orange-50 text-orange-600 border-orange-200', dot: 'bg-orange-400' },
};
const PRIORITY_CONFIG: Record<PlacementPriority, { label: string; color: string }> = {
SPONSORED: { label: 'Sponsored', color: 'bg-purple-100 text-purple-700' },
MANUAL: { label: 'Curated', color: 'bg-sky-100 text-sky-700' },
ALGO: { label: 'Algorithm', color: 'bg-zinc-100 text-zinc-600' },
};
interface PlacementListProps {
placements: PlacementWithEvent[];
surfaceId: string;
onEdit: (placement: PlacementWithEvent) => void;
onRefresh: () => void;
}
export function PlacementList({ placements, surfaceId, onEdit, onRefresh }: PlacementListProps) {
const [items, setItems] = useState(placements);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const [hasReordered, setHasReordered] = useState(false);
const [saving, setSaving] = useState(false);
const [loadingAction, setLoadingAction] = useState<string | null>(null);
// Sync when placements prop changes
if (placements !== items && !hasReordered) {
setItems(placements);
}
// --- Drag and Drop ---
const handleDragStart = (index: number) => setDragIndex(index);
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setOverIndex(index);
};
const handleDragEnd = () => {
if (dragIndex !== null && overIndex !== null && dragIndex !== overIndex) {
const reordered = [...items];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(overIndex, 0, moved);
setItems(reordered);
setHasReordered(true);
}
setDragIndex(null);
setOverIndex(null);
};
const handleSaveOrder = async () => {
setSaving(true);
try {
const res = await reorderPlacements(surfaceId, items.map(p => p.id));
if (res.success) {
toast.success(res.message);
setHasReordered(false);
onRefresh();
} else { toast.error(res.message); }
} catch { toast.error('Failed to save order'); }
finally { setSaving(false); }
};
const handlePublish = async (id: string) => {
if (!confirm('Publish this placement? It will be visible to users.')) return;
setLoadingAction(id);
try {
const res = await publishPlacement(id);
res.success ? toast.success(res.message) : toast.error(res.message);
onRefresh();
} catch { toast.error('Publish failed'); }
finally { setLoadingAction(null); }
};
const handleUnpublish = async (id: string) => {
if (!confirm('Unpublish this placement?')) return;
setLoadingAction(id);
try {
const res = await unpublishPlacement(id);
res.success ? toast.success(res.message) : toast.error(res.message);
onRefresh();
} catch { toast.error('Unpublish failed'); }
finally { setLoadingAction(null); }
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this placement permanently?')) return;
setLoadingAction(id);
try {
const res = await deletePlacement(id);
res.success ? toast.success(res.message) : toast.error(res.message);
onRefresh();
} catch { toast.error('Delete failed'); }
finally { setLoadingAction(null); }
};
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center border-2 border-dashed border-border/50 rounded-xl bg-muted/10">
<Sparkles className="h-10 w-10 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-semibold text-foreground">No placements yet</h3>
<p className="text-muted-foreground text-sm mt-1 max-w-sm">
Add events to this surface to feature them on the public app.
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* Save Order Bar */}
{hasReordered && (
<div className="flex items-center justify-between bg-primary/5 border border-primary/20 rounded-lg px-4 py-2.5 animate-in slide-in-from-top-2">
<p className="text-sm font-medium text-primary">Order has been changed</p>
<Button size="sm" onClick={handleSaveOrder} disabled={saving} className="gap-2">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
Save Order
</Button>
</div>
)}
{/* Placement Cards */}
{items.map((placement, index) => {
const statusCfg = STATUS_CONFIG[placement.status];
const priorityCfg = PRIORITY_CONFIG[placement.priority];
const event = placement.event;
const isLoading = loadingAction === placement.id;
return (
<div
key={placement.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={cn(
'group flex items-center gap-3 rounded-xl border bg-card p-3 transition-all duration-200',
'hover:shadow-md hover:border-primary/20',
dragIndex === index && 'opacity-50 scale-[0.98]',
overIndex === index && dragIndex !== index && 'border-primary border-dashed',
isLoading && 'opacity-50 pointer-events-none',
)}
>
{/* Drag Handle */}
<div className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors">
<GripVertical className="h-5 w-5" />
</div>
{/* Rank */}
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground flex-shrink-0">
{index + 1}
</div>
{/* Event Cover */}
{event?.coverImage ? (
<img src={event.coverImage} alt="" className="h-14 w-20 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="h-14 w-20 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground/30" />
</div>
)}
{/* Event Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-sm truncate">{event?.title || placement.eventId || 'Unknown Event'}</h4>
{placement.boostLabel && (
<Badge variant="outline" className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
{placement.boostLabel}
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{event && (
<>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" /> {event.city}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> {new Date(event.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
</span>
<span>{event.organizer}</span>
</>
)}
</div>
</div>
{/* Priority Badge */}
<Badge variant="secondary" className={cn('text-[10px] h-5', priorityCfg.color)}>
{priorityCfg.label}
</Badge>
{/* Status Badge */}
<Badge variant="outline" className={cn('text-[10px] h-5 gap-1', statusCfg.color)}>
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dot)} />
{statusCfg.label}
</Badge>
{/* Schedule */}
{(placement.startAt || placement.endAt) && (
<div className="text-[10px] text-muted-foreground text-right leading-tight hidden lg:block">
{placement.startAt && <div>{new Date(placement.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>}
{placement.endAt && <div> {new Date(placement.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>}
</div>
)}
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onEdit(placement)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
{(placement.status === 'DRAFT' || placement.status === 'DISABLED') && (
<DropdownMenuItem onClick={() => handlePublish(placement.id)}>
<Power className="mr-2 h-4 w-4 text-emerald-600" /> Publish
</DropdownMenuItem>
)}
{(placement.status === 'ACTIVE' || placement.status === 'SCHEDULED') && (
<DropdownMenuItem onClick={() => handleUnpublish(placement.id)}>
<PowerOff className="mr-2 h-4 w-4 text-orange-600" /> Unpublish
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleDelete(placement.id)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {
Sparkles, TrendingUp, Layers, MapPin, Search,
} from 'lucide-react';
import type { Surface, PlacementItem } from '@/lib/types/ad-control';
const SURFACE_ICONS: Record<string, React.ElementType> = {
Sparkles, TrendingUp, Layers, MapPin, Search,
};
interface SurfaceTabsProps {
surfaces: Surface[];
activeSurfaceId: string;
onSelect: (surfaceId: string) => void;
placementCounts: Record<string, number>; // surfaceId → active/scheduled count
}
export function SurfaceTabs({ surfaces, activeSurfaceId, onSelect, placementCounts }: SurfaceTabsProps) {
return (
<div className="w-64 flex-shrink-0">
<div className="sticky top-24 space-y-1.5">
<p className="px-3 text-[10px] uppercase tracking-widest text-muted-foreground font-semibold mb-3">
Placement Surfaces
</p>
{surfaces.map((surface) => {
const Icon = SURFACE_ICONS[surface.icon] || Sparkles;
const count = placementCounts[surface.id] || 0;
const isActive = surface.id === activeSurfaceId;
return (
<button
key={surface.id}
onClick={() => onSelect(surface.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-3 rounded-xl text-left transition-all duration-200',
isActive
? 'bg-primary text-primary-foreground shadow-lg'
: 'hover:bg-muted/50 text-foreground'
)}
>
<div className={cn(
'h-9 w-9 rounded-lg flex items-center justify-center flex-shrink-0',
isActive ? 'bg-white/20' : 'bg-muted'
)}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{surface.name.replace('Home ', '').replace(' Events', '')}</p>
<p className={cn(
'text-[11px]',
isActive ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}>
{surface.layoutType} · {surface.sortBehavior}
</p>
</div>
<Badge
variant={isActive ? 'secondary' : 'outline'}
className={cn(
'text-[10px] h-5 min-w-[36px] justify-center font-mono',
isActive && 'bg-white/20 text-primary-foreground border-transparent',
count >= surface.maxSlots && !isActive && 'bg-red-50 text-red-600 border-red-200'
)}
>
{count}/{surface.maxSlots}
</Badge>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
ArrowLeft, Download, Loader2, IndianRupee, Eye,
MousePointerClick, Percent, TrendingUp, Calendar,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { getCampaignReport, exportCampaignCSV } from '@/lib/actions/ads';
import type { CampaignReport } from '@/lib/types/ads';
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-emerald-50 text-emerald-700 border-emerald-200',
IN_REVIEW: 'bg-amber-50 text-amber-700 border-amber-200',
PAUSED: 'bg-orange-50 text-orange-600 border-orange-200',
ENDED: 'bg-zinc-50 text-zinc-500 border-zinc-200',
REJECTED: 'bg-red-50 text-red-600 border-red-200',
DRAFT: 'bg-slate-50 text-slate-600 border-slate-200',
};
export function CampaignReportPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [report, setReport] = useState<CampaignReport | null>(null);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
useEffect(() => {
if (!id) return;
(async () => {
setLoading(true);
const res = await getCampaignReport(id);
if (res.success && res.data) setReport(res.data);
else toast.error(res.message);
setLoading(false);
})();
}, [id]);
const handleExport = async () => {
if (!id) return;
setExporting(true);
try {
const res = await exportCampaignCSV(id);
if (res.success && res.csv) {
const blob = new Blob([res.csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `campaign-${id}-report.csv`;
a.click();
URL.revokeObjectURL(url);
toast.success('CSV downloaded');
} else { toast.error(res.message); }
} catch { toast.error('Export failed'); }
finally { setExporting(false); }
};
// Aggregate daily stats by date for the chart
const chartData = useMemo(() => {
if (!report) return [];
const byDate: Record<string, { date: string; impressions: number; clicks: number }> = {};
for (const stat of report.dailyStats) {
if (!byDate[stat.date]) byDate[stat.date] = { date: stat.date, impressions: 0, clicks: 0 };
byDate[stat.date].impressions += stat.impressions;
byDate[stat.date].clicks += stat.clicks;
}
return Object.values(byDate).sort((a, b) => a.date.localeCompare(b.date));
}, [report]);
const maxImpressions = useMemo(() => Math.max(1, ...chartData.map(d => d.impressions)), [chartData]);
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!report) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Campaign not found</p>
<Button variant="ghost" onClick={() => navigate('/ad-control/sponsored')} className="mt-4 gap-2">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
</div>
);
}
const { campaign, totals, bySurface } = report;
const spendPct = campaign.totalBudget > 0 ? (totals.spend / campaign.totalBudget) * 100 : 0;
const summaryCards = [
{ label: 'Impressions', value: totals.impressions.toLocaleString(), icon: Eye, color: 'text-violet-600', bg: 'bg-violet-50' },
{ label: 'Clicks', value: totals.clicks.toLocaleString(), icon: MousePointerClick, color: 'text-amber-600', bg: 'bg-amber-50' },
{ label: 'CTR', value: `${(totals.ctr * 100).toFixed(2)}%`, icon: Percent, color: 'text-pink-600', bg: 'bg-pink-50' },
{ label: 'Spend', value: `${totals.spend.toLocaleString()}`, icon: IndianRupee, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: 'Remaining', value: `${totals.remaining.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-600', bg: 'bg-emerald-50' },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate('/ad-control/sponsored')} className="gap-2">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">{campaign.name}</h2>
<Badge variant="outline" className={cn('text-xs', STATUS_COLORS[campaign.status])}>
{campaign.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{campaign.partnerName} · {campaign.billingModel} · {new Date(campaign.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })} {new Date(campaign.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
</p>
</div>
</div>
<Button variant="outline" onClick={handleExport} disabled={exporting} className="gap-2">
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Export CSV
</Button>
</div>
{/* Budget Progress */}
<div className="bg-card border rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Budget Utilization</span>
<span className="text-sm font-mono">{totals.spend.toLocaleString()} / {campaign.totalBudget.toLocaleString()} ({spendPct.toFixed(1)}%)</span>
</div>
<div className="w-full h-3 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', spendPct > 90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')}
style={{ width: `${Math.min(100, spendPct)}%` }}
/>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-5 gap-4">
{summaryCards.map(s => (
<div key={s.label} className="rounded-xl border bg-card p-4 flex items-center gap-3">
<div className={cn('h-10 w-10 rounded-lg flex items-center justify-center', s.bg)}>
<s.icon className={cn('h-5 w-5', s.color)} />
</div>
<div>
<p className="text-xl font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</div>
))}
</div>
{/* Chart — CSS Bar Chart */}
<div className="bg-card border rounded-xl p-6">
<h3 className="text-sm font-semibold mb-4">Daily Performance</h3>
<div className="flex items-end gap-1.5 h-48">
{chartData.map((d) => {
const impHeight = (d.impressions / maxImpressions) * 100;
const clkHeight = maxImpressions > 0 ? (d.clicks / maxImpressions) * 100 : 0;
return (
<div key={d.date} className="flex-1 flex flex-col items-center gap-0.5 group relative">
{/* Tooltip */}
<div className="absolute -top-16 left-1/2 -translate-x-1/2 bg-foreground text-background text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
{d.date}: {d.impressions} imp, {d.clicks} clk
</div>
<div className="w-full flex items-end gap-0.5 flex-1">
<div
className="flex-1 bg-violet-400/70 rounded-t transition-all hover:bg-violet-500"
style={{ height: `${impHeight}%`, minHeight: d.impressions > 0 ? '2px' : '0' }}
/>
<div
className="flex-1 bg-amber-400/70 rounded-t transition-all hover:bg-amber-500"
style={{ height: `${clkHeight}%`, minHeight: d.clicks > 0 ? '2px' : '0' }}
/>
</div>
<span className="text-[9px] text-muted-foreground mt-1">{new Date(d.date + 'T00:00:00').toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-4 mt-3 justify-center">
<div className="flex items-center gap-1.5"><div className="h-2.5 w-2.5 rounded-sm bg-violet-400" /><span className="text-xs text-muted-foreground">Impressions</span></div>
<div className="flex items-center gap-1.5"><div className="h-2.5 w-2.5 rounded-sm bg-amber-400" /><span className="text-xs text-muted-foreground">Clicks</span></div>
</div>
</div>
{/* Surface Breakdown Table */}
<div className="bg-card border rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30">
<h3 className="text-sm font-semibold">Performance by Surface</h3>
</div>
<table className="w-full">
<thead>
<tr className="border-b text-left">
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground">Surface</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Impressions</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Clicks</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">CTR</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Spend</th>
</tr>
</thead>
<tbody>
{bySurface.map(s => (
<tr key={s.surfaceKey} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 text-sm font-medium">{s.surfaceName}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{s.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{s.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{(s.ctr * 100).toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right font-mono">{s.spend.toLocaleString()}</td>
</tr>
))}
<tr className="bg-muted/30 font-semibold">
<td className="px-4 py-3 text-sm">Total</td>
<td className="px-4 py-3 text-sm text-right font-mono">{totals.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{totals.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{(totals.ctr * 100).toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right font-mono">{totals.spend.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,426 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider';
import {
ArrowLeft, ArrowRight, Check, Loader2, Sparkles,
IndianRupee, Target, Calendar, Layers, Send,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { createCampaign, submitCampaign } from '@/lib/actions/ads';
import { EventPickerModal } from '@/features/ad-control/components/EventPickerModal';
import { MOCK_PICKER_EVENTS, MOCK_CITIES, MOCK_CATEGORIES } from '@/features/ad-control/data/mockAdData';
import type { CampaignFormData, BillingModel, CampaignObjective } from '@/lib/types/ads';
import type { SurfaceKey, PickerEvent } from '@/lib/types/ad-control';
const STEPS = [
{ id: 1, title: 'Basics', icon: Sparkles },
{ id: 2, title: 'Placement', icon: Layers },
{ id: 3, title: 'Targeting', icon: Target },
{ id: 4, title: 'Budget', icon: IndianRupee },
{ id: 5, title: 'Review', icon: Send },
];
const SURFACE_OPTIONS: { key: SurfaceKey; label: string }[] = [
{ key: 'HOME_FEATURED_CAROUSEL', label: 'Home Featured Carousel' },
{ key: 'HOME_TOP_EVENTS', label: 'Home Top Events' },
{ key: 'CATEGORY_FEATURED', label: 'Category Featured' },
{ key: 'CITY_TRENDING', label: 'City Trending' },
{ key: 'SEARCH_BOOSTED', label: 'Search Boosted' },
];
export function CampaignWizard() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
// Step 1: Basics
const [partnerName, setPartnerName] = useState('');
const [campaignName, setCampaignName] = useState('');
const [objective, setObjective] = useState<CampaignObjective>('AWARENESS');
const [startAt, setStartAt] = useState('');
const [endAt, setEndAt] = useState('');
// Step 2: Placement
const [surfaceKeys, setSurfaceKeys] = useState<SurfaceKey[]>([]);
const [selectedEvents, setSelectedEvents] = useState<PickerEvent[]>([]);
// Step 3: Targeting
const [selectedCities, setSelectedCities] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// Step 4: Budget
const [billingModel, setBillingModel] = useState<BillingModel>('CPM');
const [totalBudget, setTotalBudget] = useState(10000);
const [dailyCap, setDailyCap] = useState<number | null>(null);
const [frequencyCap, setFrequencyCap] = useState(5);
const toggleSurface = (key: SurfaceKey) => {
setSurfaceKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
};
const toggleCity = (id: string) => setSelectedCities(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
const toggleCategory = (id: string) => setSelectedCategories(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
const handleEventSelected = (event: PickerEvent) => {
if (!selectedEvents.find(e => e.id === event.id)) {
setSelectedEvents(prev => [...prev, event]);
}
};
const removeEvent = (id: string) => setSelectedEvents(prev => prev.filter(e => e.id !== id));
// --- Validation ---
const stepValid = (s: number): boolean => {
switch (s) {
case 1: return !!partnerName.trim() && !!campaignName.trim() && !!startAt && !!endAt;
case 2: return surfaceKeys.length > 0 && selectedEvents.length > 0;
case 3: return true; // targeting is optional
case 4: return totalBudget > 0;
case 5: return true;
default: return false;
}
};
const handleSubmit = async () => {
setLoading(true);
try {
const data: CampaignFormData = {
partnerName,
name: campaignName,
objective,
startAt: new Date(startAt).toISOString(),
endAt: new Date(endAt).toISOString(),
surfaceKeys,
eventIds: selectedEvents.map(e => e.id),
targeting: { cityIds: selectedCities, categoryIds: selectedCategories, countryCodes: ['IN'] },
billingModel,
totalBudget,
dailyCap,
frequencyCap,
};
const res = await createCampaign(data);
if (!res.success || !res.data) { toast.error(res.message); setLoading(false); return; }
// Auto-submit for review
const submitRes = await submitCampaign(res.data.id);
submitRes.success
? toast.success('Campaign submitted for review!')
: toast.error(submitRes.message);
navigate('/ad-control/sponsored');
} catch { toast.error('Failed to create campaign'); }
finally { setLoading(false); }
};
return (
<div className="max-w-3xl mx-auto">
{/* Step Indicator */}
<div className="flex items-center justify-between mb-8">
{STEPS.map((s, i) => (
<div key={s.id} className="flex items-center">
<button
onClick={() => s.id <= step && setStep(s.id)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all',
step === s.id ? 'bg-primary text-primary-foreground' :
step > s.id ? 'bg-emerald-50 text-emerald-700' : 'bg-muted text-muted-foreground',
)}
>
{step > s.id ? <Check className="h-4 w-4" /> : <s.icon className="h-4 w-4" />}
{s.title}
</button>
{i < STEPS.length - 1 && (
<div className={cn('w-8 h-0.5 mx-1', step > s.id ? 'bg-emerald-400' : 'bg-muted')} />
)}
</div>
))}
</div>
{/* Step Content */}
<div className="bg-card border rounded-xl p-6 min-h-[400px]">
{/* STEP 1: Basics */}
{step === 1 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Campaign Basics</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Partner / Organizer Name</Label>
<Input value={partnerName} onChange={e => setPartnerName(e.target.value)} placeholder="e.g. SoundWave Productions" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Objective</Label>
<Select value={objective} onValueChange={v => setObjective(v as CampaignObjective)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="AWARENESS">Awareness Maximize reach</SelectItem>
<SelectItem value="SALES">Sales Drive ticket purchases</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Campaign Name</Label>
<Input value={campaignName} onChange={e => setCampaignName(e.target.value)} placeholder="e.g. Mumbai Music Festival Premium Push" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Start Date</Label>
<Input type="datetime-local" value={startAt} onChange={e => setStartAt(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">End Date</Label>
<Input type="datetime-local" value={endAt} onChange={e => setEndAt(e.target.value)} />
</div>
</div>
</div>
)}
{/* STEP 2: Placement */}
{step === 2 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Placement Surfaces & Events</h3>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Surfaces</Label>
<div className="grid grid-cols-2 gap-2">
{SURFACE_OPTIONS.map(s => (
<label
key={s.key}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-all',
surfaceKeys.includes(s.key) ? 'border-primary bg-primary/5' : 'hover:bg-muted/30',
)}
>
<Checkbox
checked={surfaceKeys.includes(s.key)}
onCheckedChange={() => toggleSurface(s.key)}
/>
<span className="text-sm">{s.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Events</Label>
<Button variant="outline" size="sm" onClick={() => setPickerOpen(true)} className="gap-1">
<Sparkles className="h-3.5 w-3.5" /> Add Event
</Button>
</div>
{selectedEvents.length === 0 && (
<p className="text-sm text-muted-foreground italic">No events selected. Click "Add Event" to begin.</p>
)}
<div className="space-y-2">
{selectedEvents.map(event => (
<div key={event.id} className="flex items-center gap-3 rounded-lg border p-2">
{event.coverImage && <img src={event.coverImage} alt="" className="h-10 w-16 rounded object-cover" />}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{event.title}</p>
<p className="text-xs text-muted-foreground">{event.city} · {event.organizer}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeEvent(event.id)} className="text-red-500 hover:text-red-700 text-xs">Remove</Button>
</div>
))}
</div>
</div>
</div>
)}
{/* STEP 3: Targeting */}
{step === 3 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Audience Targeting</h3>
<p className="text-sm text-muted-foreground">Leave empty for nationwide, all-category targeting.</p>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Cities</Label>
<div className="flex flex-wrap gap-1.5">
{MOCK_CITIES.map(city => (
<Badge
key={city.id}
variant="outline"
className={cn('cursor-pointer text-xs py-1 px-2 transition-all', selectedCities.includes(city.id) && 'bg-primary text-primary-foreground border-primary')}
onClick={() => toggleCity(city.id)}
>
{selectedCities.includes(city.id) && '✓ '}{city.name}
</Badge>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Categories</Label>
<div className="flex flex-wrap gap-1.5">
{MOCK_CATEGORIES.map(cat => (
<Badge
key={cat.id}
variant="outline"
className={cn('cursor-pointer text-xs py-1 px-2 transition-all', selectedCategories.includes(cat.id) && 'bg-primary text-primary-foreground border-primary')}
onClick={() => toggleCategory(cat.id)}
>
{selectedCategories.includes(cat.id) && '✓ '}{cat.name}
</Badge>
))}
</div>
</div>
</div>
)}
{/* STEP 4: Budget & Pricing */}
{step === 4 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Budget & Pricing</h3>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Billing Model</Label>
<div className="grid grid-cols-3 gap-3">
{(['FIXED', 'CPM', 'CPC'] as BillingModel[]).map(model => (
<button
key={model}
onClick={() => setBillingModel(model)}
className={cn(
'rounded-xl border p-4 text-left transition-all',
billingModel === model ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-muted/30',
)}
>
<p className="font-semibold text-sm">{model === 'FIXED' ? 'Fixed Fee' : model}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{model === 'FIXED' ? 'Flat amount for date range' :
model === 'CPM' ? 'Cost per 1,000 impressions' :
'Cost per click'}
</p>
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Total Budget ()</Label>
<Input
type="number"
value={totalBudget}
onChange={e => setTotalBudget(Number(e.target.value))}
min={100}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Daily Cap (, optional)</Label>
<Input
type="number"
value={dailyCap ?? ''}
onChange={e => setDailyCap(e.target.value ? Number(e.target.value) : null)}
placeholder="No daily limit"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs">Frequency Cap (impressions / user / day)</Label>
<span className="text-sm font-mono font-bold">{frequencyCap === 0 ? 'Unlimited' : frequencyCap}</span>
</div>
<Slider
value={[frequencyCap]}
onValueChange={([v]) => setFrequencyCap(v)}
min={0}
max={20}
step={1}
/>
<p className="text-[11px] text-muted-foreground">Set to 0 for unlimited. Recommended: 3-5 per day.</p>
</div>
</div>
)}
{/* STEP 5: Review */}
{step === 5 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Review & Submit</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="space-y-3 p-4 bg-muted/20 rounded-xl">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Campaign</h4>
<div><span className="text-muted-foreground">Partner:</span> <strong>{partnerName}</strong></div>
<div><span className="text-muted-foreground">Name:</span> <strong>{campaignName}</strong></div>
<div><span className="text-muted-foreground">Objective:</span> <strong>{objective}</strong></div>
<div><span className="text-muted-foreground">Period:</span> <strong>{startAt ? new Date(startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} {endAt ? new Date(endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}</strong></div>
</div>
<div className="space-y-3 p-4 bg-muted/20 rounded-xl">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Budget</h4>
<div><span className="text-muted-foreground">Model:</span> <strong>{billingModel === 'FIXED' ? 'Fixed Fee' : billingModel}</strong></div>
<div><span className="text-muted-foreground">Total:</span> <strong>{totalBudget.toLocaleString()}</strong></div>
<div><span className="text-muted-foreground">Daily Cap:</span> <strong>{dailyCap ? `${dailyCap.toLocaleString()}` : 'None'}</strong></div>
<div><span className="text-muted-foreground">Frequency:</span> <strong>{frequencyCap || 'Unlimited'}/user/day</strong></div>
</div>
</div>
<div className="p-4 bg-muted/20 rounded-xl space-y-2">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Placement</h4>
<div className="flex flex-wrap gap-1.5">
{surfaceKeys.map(sk => (
<Badge key={sk} variant="secondary" className="text-xs">{sk.replace(/_/g, ' ')}</Badge>
))}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{selectedEvents.map(e => (
<Badge key={e.id} variant="outline" className="text-xs">{e.title}</Badge>
))}
</div>
</div>
<div className="p-4 bg-muted/20 rounded-xl space-y-2">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Targeting</h4>
<div className="text-sm">
<span className="text-muted-foreground">Cities:</span>{' '}
{selectedCities.length > 0 ? selectedCities.map(c => MOCK_CITIES.find(m => m.id === c)?.name).join(', ') : 'All (Nationwide)'}
</div>
<div className="text-sm">
<span className="text-muted-foreground">Categories:</span>{' '}
{selectedCategories.length > 0 ? selectedCategories.map(c => MOCK_CATEGORIES.find(m => m.id === c)?.name).join(', ') : 'All'}
</div>
</div>
</div>
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between mt-6">
<Button
variant="ghost"
onClick={() => step === 1 ? navigate('/ad-control/sponsored') : setStep(step - 1)}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
{step === 1 ? 'Cancel' : 'Back'}
</Button>
{step < 5 ? (
<Button onClick={() => setStep(step + 1)} disabled={!stepValid(step)} className="gap-2">
Next <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={handleSubmit} disabled={loading} className="gap-2 bg-emerald-600 hover:bg-emerald-700">
{loading ? <><Loader2 className="h-4 w-4 animate-spin" /> Submitting...</> : <><Send className="h-4 w-4" /> Submit for Approval</>}
</Button>
)}
</div>
{/* Event Picker */}
<EventPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
events={MOCK_PICKER_EVENTS}
onSelectEvent={handleEventSelected}
alreadyPlacedEventIds={selectedEvents.map(e => e.id)}
/>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Plus, Search, MoreHorizontal, BarChart3, Pause, Play,
CheckCircle2, XCircle, Loader2, IndianRupee, Eye, MousePointerClick,
Percent, TrendingUp, Megaphone,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { getCampaigns, getSponsoredStats, approveCampaign, rejectCampaign, pauseCampaign, resumeCampaign } from '@/lib/actions/ads';
import type { CampaignWithEvents, CampaignStatus } from '@/lib/types/ads';
const STATUS_CONFIG: Record<CampaignStatus, { label: string; color: string; dot: string }> = {
ACTIVE: { label: 'Active', color: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
IN_REVIEW: { label: 'In Review', color: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-500' },
PAUSED: { label: 'Paused', color: 'bg-orange-50 text-orange-600 border-orange-200', dot: 'bg-orange-400' },
DRAFT: { label: 'Draft', color: 'bg-slate-50 text-slate-600 border-slate-200', dot: 'bg-slate-400' },
ENDED: { label: 'Ended', color: 'bg-zinc-50 text-zinc-500 border-zinc-200', dot: 'bg-zinc-400' },
REJECTED: { label: 'Rejected', color: 'bg-red-50 text-red-600 border-red-200', dot: 'bg-red-400' },
};
const BILLING_LABELS: Record<string, string> = { FIXED: 'Fixed Fee', CPM: 'CPM', CPC: 'CPC' };
type StatusFilter = 'ALL' | CampaignStatus;
export function SponsoredDashboard() {
const navigate = useNavigate();
const [campaigns, setCampaigns] = useState<CampaignWithEvents[]>([]);
const [stats, setStats] = useState({ activeCampaigns: 0, todaySpend: 0, impressions24h: 0, clicks24h: 0, ctr24h: 0 });
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState('');
const load = async () => {
setLoading(true);
const [campRes, statRes] = await Promise.all([getCampaigns(statusFilter === 'ALL' ? undefined : statusFilter), getSponsoredStats()]);
if (campRes.success) setCampaigns(campRes.data);
if (statRes.success) setStats(statRes.data);
setLoading(false);
};
useEffect(() => { load(); }, [statusFilter]);
const filtered = useMemo(() => {
if (!query.trim()) return campaigns;
const q = query.toLowerCase();
return campaigns.filter(c =>
c.name.toLowerCase().includes(q) ||
c.partnerName.toLowerCase().includes(q) ||
c.id.toLowerCase().includes(q)
);
}, [campaigns, query]);
const doAction = async (id: string, action: () => Promise<{ success: boolean; message: string }>, successMsg?: string) => {
setActionLoading(id);
try {
const res = await action();
res.success ? toast.success(successMsg || res.message) : toast.error(res.message);
await load();
} catch { toast.error('Action failed'); }
finally { setActionLoading(null); }
};
const handleReject = async () => {
if (!rejectTarget || !rejectReason.trim()) { toast.error('Reason is required'); return; }
await doAction(rejectTarget, () => rejectCampaign(rejectTarget, rejectReason));
setRejectDialogOpen(false);
setRejectTarget(null);
setRejectReason('');
};
const statCards = [
{ label: 'Active Campaigns', value: stats.activeCampaigns, icon: Megaphone, color: 'text-emerald-600', bg: 'bg-emerald-50' },
{ label: 'Spend Today', value: `${stats.todaySpend.toLocaleString()}`, icon: IndianRupee, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: 'Impressions (24h)', value: stats.impressions24h.toLocaleString(), icon: Eye, color: 'text-violet-600', bg: 'bg-violet-50' },
{ label: 'Clicks (24h)', value: stats.clicks24h.toLocaleString(), icon: MousePointerClick, color: 'text-amber-600', bg: 'bg-amber-50' },
{ label: 'CTR (24h)', value: `${(stats.ctr24h * 100).toFixed(2)}%`, icon: Percent, color: 'text-pink-600', bg: 'bg-pink-50' },
];
return (
<div className="space-y-6">
{/* Stat Cards */}
<div className="grid grid-cols-5 gap-4">
{statCards.map((s) => (
<div key={s.label} className="rounded-xl border bg-card p-4 flex items-center gap-3">
<div className={cn('h-10 w-10 rounded-lg flex items-center justify-center', s.bg)}>
<s.icon className={cn('h-5 w-5', s.color)} />
</div>
<div>
<p className="text-2xl font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search campaigns..." className="pl-10" />
</div>
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList className="h-9">
<TabsTrigger value="ALL" className="text-xs px-3">All</TabsTrigger>
<TabsTrigger value="ACTIVE" className="text-xs px-3">Active</TabsTrigger>
<TabsTrigger value="IN_REVIEW" className="text-xs px-3">In Review</TabsTrigger>
<TabsTrigger value="PAUSED" className="text-xs px-3">Paused</TabsTrigger>
<TabsTrigger value="ENDED" className="text-xs px-3">Ended</TabsTrigger>
</TabsList>
</Tabs>
<Button onClick={() => navigate('/ad-control/sponsored/new')} className="gap-2 ml-auto">
<Plus className="h-4 w-4" /> New Campaign
</Button>
</div>
{/* Campaign Table */}
<div className="border rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-muted/30 border-b text-left">
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Partner</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Campaign</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Status</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Model</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Budget</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Surfaces</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Dates</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground text-right">Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={8} className="text-center py-12"><Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" /></td></tr>
)}
{!loading && filtered.length === 0 && (
<tr><td colSpan={8} className="text-center py-12 text-muted-foreground">No campaigns found</td></tr>
)}
{!loading && filtered.map(c => {
const statusCfg = STATUS_CONFIG[c.status];
const spendPct = c.totalBudget > 0 ? Math.min(100, (c.spent / c.totalBudget) * 100) : 0;
const isLoading = actionLoading === c.id;
return (
<tr key={c.id} className={cn('border-b hover:bg-muted/20 transition-colors', isLoading && 'opacity-50')}>
<td className="px-4 py-3">
<p className="text-sm font-medium">{c.partnerName}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm font-semibold">{c.name}</p>
<p className="text-xs text-muted-foreground">
{c.events.map(e => e.title).join(', ') || 'No events'}
</p>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className={cn('text-[10px] gap-1', statusCfg.color)}>
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dot)} />
{statusCfg.label}
</Badge>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground">{BILLING_LABELS[c.billingModel]}</span>
</td>
<td className="px-4 py-3">
<div className="space-y-1">
<p className="text-sm font-mono">{c.totalBudget.toLocaleString()}</p>
<div className="w-20 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', spendPct > 90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')}
style={{ width: `${spendPct}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground">{spendPct.toFixed(0)}% spent</p>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{c.surfaceKeys.slice(0, 2).map(sk => (
<Badge key={sk} variant="secondary" className="text-[9px] py-0 px-1.5">
{sk.replace(/_/g, ' ').replace('HOME ', '')}
</Badge>
))}
{c.surfaceKeys.length > 2 && (
<Badge variant="secondary" className="text-[9px] py-0 px-1.5">+{c.surfaceKeys.length - 2}</Badge>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-xs text-muted-foreground leading-tight">
<div>{new Date(c.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>
<div> {new Date(c.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>
</div>
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => navigate(`/ad-control/sponsored/${c.id}/report`)}>
<BarChart3 className="mr-2 h-4 w-4" /> View Report
</DropdownMenuItem>
{c.status === 'IN_REVIEW' && (
<>
<DropdownMenuItem onClick={() => doAction(c.id, () => approveCampaign(c.id))}>
<CheckCircle2 className="mr-2 h-4 w-4 text-emerald-600" /> Approve
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setRejectTarget(c.id); setRejectDialogOpen(true); }}>
<XCircle className="mr-2 h-4 w-4 text-red-600" /> Reject
</DropdownMenuItem>
</>
)}
{c.status === 'ACTIVE' && (
<DropdownMenuItem onClick={() => doAction(c.id, () => pauseCampaign(c.id))}>
<Pause className="mr-2 h-4 w-4 text-orange-600" /> Pause
</DropdownMenuItem>
)}
{c.status === 'PAUSED' && (
<DropdownMenuItem onClick={() => doAction(c.id, () => resumeCampaign(c.id))}>
<Play className="mr-2 h-4 w-4 text-emerald-600" /> Resume
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Reject Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Campaign</DialogTitle>
<DialogDescription>Provide a reason for rejection. This will be visible to the partner.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Reason</Label>
<Textarea value={rejectReason} onChange={e => setRejectReason(e.target.value)} placeholder="e.g. Event not approved, budget too low..." rows={3} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setRejectDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={handleReject} disabled={!rejectReason.trim()}>Reject Campaign</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,269 @@
// Ad Control — Mock Data: Surfaces, Events, and Seed Placements
import type {
Surface, PlacementItem, PickerEvent,
} from '@/lib/types/ad-control';
// ===== SURFACES =====
export const MOCK_SURFACES: Surface[] = [
{
id: 'srf-001', key: 'HOME_FEATURED_CAROUSEL', name: 'Home Featured Carousel',
description: 'Main hero carousel on the homepage — high visibility, prime real estate',
maxSlots: 8, layoutType: 'carousel', sortBehavior: 'rank', icon: 'Sparkles',
createdAt: '2025-06-01T00:00:00Z',
},
{
id: 'srf-002', key: 'HOME_TOP_EVENTS', name: 'Home Top Events',
description: 'Curated "Top Events" grid below the carousel on the homepage',
maxSlots: 12, layoutType: 'grid', sortBehavior: 'rank', icon: 'TrendingUp',
createdAt: '2025-06-01T00:00:00Z',
},
{
id: 'srf-003', key: 'CATEGORY_FEATURED', name: 'Category Featured',
description: 'Featured events pinned at the top of category pages',
maxSlots: 6, layoutType: 'grid', sortBehavior: 'rank', icon: 'Layers',
createdAt: '2025-07-15T00:00:00Z',
},
{
id: 'srf-004', key: 'CITY_TRENDING', name: 'City Trending',
description: 'Trending events shown on city landing pages',
maxSlots: 10, layoutType: 'list', sortBehavior: 'popularity', icon: 'MapPin',
createdAt: '2025-08-01T00:00:00Z',
},
{
id: 'srf-005', key: 'SEARCH_BOOSTED', name: 'Search Boosted',
description: 'Sponsored results that appear at the top of search results',
maxSlots: 5, layoutType: 'list', sortBehavior: 'rank', icon: 'Search',
createdAt: '2025-09-01T00:00:00Z',
},
];
// ===== MOCK EVENTS (for picker) =====
export const MOCK_PICKER_EVENTS: PickerEvent[] = [
{
id: 'evt-101', title: 'Mumbai Music Festival 2026', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
date: '2026-03-15T18:00:00Z', endDate: '2026-03-17T23:00:00Z', organizer: 'SoundWave Productions',
organizerLogo: 'https://i.pravatar.cc/40?u=soundwave', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400', approvalStatus: 'APPROVED',
ticketsSold: 4200, capacity: 8000,
},
{
id: 'evt-102', title: 'Delhi Tech Summit', city: 'New Delhi', state: 'Delhi', country: 'IN',
date: '2026-03-20T09:00:00Z', endDate: '2026-03-21T18:00:00Z', organizer: 'TechConf India',
organizerLogo: 'https://i.pravatar.cc/40?u=techconf', category: 'Technology',
coverImage: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=400', approvalStatus: 'APPROVED',
ticketsSold: 1800, capacity: 3000,
},
{
id: 'evt-103', title: 'Bangalore Food Carnival', city: 'Bangalore', state: 'Karnataka', country: 'IN',
date: '2026-04-05T11:00:00Z', endDate: '2026-04-07T22:00:00Z', organizer: 'FoodieHub',
organizerLogo: 'https://i.pravatar.cc/40?u=foodiehub', category: 'Food & Drink',
coverImage: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=400', approvalStatus: 'APPROVED',
ticketsSold: 3100, capacity: 5000,
},
{
id: 'evt-104', title: 'Hyderabad Comedy Night', city: 'Hyderabad', state: 'Telangana', country: 'IN',
date: '2026-03-28T19:00:00Z', endDate: '2026-03-28T22:00:00Z', organizer: 'LaughFactory',
organizerLogo: 'https://i.pravatar.cc/40?u=laughfactory', category: 'Comedy',
coverImage: 'https://images.unsplash.com/photo-1527224857830-43a7acc85260?w=400', approvalStatus: 'APPROVED',
ticketsSold: 450, capacity: 600,
},
{
id: 'evt-105', title: 'Chennai Classical Dance Festival', city: 'Chennai', state: 'Tamil Nadu', country: 'IN',
date: '2026-04-12T17:00:00Z', endDate: '2026-04-14T21:00:00Z', organizer: 'Natya Academy',
organizerLogo: 'https://i.pravatar.cc/40?u=natya', category: 'Arts & Culture',
coverImage: 'https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?w=400', approvalStatus: 'APPROVED',
ticketsSold: 900, capacity: 1500,
},
{
id: 'evt-106', title: 'Pune Marathon 2026', city: 'Pune', state: 'Maharashtra', country: 'IN',
date: '2026-03-10T05:30:00Z', endDate: '2026-03-10T12:00:00Z', organizer: 'RunIndia',
organizerLogo: 'https://i.pravatar.cc/40?u=runindia', category: 'Sports',
coverImage: 'https://images.unsplash.com/photo-1513593771513-7b58b6c4af38?w=400', approvalStatus: 'APPROVED',
ticketsSold: 6500, capacity: 10000,
},
{
id: 'evt-107', title: 'Goa Sunburn Festival', city: 'Goa', state: 'Goa', country: 'IN',
date: '2026-04-25T14:00:00Z', endDate: '2026-04-27T04:00:00Z', organizer: 'Sunburn Events',
organizerLogo: 'https://i.pravatar.cc/40?u=sunburn', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400', approvalStatus: 'APPROVED',
ticketsSold: 12000, capacity: 20000,
},
{
id: 'evt-108', title: 'Jaipur Literature Fest', city: 'Jaipur', state: 'Rajasthan', country: 'IN',
date: '2026-02-20T10:00:00Z', endDate: '2026-02-24T18:00:00Z', organizer: 'JLF Foundation',
organizerLogo: 'https://i.pravatar.cc/40?u=jlf', category: 'Arts & Culture',
coverImage: 'https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=400', approvalStatus: 'APPROVED',
ticketsSold: 2200, capacity: 5000,
},
{
id: 'evt-109', title: 'Kolkata Film Festival', city: 'Kolkata', state: 'West Bengal', country: 'IN',
date: '2026-05-10T10:00:00Z', endDate: '2026-05-17T22:00:00Z', organizer: 'KIFF',
organizerLogo: 'https://i.pravatar.cc/40?u=kiff', category: 'Film',
coverImage: 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=400', approvalStatus: 'PENDING',
ticketsSold: 0, capacity: 3000,
},
{
id: 'evt-110', title: 'Ahmedabad Startup Week', city: 'Ahmedabad', state: 'Gujarat', country: 'IN',
date: '2026-04-01T09:00:00Z', endDate: '2026-04-05T18:00:00Z', organizer: 'Startup Gujarat',
organizerLogo: 'https://i.pravatar.cc/40?u=startupguj', category: 'Business',
coverImage: null, approvalStatus: 'APPROVED',
ticketsSold: 800, capacity: 2000,
},
{
id: 'evt-111', title: 'Mumbai Art Walk', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
date: '2026-03-08T16:00:00Z', endDate: '2026-03-08T21:00:00Z', organizer: 'Art District',
organizerLogo: 'https://i.pravatar.cc/40?u=artdistrict', category: 'Arts & Culture',
coverImage: 'https://images.unsplash.com/photo-1531243269054-5ebf6f34081e?w=400', approvalStatus: 'APPROVED',
ticketsSold: 320, capacity: 500,
},
{
id: 'evt-112', title: 'Delhi Wine Experience', city: 'New Delhi', state: 'Delhi', country: 'IN',
date: '2026-03-22T18:00:00Z', endDate: '2026-03-22T23:00:00Z', organizer: 'Vineyard Co.',
organizerLogo: 'https://i.pravatar.cc/40?u=vineyard', category: 'Food & Drink',
coverImage: 'https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=400', approvalStatus: 'APPROVED',
ticketsSold: 180, capacity: 200,
},
{
id: 'evt-113', title: 'Bangalore Indie Music Showcase', city: 'Bangalore', state: 'Karnataka', country: 'IN',
date: '2026-03-30T19:00:00Z', endDate: '2026-03-30T23:00:00Z', organizer: 'IndieWave',
organizerLogo: 'https://i.pravatar.cc/40?u=indiewave', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400', approvalStatus: 'APPROVED',
ticketsSold: 700, capacity: 1000,
},
{
id: 'evt-114', title: 'Old Event (Ended)', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
date: '2025-12-01T10:00:00Z', endDate: '2025-12-03T20:00:00Z', organizer: 'Past Events Co.',
organizerLogo: 'https://i.pravatar.cc/40?u=pastevents', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1501281668745-f7f57925c3b4?w=400', approvalStatus: 'APPROVED',
ticketsSold: 5000, capacity: 5000,
},
{
id: 'evt-115', title: 'Rejected Event Example', city: 'Pune', state: 'Maharashtra', country: 'IN',
date: '2026-05-01T18:00:00Z', endDate: '2026-05-01T22:00:00Z', organizer: 'Shady Promo',
organizerLogo: 'https://i.pravatar.cc/40?u=shady', category: 'Nightlife',
coverImage: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=400', approvalStatus: 'REJECTED',
ticketsSold: 0, capacity: 500,
},
];
// ===== SEED PLACEMENTS =====
export const MOCK_PLACEMENTS: PlacementItem[] = [
// HOME_FEATURED_CAROUSEL — 4 active items
{
id: 'plc-001', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-101',
status: 'ACTIVE', priority: 'SPONSORED', rank: 1,
startAt: '2026-02-01T00:00:00Z', endAt: '2026-03-20T00:00:00Z',
targeting: { cityIds: [], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'Headline sponsor placement', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-01-28T10:00:00Z', updatedAt: '2026-02-05T14:00:00Z',
},
{
id: 'plc-002', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-107',
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
startAt: '2026-02-10T00:00:00Z', endAt: '2026-04-28T00:00:00Z',
targeting: { cityIds: [], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'High demand festival', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-01T10:00:00Z', updatedAt: '2026-02-10T09:00:00Z',
},
{
id: 'plc-003', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-102',
status: 'ACTIVE', priority: 'MANUAL', rank: 3,
startAt: null, endAt: null,
targeting: { cityIds: ['delhi'], categoryIds: ['technology'], countryCodes: ['IN'] },
boostLabel: null, notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-05T11:00:00Z', updatedAt: '2026-02-05T11:00:00Z',
},
{
id: 'plc-004', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-105',
status: 'SCHEDULED', priority: 'MANUAL', rank: 4,
startAt: '2026-03-01T00:00:00Z', endAt: '2026-04-15T00:00:00Z',
targeting: { cityIds: ['chennai'], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'Scheduled for March launch', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-08T09:00:00Z', updatedAt: '2026-02-08T09:00:00Z',
},
// HOME_TOP_EVENTS — 3 items
{
id: 'plc-005', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-103',
status: 'ACTIVE', priority: 'MANUAL', rank: 1,
startAt: null, endAt: null,
targeting: { cityIds: ['bangalore'], categoryIds: ['food-drink'], countryCodes: ['IN'] },
boostLabel: 'Top', notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-03T10:00:00Z', updatedAt: '2026-02-03T10:00:00Z',
},
{
id: 'plc-006', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-106',
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
startAt: null, endAt: null,
targeting: { cityIds: ['pune'], categoryIds: ['sports'], countryCodes: ['IN'] },
boostLabel: 'Top', notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-04T12:00:00Z', updatedAt: '2026-02-04T12:00:00Z',
},
{
id: 'plc-007', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-104',
status: 'DRAFT', priority: 'MANUAL', rank: 3,
startAt: null, endAt: null,
targeting: { cityIds: ['hyderabad'], categoryIds: ['comedy'], countryCodes: ['IN'] },
boostLabel: null, notes: 'Pending manager approval', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-07T15:00:00Z', updatedAt: '2026-02-07T15:00:00Z',
},
// CITY_TRENDING
{
id: 'plc-008', surfaceId: 'srf-004', itemType: 'EVENT', eventId: 'evt-111',
status: 'ACTIVE', priority: 'ALGO', rank: 1,
startAt: null, endAt: null,
targeting: { cityIds: ['mumbai'], categoryIds: [], countryCodes: ['IN'] },
boostLabel: null, notes: 'Auto-promoted by algorithm', createdBy: 'system', updatedBy: 'system',
createdAt: '2026-02-09T00:00:00Z', updatedAt: '2026-02-09T00:00:00Z',
},
{
id: 'plc-009', surfaceId: 'srf-004', itemType: 'EVENT', eventId: 'evt-113',
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
startAt: null, endAt: null,
targeting: { cityIds: ['bangalore'], categoryIds: ['music'], countryCodes: ['IN'] },
boostLabel: null, notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-09T08:00:00Z', updatedAt: '2026-02-09T08:00:00Z',
},
// Expired placement
{
id: 'plc-010', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-114',
status: 'EXPIRED', priority: 'MANUAL', rank: 99,
startAt: '2025-11-15T00:00:00Z', endAt: '2025-12-05T00:00:00Z',
targeting: { cityIds: ['mumbai'], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'Event has ended', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2025-11-10T10:00:00Z', updatedAt: '2025-12-05T00:01:00Z',
},
];
// ===== Targeting options for UI =====
export const MOCK_CITIES = [
{ id: 'mumbai', name: 'Mumbai' },
{ id: 'delhi', name: 'New Delhi' },
{ id: 'bangalore', name: 'Bangalore' },
{ id: 'hyderabad', name: 'Hyderabad' },
{ id: 'chennai', name: 'Chennai' },
{ id: 'pune', name: 'Pune' },
{ id: 'kolkata', name: 'Kolkata' },
{ id: 'jaipur', name: 'Jaipur' },
{ id: 'goa', name: 'Goa' },
{ id: 'ahmedabad', name: 'Ahmedabad' },
];
export const MOCK_CATEGORIES = [
{ id: 'music', name: 'Music' },
{ id: 'technology', name: 'Technology' },
{ id: 'food-drink', name: 'Food & Drink' },
{ id: 'comedy', name: 'Comedy' },
{ id: 'arts-culture', name: 'Arts & Culture' },
{ id: 'sports', name: 'Sports' },
{ id: 'business', name: 'Business' },
{ id: 'film', name: 'Film' },
{ id: 'nightlife', name: 'Nightlife' },
];

View File

@@ -0,0 +1,193 @@
// Sponsored Ads — Mock Data: Campaigns, Tracking Events, Daily Stats
import type { Campaign, AdTrackingEvent, PlacementDailyStats } from '@/lib/types/ads';
// ===== MOCK CAMPAIGNS =====
export const MOCK_CAMPAIGNS: Campaign[] = [
{
id: 'camp-001',
partnerId: 'partner-sw',
partnerName: 'SoundWave Productions',
name: 'Mumbai Music Festival Premium Push',
objective: 'AWARENESS',
status: 'ACTIVE',
startAt: '2026-02-01T00:00:00Z',
endAt: '2026-03-15T23:59:59Z',
billingModel: 'CPM',
totalBudget: 50000,
dailyCap: 2500,
spent: 18750,
targeting: { cityIds: ['mumbai', 'pune'], categoryIds: ['music'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'],
eventIds: ['evt-101'],
frequencyCap: 5,
approvedBy: 'admin-1',
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-01-25T10:00:00Z',
updatedAt: '2026-02-10T08:00:00Z',
},
{
id: 'camp-002',
partnerId: 'partner-sb',
partnerName: 'Sunburn Events',
name: 'Goa Sunburn Early Bird Blitz',
objective: 'SALES',
status: 'IN_REVIEW',
startAt: '2026-03-01T00:00:00Z',
endAt: '2026-04-25T23:59:59Z',
billingModel: 'CPC',
totalBudget: 75000,
dailyCap: 5000,
spent: 0,
targeting: { cityIds: [], categoryIds: ['music', 'nightlife'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_FEATURED_CAROUSEL', 'HOME_TOP_EVENTS', 'SEARCH_BOOSTED'],
eventIds: ['evt-107'],
frequencyCap: 3,
approvedBy: null,
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-02-08T14:00:00Z',
updatedAt: '2026-02-08T14:00:00Z',
},
{
id: 'camp-003',
partnerId: 'partner-tc',
partnerName: 'TechConf India',
name: 'Delhi Tech Summit Sponsor Package',
objective: 'AWARENESS',
status: 'DRAFT',
startAt: '2026-03-10T00:00:00Z',
endAt: '2026-03-21T23:59:59Z',
billingModel: 'FIXED',
totalBudget: 30000,
dailyCap: null,
spent: 0,
targeting: { cityIds: ['delhi'], categoryIds: ['technology', 'business'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_TOP_EVENTS', 'CATEGORY_FEATURED'],
eventIds: ['evt-102'],
frequencyCap: 0,
approvedBy: null,
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-02-09T11:00:00Z',
updatedAt: '2026-02-09T11:00:00Z',
},
{
id: 'camp-004',
partnerId: 'partner-ri',
partnerName: 'RunIndia',
name: 'Pune Marathon Registration Drive',
objective: 'SALES',
status: 'ENDED',
startAt: '2026-01-15T00:00:00Z',
endAt: '2026-02-05T23:59:59Z',
billingModel: 'CPM',
totalBudget: 20000,
dailyCap: 1500,
spent: 19800,
targeting: { cityIds: ['pune', 'mumbai'], categoryIds: ['sports'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_TOP_EVENTS', 'CITY_TRENDING'],
eventIds: ['evt-106'],
frequencyCap: 4,
approvedBy: 'admin-1',
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-01-10T09:00:00Z',
updatedAt: '2026-02-05T23:59:59Z',
},
];
// ===== MOCK TRACKING EVENTS (for camp-001) =====
function genTrackingEvents(): AdTrackingEvent[] {
const events: AdTrackingEvent[] = [];
const surfaces = ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'] as const;
const devices = ['mobile-ios', 'mobile-android', 'web-desktop', 'web-mobile'];
const cities = ['mumbai', 'pune'];
const baseTime = new Date('2026-02-03T00:00:00Z');
for (let day = 0; day < 7; day++) {
const impressionsPerDay = 80 + Math.floor(Math.random() * 40);
for (let i = 0; i < impressionsPerDay; i++) {
const ts = new Date(baseTime.getTime() + day * 86400000 + Math.floor(Math.random() * 86400000));
const surface = surfaces[Math.floor(Math.random() * surfaces.length)];
events.push({
id: `te-imp-${day}-${i}`,
type: 'IMPRESSION',
placementId: `splc-camp001-${surface}`,
campaignId: 'camp-001',
surfaceKey: surface,
eventId: 'evt-101',
userId: Math.random() > 0.4 ? `user-${100 + Math.floor(Math.random() * 50)}` : null,
anonId: `anon-${Math.floor(Math.random() * 200)}`,
sessionId: `sess-${day}-${Math.floor(Math.random() * 100)}`,
timestamp: ts.toISOString(),
device: devices[Math.floor(Math.random() * devices.length)],
cityId: cities[Math.floor(Math.random() * cities.length)],
});
}
// Clicks (~8-15% of impressions)
const clicksPerDay = Math.floor(impressionsPerDay * (0.08 + Math.random() * 0.07));
for (let c = 0; c < clicksPerDay; c++) {
const ts = new Date(baseTime.getTime() + day * 86400000 + Math.floor(Math.random() * 86400000));
const surface = surfaces[Math.floor(Math.random() * surfaces.length)];
events.push({
id: `te-clk-${day}-${c}`,
type: 'CLICK',
placementId: `splc-camp001-${surface}`,
campaignId: 'camp-001',
surfaceKey: surface,
eventId: 'evt-101',
userId: Math.random() > 0.3 ? `user-${100 + Math.floor(Math.random() * 50)}` : null,
anonId: `anon-${Math.floor(Math.random() * 200)}`,
sessionId: `sess-${day}-${Math.floor(Math.random() * 100)}`,
timestamp: ts.toISOString(),
device: devices[Math.floor(Math.random() * devices.length)],
cityId: cities[Math.floor(Math.random() * cities.length)],
});
}
}
return events;
}
export const MOCK_TRACKING_EVENTS = genTrackingEvents();
// ===== MOCK DAILY STATS =====
export function generateMockDailyStats(): PlacementDailyStats[] {
const stats: PlacementDailyStats[] = [];
const baseDate = new Date('2026-02-03');
const cpmRate = 50000 / (7 * 100); // simplified: budget / (days * avg impressions per batch)
for (let day = 0; day < 7; day++) {
const date = new Date(baseDate.getTime() + day * 86400000).toISOString().slice(0, 10);
const surfaces = ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'] as const;
for (const surface of surfaces) {
const impressions = 40 + Math.floor(Math.random() * 25);
const clicks = Math.floor(impressions * (0.08 + Math.random() * 0.07));
const ctr = impressions > 0 ? Number((clicks / impressions).toFixed(4)) : 0;
const spend = Number(((impressions / 1000) * 250).toFixed(2)); // ₹250 CPM
stats.push({
id: `ds-${day}-${surface}`,
campaignId: 'camp-001',
placementId: `splc-camp001-${surface}`,
surfaceKey: surface,
date,
impressions,
clicks,
ctr,
spend,
});
}
}
return stats;
}
export const MOCK_DAILY_STATS = generateMockDailyStats();

View File

@@ -0,0 +1,579 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { CalendarIcon, Loader2, Upload } from "lucide-react";
import { format } from "date-fns";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
const formSchema = z.object({
name: z.string().min(2, "Name is required"),
description: z.string().optional(),
startDate: z.date({ required_error: "Start date is required" }),
endDate: z.date().optional(),
startTime: z.string().optional(),
endTime: z.string().optional(),
allYearEvent: z.boolean().default(false),
latitude: z.string().optional(),
longitude: z.string().optional(),
pincode: z.string().optional(),
district: z.string().optional(),
state: z.string().optional(),
place: z.string().optional(), // Specific locality/place
isBookable: z.boolean().default(false),
isEventifyEvent: z.boolean().default(false),
outsideEventUrl: z.string().optional(),
eventType: z.string().min(1, "Event type is required"),
eventStatus: z.string().default("Pending"),
cancelledReason: z.string().optional(),
title: z.string().min(2, "Title is required"), // Display title?
importantInformation: z.string().optional(),
venueName: z.string().optional(),
source: z.string().optional(),
imageUrl: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
interface CreateEventSheetProps {
children: React.ReactNode;
}
export function CreateEventSheet({ children }: CreateEventSheetProps) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
startTime: "",
endTime: "",
allYearEvent: false,
latitude: "",
longitude: "",
pincode: "",
district: "",
state: "",
place: "",
isBookable: false,
isEventifyEvent: false,
outsideEventUrl: "NA",
eventType: "",
eventStatus: "Pending",
cancelledReason: "NA",
title: "",
importantInformation: "",
venueName: "",
source: "",
imageUrl: "",
},
});
async function onSubmit(data: FormValues) {
setIsSubmitting(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log("Form submitted:", data);
toast.success("Event created successfully");
setIsSubmitting(false);
setOpen(false);
form.reset();
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{children}
</SheetTrigger>
<SheetContent className="w-[100%] sm:w-[540px] md:w-[700px] overflow-y-scroll sm:max-w-[700px]">
<SheetHeader>
<SheetTitle>Add Event</SheetTitle>
<SheetDescription>
Fill in the details to create a new event.
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 mt-6 pb-20">
{/* Basic Info */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Event Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Event Title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Event description..." className="min-h-[100px]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Date & Time */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Start date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? format(field.value, "dd/MM/yyyy") : <span>dd/mm/yyyy</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>End date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? format(field.value, "dd/MM/yyyy") : <span>dd/mm/yyyy</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="startTime"
render={({ field }) => (
<FormItem>
<FormLabel>Start time</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endTime"
render={({ field }) => (
<FormItem>
<FormLabel>End time</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="allYearEvent"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>All year event</FormLabel>
<FormDescription>
This event happens year-round.
</FormDescription>
</div>
</FormItem>
)}
/>
{/* Location */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="latitude"
render={({ field }) => (
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input placeholder="Lat" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input placeholder="Long" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="pincode"
render={({ field }) => (
<FormItem>
<FormLabel>Pincode</FormLabel>
<FormControl>
<Input placeholder="Pincode" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="place"
render={({ field }) => (
<FormItem>
<FormLabel>Place</FormLabel>
<FormControl>
<Input placeholder="Place" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="district"
render={({ field }) => (
<FormItem>
<FormLabel>District</FormLabel>
<FormControl>
<Input placeholder="District" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="state"
render={({ field }) => (
<FormItem>
<FormLabel>State</FormLabel>
<FormControl>
<Input placeholder="State" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="venueName"
render={({ field }) => (
<FormItem>
<FormLabel>Venue name</FormLabel>
<FormControl>
<Input placeholder="Venue Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="isBookable"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Is bookable</FormLabel>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isEventifyEvent"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Is eventify event</FormLabel>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="outsideEventUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Outside event url</FormLabel>
<FormControl>
<Input placeholder="NA" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="eventType"
render={({ field }) => (
<FormItem>
<FormLabel>Event type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="---------" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="drama">Drama</SelectItem>
<SelectItem value="festivals">Festivals</SelectItem>
<SelectItem value="sport">Sport</SelectItem>
<SelectItem value="music">Music</SelectItem>
<SelectItem value="comedy">Comedy</SelectItem>
<SelectItem value="workshops">Workshops</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventStatus"
render={({ field }) => (
<FormItem>
<FormLabel>Event status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select Status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Pending">Pending</SelectItem>
<SelectItem value="Published">Published</SelectItem>
<SelectItem value="Cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="cancelledReason"
render={({ field }) => (
<FormItem>
<FormLabel>Cancelled reason</FormLabel>
<FormControl>
<Input placeholder="NA" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="importantInformation"
render={({ field }) => (
<FormItem>
<FormLabel>Important information</FormLabel>
<FormControl>
<Textarea placeholder="Important info..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>Source</FormLabel>
<FormControl>
<Input placeholder="Source" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3 pt-4 sticky bottom-0 bg-background/95 backdrop-blur py-4 border-t mt-8">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-primary text-primary-foreground min-w-[120px]">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Create Event"
)}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,116 @@
import { useState } from "react";
import { format } from "date-fns";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { mockSettlements, Settlement } from "@/data/mockFinancialData";
import { ArrowUpRight } from "lucide-react";
import { toast } from "sonner";
export function SettlementTable() {
const [selected, setSelected] = useState<string[]>([]);
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelected(mockSettlements.map(s => s.id));
} else {
setSelected([]);
}
};
const handleSelectOne = (id: string, checked: boolean) => {
if (checked) {
setSelected(prev => [...prev, id]);
} else {
setSelected(prev => prev.filter(item => item !== id));
}
};
const handleReleasePayout = () => {
toast.success(`Processing payouts for ${selected.length} partners`);
setSelected([]);
};
const getStatusBadge = (status: Settlement['status']) => {
switch (status) {
case 'Ready':
return <Badge className="bg-success/15 text-success hover:bg-success/25 border-none">Ready</Badge>;
case 'On Hold':
return <Badge className="bg-warning/15 text-warning hover:bg-warning/25 border-none">On Hold</Badge>;
case 'Overdue':
return <Badge className="bg-error/15 text-error hover:bg-error/25 border-none">Overdue</Badge>;
default: return null;
}
};
return (
<div className="neu-card p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-bold text-foreground">Due for Settlement</h2>
<p className="text-sm text-muted-foreground">Manage pending partner payouts</p>
</div>
<Button
onClick={handleReleasePayout}
disabled={selected.length === 0}
className="bg-primary hover:bg-primary/90 text-white"
>
Release Payout ({selected.length})
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-md border border-border/50">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[50px]">
<Checkbox
checked={selected.length === mockSettlements.length && mockSettlements.length > 0}
onCheckedChange={(checked) => handleSelectAll(checked as boolean)}
/>
</TableHead>
<TableHead>Partner Name</TableHead>
<TableHead>Event</TableHead>
<TableHead>Unsettled Amount</TableHead>
<TableHead>Due Date</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockSettlements.map((settlement) => (
<TableRow key={settlement.id} className="hover:bg-secondary/20">
<TableCell>
<Checkbox
checked={selected.includes(settlement.id)}
onCheckedChange={(checked) => handleSelectOne(settlement.id, checked as boolean)}
/>
</TableCell>
<TableCell className="font-medium">{settlement.partnerName}</TableCell>
<TableCell className="text-muted-foreground">{settlement.eventName}</TableCell>
<TableCell className="font-semibold">
{settlement.amount.toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}
</TableCell>
<TableCell>
{format(new Date(settlement.dueDate), 'MMM dd, yyyy')}
</TableCell>
<TableCell className="text-right">
{getStatusBadge(settlement.status)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Loader2, Landmark } from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
const taxSettingsSchema = z.object({
gstNumber: z.string().min(15, "GST Number must be 15 characters").max(15, "GST Number must be 15 characters"),
panNumber: z.string().min(10, "PAN Number must be 10 characters").max(10, "PAN Number must be 10 characters"),
platformFeePercentage: z.string().regex(/^\d+(\.\d{1,2})?$/, "Must be a valid percentage"),
taxPercentage: z.string().regex(/^\d+(\.\d{1,2})?$/, "Must be a valid percentage"),
});
type FormValues = z.infer<typeof taxSettingsSchema>;
interface TaxSettingsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TaxSettingsSheet({ open, onOpenChange }: TaxSettingsSheetProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(taxSettingsSchema),
defaultValues: {
gstNumber: "27AAAAA0000A1Z5",
panNumber: "AAAAA0000A",
platformFeePercentage: "12",
taxPercentage: "18",
},
});
async function onSubmit(data: FormValues) {
setIsSubmitting(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log("Tax settings updated:", data);
toast.success("Tax settings updated successfully");
setIsSubmitting(false);
onOpenChange(false);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[400px] sm:w-[540px]">
<SheetHeader>
<SheetTitle>Tax & Platform Settings</SheetTitle>
<SheetDescription>
Configure platform fees and tax details.
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 mt-6">
<div className="bg-secondary/20 p-4 rounded-lg flex items-start gap-4 mb-6">
<Landmark className="h-6 w-6 text-primary mt-1" />
<div>
<h4 className="font-semibold text-foreground">Global Settings</h4>
<p className="text-sm text-muted-foreground">These settings apply to all new events and transactions by default.</p>
</div>
</div>
<FormField
control={form.control}
name="platformFeePercentage"
render={({ field }) => (
<FormItem>
<FormLabel>Default Platform Fee (%)</FormLabel>
<FormControl>
<Input placeholder="12" {...field} />
</FormControl>
<FormDescription>
Percentage of ticket sales taken as platform revenue.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="taxPercentage"
render={({ field }) => (
<FormItem>
<FormLabel>Govt. Tax (GST) (%)</FormLabel>
<FormControl>
<Input placeholder="18" {...field} />
</FormControl>
<FormDescription>
Applicable GST on platform fees.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="gstNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Company GSTIN</FormLabel>
<FormControl>
<Input placeholder="27AAAAA0000A1Z5" {...field} className="uppercase" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="panNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Company PAN</FormLabel>
<FormControl>
<Input placeholder="AAAAA0000A" {...field} className="uppercase" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-primary text-primary-foreground">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
"Save Changes"
)}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,93 @@
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Transaction } from "@/data/mockFinancialData";
import { AlertCircle, CheckCircle2, XCircle } from "lucide-react";
interface TransactionDetailsSheetProps {
transaction: Transaction | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TransactionDetailsSheet({ transaction, open, onOpenChange }: TransactionDetailsSheetProps) {
if (!transaction) return null;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>Transaction Details</SheetTitle>
<SheetDescription>
ID: {transaction.id}
</SheetDescription>
</SheetHeader>
<div className="mt-8 space-y-6">
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl">
<div className="flex items-center gap-3">
{transaction.status === 'Completed' ? (
<CheckCircle2 className="h-8 w-8 text-success" />
) : transaction.status === 'Failed' ? (
<XCircle className="h-8 w-8 text-error" />
) : (
<AlertCircle className="h-8 w-8 text-warning" />
)}
<div>
<p className="font-bold text-lg">{transaction.amount.toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}</p>
<p className="text-sm text-muted-foreground">{transaction.status}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium">{new Date(transaction.date).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{new Date(transaction.date).toLocaleTimeString()}</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Breakdown</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-foreground">Gross Amount</span>
<span className="font-medium">{transaction.amount.toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Platform Fees (5%)</span>
<span>- {transaction.fees.toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}</span>
</div>
<div className="h-px bg-border my-2" />
<div className="flex justify-between text-lg font-bold">
<span>Net Settlement</span>
<span className="text-success">{transaction.net.toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Metadata</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-muted-foreground">Partner</p>
<p className="font-medium">{transaction.partner}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Method</p>
<p className="font-medium">{transaction.method}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-muted-foreground">Description</p>
<p className="font-medium">{transaction.title}</p>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import { format, isToday, isYesterday } from "date-fns";
import { mockTransactions, Transaction } from "@/data/mockFinancialData";
import { TransactionDetailsSheet } from "./TransactionDetailsSheet";
import { ArrowUpRight, ArrowDownRight, CreditCard, Landmark, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";
export function TransactionList() {
const [selectedTx, setSelectedTx] = useState<Transaction | null>(null);
const [open, setOpen] = useState(false);
// Group transactions by date
const groupedTransactions = mockTransactions.reduce((groups, tx) => {
const date = new Date(tx.date);
let key = format(date, 'yyyy-MM-dd');
if (isToday(date)) key = 'Today';
else if (isYesterday(date)) key = 'Yesterday';
else key = format(date, 'MMMM dd, yyyy');
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(tx);
return groups;
}, {} as Record<string, Transaction[]>);
const handleRowClick = (tx: Transaction) => {
setSelectedTx(tx);
setOpen(true);
};
const getMethodIcon = (method: Transaction['method']) => {
switch (method) {
case 'Stripe': return <CreditCard className="h-4 w-4" />;
case 'Bank Transfer': return <Landmark className="h-4 w-4" />;
case 'Razorpay': return <Wallet className="h-4 w-4" />;
default: return <Wallet className="h-4 w-4" />;
}
};
return (
<div className="neu-card p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold text-foreground">Recent Transactions</h2>
<button className="text-sm font-medium text-accent hover:underline">
View All
</button>
</div>
<div className="space-y-6">
{Object.entries(groupedTransactions).map(([date, transactions]) => (
<div key={date}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 px-2">
{date}
</h3>
<div className="space-y-2">
{transactions.map((tx) => (
<div
key={tx.id}
onClick={() => handleRowClick(tx)}
className={cn(
"flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all",
"hover:bg-secondary/50 border border-transparent hover:border-border/50",
tx.type === 'out' ? "bg-error/5 hover:bg-error/10" : "bg-card"
)}
>
<div className="flex items-center gap-4">
<div className={cn(
"h-10 w-10 rounded-full flex items-center justify-center shrink-0",
tx.type === 'in' ? "bg-success/10 text-success" : "bg-error/10 text-error"
)}>
{tx.type === 'in' ? <ArrowUpRight className="h-5 w-5" /> : <ArrowDownRight className="h-5 w-5" />}
</div>
<div className="min-w-[120px]">
<p className="font-medium text-sm text-foreground">{tx.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span className="flex items-center gap-1">
{getMethodIcon(tx.method)}
{tx.method}
</span>
<span></span>
<span>{format(new Date(tx.date), 'hh:mm a')}</span>
</div>
</div>
</div>
<div className="text-right">
<p className={cn(
"font-bold text-sm",
tx.type === 'in' ? "text-success" : "text-error"
)}>
{tx.type === 'in' ? '+' : '-'}{tx.amount.toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}
</p>
<p className="text-xs text-muted-foreground">
{tx.status}
</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
<TransactionDetailsSheet
transaction={selectedTx}
open={open}
onOpenChange={setOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,303 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AppLayout } from '@/components/layout/AppLayout';
import { mockPartners } from '@/data/mockPartnerData';
import { mockPartnerEvents } from '@/data/mockPartnerData';
import { Partner, getRiskLevel } from '@/types/partner';
import { StatusBadge, TypeBadge } from './components/PartnerBadges';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Search,
Plus,
MoreHorizontal,
Eye,
ShieldCheck,
Ban,
UserCheck,
AlertTriangle,
TrendingUp,
Users,
Clock,
CalendarPlus,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { subDays } from 'date-fns';
function RiskGauge({ score }: { score: number }) {
const level = getRiskLevel(score);
const color =
level === 'low' ? 'text-success' :
level === 'medium' ? 'text-warning' :
'text-destructive';
const bg =
level === 'low' ? 'bg-success/10' :
level === 'medium' ? 'bg-warning/10' :
'bg-destructive/10';
return (
<div className="flex items-center gap-2">
<div className={cn('h-2 w-2 rounded-full', color.replace('text-', 'bg-'))} />
<span className={cn('text-xs font-semibold px-1.5 py-0.5 rounded', bg, color)}>
{score}
</span>
</div>
);
}
function VerificationBadge({ status }: { status: Partner['verificationStatus'] }) {
if (status === 'Verified') return (
<Badge variant="outline" className="bg-success/10 text-success border-success/20 gap-1 text-xs">
<ShieldCheck className="h-3 w-3" /> Verified
</Badge>
);
if (status === 'Rejected') return (
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20 gap-1 text-xs">
<Ban className="h-3 w-3" /> Rejected
</Badge>
);
return (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 gap-1 text-xs">
<Clock className="h-3 w-3" /> Pending
</Badge>
);
}
export default function PartnerDirectory() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState('all');
const oneWeekAgo = subDays(new Date(), 7);
// Count pending events per partner
const pendingEventsMap = useMemo(() => {
const map: Record<string, number> = {};
mockPartnerEvents.filter(e => e.status === 'PENDING_REVIEW').forEach(e => {
map[e.partnerId] = (map[e.partnerId] || 0) + 1;
});
return map;
}, []);
const filteredPartners = useMemo(() => {
let partners = [...mockPartners];
// Search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
partners = partners.filter(p =>
p.name.toLowerCase().includes(q) ||
p.primaryContact.name.toLowerCase().includes(q) ||
p.primaryContact.email.toLowerCase().includes(q)
);
}
// Tabs
switch (activeTab) {
case 'pending_kyc':
partners = partners.filter(p => p.verificationStatus === 'Pending');
break;
case 'high_risk':
partners = partners.filter(p => p.riskScore > 60);
break;
case 'new_this_week':
partners = partners.filter(p => new Date(p.joinedAt) >= oneWeekAgo);
break;
}
return partners;
}, [searchQuery, activeTab, oneWeekAgo]);
const stats = useMemo(() => ({
total: mockPartners.length,
active: mockPartners.filter(p => p.status === 'Active').length,
pendingKYC: mockPartners.filter(p => p.verificationStatus === 'Pending').length,
highRisk: mockPartners.filter(p => p.riskScore > 60).length,
}), []);
return (
<AppLayout title="Partners">
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<Users className="h-3.5 w-3.5" /> Total Partners
</div>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<UserCheck className="h-3.5 w-3.5" /> Active
</div>
<p className="text-2xl font-bold text-success">{stats.active}</p>
</div>
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<Clock className="h-3.5 w-3.5" /> Pending KYC
</div>
<p className="text-2xl font-bold text-warning">{stats.pendingKYC}</p>
</div>
<div className="neu-card p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<AlertTriangle className="h-3.5 w-3.5" /> High Risk
</div>
<p className="text-2xl font-bold text-destructive">{stats.highRisk}</p>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name, email, or contact..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button className="gap-2 shrink-0">
<Plus className="h-4 w-4" /> Add Partner
</Button>
</div>
{/* Tabs + Table */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="all" className="gap-1.5">
All <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1">{mockPartners.length}</Badge>
</TabsTrigger>
<TabsTrigger value="pending_kyc" className="gap-1.5">
Pending KYC <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1 bg-warning/10 text-warning">{stats.pendingKYC}</Badge>
</TabsTrigger>
<TabsTrigger value="high_risk" className="gap-1.5">
High Risk <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1 bg-destructive/10 text-destructive">{stats.highRisk}</Badge>
</TabsTrigger>
<TabsTrigger value="new_this_week" className="gap-1.5">
<CalendarPlus className="h-3.5 w-3.5" /> New This Week
</TabsTrigger>
</TabsList>
{/* Shared table for all tabs */}
<div className="neu-card overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Partner</TableHead>
<TableHead>Verification</TableHead>
<TableHead className="text-center">Active Events</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-center">Risk Score</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{filteredPartners.length > 0 ? (
filteredPartners.map(partner => (
<TableRow
key={partner.id}
className="cursor-pointer hover:bg-secondary/30"
onClick={() => navigate(`/partners/${partner.id}`)}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-secondary flex items-center justify-center overflow-hidden border border-border/50 shrink-0">
{partner.logo ? (
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
) : (
<span className="text-xs font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
)}
</div>
<div>
<p className="font-medium text-sm">{partner.name}</p>
<p className="text-xs text-muted-foreground">{partner.primaryContact.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<VerificationBadge status={partner.verificationStatus} />
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<span className="font-medium">{partner.metrics.eventsCount}</span>
{(pendingEventsMap[partner.id] || 0) > 0 && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 text-[9px] h-4 px-1">
{pendingEventsMap[partner.id]} pending
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">
{partner.metrics.totalRevenue.toLocaleString()}
</TableCell>
<TableCell>
<div className="flex justify-center">
<RiskGauge score={partner.riskScore} />
</div>
</TableCell>
<TableCell>
<StatusBadge status={partner.status} />
</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/partners/${partner.id}`)}>
<Eye className="h-4 w-4 mr-2" /> View Details
</DropdownMenuItem>
<DropdownMenuItem>
<TrendingUp className="h-4 w-4 mr-2" /> View Events
</DropdownMenuItem>
<DropdownMenuSeparator />
{partner.status === 'Suspended' ? (
<DropdownMenuItem className="text-success">
<UserCheck className="h-4 w-4 mr-2" /> Revoke Suspension
</DropdownMenuItem>
) : (
<DropdownMenuItem className="text-destructive">
<Ban className="h-4 w-4 mr-2" /> Suspend Partner
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<Users className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">No partners found</p>
<p className="text-muted-foreground text-xs mt-1">Try adjusting your search or filters</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Tabs>
</AppLayout>
);
}

View File

@@ -0,0 +1,397 @@
import { useParams, useNavigate } from 'react-router-dom';
import { AppLayout } from '@/components/layout/AppLayout';
import { mockPartners, mockDealTerms, mockLedger, mockKYCDocuments, mockPartnerEvents } from '@/data/mockPartnerData';
import { getRiskLevel } from '@/types/partner';
import { StatusBadge, TypeBadge } from './components/PartnerBadges';
import { KYCVaultPanel } from './components/KYCVaultPanel';
import { EventApprovalQueue } from './components/EventApprovalQueue';
import { ImpersonationDialog } from './components/ImpersonationDialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Mail,
Phone,
ArrowLeft,
LogIn,
KeyRound,
ShieldOff,
Ban,
UserCheck,
Calendar,
Wallet,
TrendingUp,
AlertTriangle,
ExternalLink,
FileSignature,
DollarSign,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
resetPartner2FA,
resetPartnerPassword,
suspendPartner,
unsuspendPartner,
} from '@/lib/actions/partner-governance';
import { useState } from 'react';
export default function PartnerProfile() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const partner = mockPartners.find(p => p.id === id);
const [partnerStatus, setPartnerStatus] = useState(partner?.status || 'Active');
if (!partner) {
return (
<AppLayout title="Partner Not Found">
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-muted-foreground mb-4">Partner not found.</p>
<Button onClick={() => navigate('/partners')}>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Partners
</Button>
</div>
</AppLayout>
);
}
const kycDocs = mockKYCDocuments.filter(d => d.partnerId === partner.id);
const partnerEvents = mockPartnerEvents.filter(e => e.partnerId === partner.id);
const dealTerms = mockDealTerms.filter(d => d.partnerId === partner.id);
const ledger = mockLedger.filter(l => l.partnerId === partner.id);
const riskLevel = getRiskLevel(partner.riskScore);
const handleReset2FA = async () => {
const result = await resetPartner2FA(partner.id);
if (result.success) toast.success(result.message);
else toast.error(result.message);
};
const handleResetPassword = async () => {
const result = await resetPartnerPassword(partner.id);
if (result.success) toast.success(result.message);
else toast.error(result.message);
};
const handleSuspend = async () => {
const result = await suspendPartner(partner.id, 'Suspended by admin from profile page');
if (result.success) {
toast.success(result.message);
setPartnerStatus('Suspended');
} else {
toast.error(result.message);
}
};
const handleUnsuspend = async () => {
const result = await unsuspendPartner(partner.id);
if (result.success) {
toast.success(result.message);
setPartnerStatus('Active');
} else {
toast.error(result.message);
}
};
return (
<AppLayout title={partner.name}>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Button variant="ghost" size="icon" onClick={() => navigate('/partners')} className="shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="h-12 w-12 rounded-xl bg-secondary flex items-center justify-center overflow-hidden border border-border/50 shrink-0">
{partner.logo ? (
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
) : (
<span className="text-lg font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
)}
</div>
<div className="min-w-0">
<h1 className="text-xl font-bold truncate">{partner.name}</h1>
<div className="flex items-center gap-2 mt-0.5">
<TypeBadge type={partner.type} />
<StatusBadge status={partnerStatus as any} />
</div>
</div>
</div>
</div>
{/* 3-Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Column 1: Identity & Stats ───────────────────────────── */}
<div className="space-y-4">
{/* Contact Card */}
<div className="neu-card p-5 space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Contact</h3>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-xs font-bold text-primary">{partner.primaryContact.name.substring(0, 2)}</span>
</div>
<div>
<p className="text-sm font-medium">{partner.primaryContact.name}</p>
<p className="text-xs text-muted-foreground">{partner.primaryContact.role || 'Contact'}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3.5 w-3.5" /> {partner.primaryContact.email}
</div>
{partner.primaryContact.phone && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="h-3.5 w-3.5" /> {partner.primaryContact.phone}
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-3.5 w-3.5" /> Joined {new Date(partner.joinedAt).toLocaleDateString()}
</div>
</div>
</div>
{/* Quick Stats */}
<div className="neu-card p-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Stats</h3>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
<p className="text-xs text-muted-foreground flex items-center gap-1"><Wallet className="h-3 w-3" /> Revenue</p>
<p className="font-bold text-lg mt-1">{partner.metrics.totalRevenue.toLocaleString()}</p>
</div>
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
<p className="text-xs text-muted-foreground flex items-center gap-1"><TrendingUp className="h-3 w-3" /> Events</p>
<p className="font-bold text-lg mt-1">{partner.metrics.eventsCount}</p>
</div>
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
<p className="text-xs text-muted-foreground flex items-center gap-1"><DollarSign className="h-3 w-3" /> Open Bal.</p>
<p className="font-bold text-lg mt-1">{partner.metrics.openBalance.toLocaleString()}</p>
</div>
<div className={cn(
'p-3 rounded-lg border',
riskLevel === 'low' ? 'bg-success/5 border-success/20' :
riskLevel === 'medium' ? 'bg-warning/5 border-warning/20' :
'bg-destructive/5 border-destructive/20'
)}>
<p className="text-xs text-muted-foreground flex items-center gap-1"><AlertTriangle className="h-3 w-3" /> Risk</p>
<p className={cn(
'font-bold text-lg mt-1',
riskLevel === 'low' ? 'text-success' :
riskLevel === 'medium' ? 'text-warning' :
'text-destructive'
)}>{partner.riskScore}</p>
</div>
</div>
</div>
{/* Admin Actions */}
<div className="neu-card p-5 space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Admin Actions</h3>
<ImpersonationDialog partnerId={partner.id} partnerName={partner.name}>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
<LogIn className="h-4 w-4 text-warning" /> Login as Partner
<ExternalLink className="h-3 w-3 ml-auto text-muted-foreground" />
</Button>
</ImpersonationDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
<KeyRound className="h-4 w-4 text-blue-400" /> Reset Password
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset Password</AlertDialogTitle>
<AlertDialogDescription>
This will send a password reset email to {partner.primaryContact.email}. This action is logged.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleResetPassword}>Send Reset Email</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
<ShieldOff className="h-4 w-4 text-orange-400" /> Reset 2FA
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset Two-Factor Authentication</AlertDialogTitle>
<AlertDialogDescription>
This will remove {partner.name}'s 2FA setup. They will be required to re-enroll on their next login. This action is logged.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleReset2FA}>Reset 2FA</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{partnerStatus === 'Suspended' ? (
<Button
variant="outline"
className="w-full justify-start gap-2 h-9 text-sm text-success border-success/30 hover:bg-success/10"
onClick={handleUnsuspend}
>
<UserCheck className="h-4 w-4" /> Revoke Suspension
</Button>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm text-destructive border-destructive/30 hover:bg-destructive/10">
<Ban className="h-4 w-4" /> Suspend Partner
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Suspend Partner</AlertDialogTitle>
<AlertDialogDescription>
This will suspend {partner.name}'s account. They will be unable to access their dashboard or manage events. This action is logged.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSuspend} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Suspend
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Deal Terms & Finance Accordion */}
<Accordion type="multiple" className="neu-card overflow-hidden">
<AccordionItem value="deals" className="border-b-0 px-5">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
<span className="flex items-center gap-2">
<FileSignature className="h-4 w-4 text-muted-foreground" /> Deal Terms
<Badge variant="secondary" className="text-[10px] h-4 px-1">{dealTerms.length}</Badge>
</span>
</AccordionTrigger>
<AccordionContent>
{dealTerms.length > 0 ? (
<div className="space-y-2">
{dealTerms.map(dt => (
<div key={dt.id} className="p-3 rounded-lg bg-secondary/20 border border-border/30">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{dt.name}</p>
<Badge variant="outline" className={cn('text-[10px]',
dt.status === 'Active' ? 'bg-success/10 text-success border-success/20' :
dt.status === 'Draft' ? 'bg-muted text-muted-foreground' :
'bg-warning/10 text-warning border-warning/20'
)}>
{dt.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
{dt.type} {dt.params.percentage ? `${dt.params.percentage}%` : `${dt.params.amount}`}
</p>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground text-center py-3">No deals configured</p>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="finance" className="border-b-0 px-5">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
<span className="flex items-center gap-2">
<Wallet className="h-4 w-4 text-muted-foreground" /> Finance Ledger
<Badge variant="secondary" className="text-[10px] h-4 px-1">{ledger.length}</Badge>
</span>
</AccordionTrigger>
<AccordionContent>
{ledger.length > 0 ? (
<div className="space-y-2">
{ledger.map(entry => (
<div key={entry.id} className="p-3 rounded-lg bg-secondary/20 border border-border/30 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{entry.description}</p>
<p className="text-xs text-muted-foreground">{new Date(entry.createdAt).toLocaleDateString()} {entry.type}</p>
</div>
<p className={cn('font-semibold text-sm', entry.amount >= 0 ? 'text-success' : 'text-destructive')}>
{entry.amount >= 0 ? '+' : ''}{Math.abs(entry.amount).toLocaleString()}
</p>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground text-center py-3">No ledger entries</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* ── Column 2: KYC Vault ──────────────────────────────────── */}
<div className="neu-card p-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
KYC & Compliance
</h3>
<KYCVaultPanel
partnerId={partner.id}
partnerName={partner.name}
verificationStatus={partner.verificationStatus}
documents={kycDocs}
/>
</div>
{/* ── Column 3: Event Governance ──────────────────────────── */}
<div className="neu-card p-5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Event Governance
</h3>
<EventApprovalQueue
partnerId={partner.id}
events={partnerEvents}
/>
</div>
</div>
{/* Tags */}
{partner.tags && partner.tags.length > 0 && (
<div className="mt-6 flex items-center gap-2 flex-wrap">
{partner.tags.map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
{/* Notes */}
{partner.notes && (
<div className="mt-4 p-4 bg-warning/5 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> Notes
</p>
<p className="text-sm text-muted-foreground mt-1">{partner.notes}</p>
</div>
)}
</AppLayout>
);
}

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