feat: add Review Management module and UI layout fixes
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
216
.lovable/plan.md
Normal 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
159
API_AND_DB_SPEC.md
Normal 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
270
MASTER_API_INVENTORY.md
Normal 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
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Eventify Command Center 🚀
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
20
components.json
Normal file
20
components.json
Normal 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
26
eslint.config.js
Normal 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
28
index.html
Normal 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
27
nginx_admin_config
Normal 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
8347
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
92
package.json
Normal file
92
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
217
prisma/schema.prisma
Normal file
217
prisma/schema.prisma
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
1
public/placeholder.svg
Normal file
1
public/placeholder.svg
Normal 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
14
public/robots.txt
Normal 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
42
src/App.css
Normal 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
141
src/App.tsx
Normal 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;
|
||||||
28
src/components/NavLink.tsx
Normal file
28
src/components/NavLink.tsx
Normal 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 };
|
||||||
27
src/components/auth/ProtectedRoute.tsx
Normal file
27
src/components/auth/ProtectedRoute.tsx
Normal 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}</>;
|
||||||
|
};
|
||||||
86
src/components/dashboard/ActionItemsPanel.tsx
Normal file
86
src/components/dashboard/ActionItemsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/dashboard/ActivityFeed.tsx
Normal file
85
src/components/dashboard/ActivityFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/dashboard/DashboardMetricCard.tsx
Normal file
64
src/components/dashboard/DashboardMetricCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/dashboard/RevenueChart.tsx
Normal file
76
src/components/dashboard/RevenueChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/layout/AppLayout.tsx
Normal file
23
src/components/layout/AppLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/layout/Sidebar.tsx
Normal file
98
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/layout/TopBar.tsx
Normal file
93
src/components/layout/TopBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
273
src/components/notifications/NotificationPopover.tsx
Normal file
273
src/components/notifications/NotificationPopover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/ui/PageLoader.tsx
Normal file
74
src/components/ui/PageLoader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/ui/accordion.tsx
Normal file
52
src/components/ui/accordion.tsx
Normal 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 };
|
||||||
104
src/components/ui/alert-dialog.tsx
Normal file
104
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
43
src/components/ui/alert.tsx
Normal file
43
src/components/ui/alert.tsx
Normal 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 };
|
||||||
5
src/components/ui/aspect-ratio.tsx
Normal file
5
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root;
|
||||||
|
|
||||||
|
export { AspectRatio };
|
||||||
38
src/components/ui/avatar.tsx
Normal file
38
src/components/ui/avatar.tsx
Normal 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 };
|
||||||
29
src/components/ui/badge.tsx
Normal file
29
src/components/ui/badge.tsx
Normal 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 };
|
||||||
90
src/components/ui/breadcrumb.tsx
Normal file
90
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||||
|
};
|
||||||
47
src/components/ui/button.tsx
Normal file
47
src/components/ui/button.tsx
Normal 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 };
|
||||||
54
src/components/ui/calendar.tsx
Normal file
54
src/components/ui/calendar.tsx
Normal 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 };
|
||||||
43
src/components/ui/card.tsx
Normal file
43
src/components/ui/card.tsx
Normal 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 };
|
||||||
224
src/components/ui/carousel.tsx
Normal file
224
src/components/ui/carousel.tsx
Normal 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
303
src/components/ui/chart.tsx
Normal 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 };
|
||||||
26
src/components/ui/checkbox.tsx
Normal file
26
src/components/ui/checkbox.tsx
Normal 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 };
|
||||||
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal 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 };
|
||||||
132
src/components/ui/command.tsx
Normal file
132
src/components/ui/command.tsx
Normal 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,
|
||||||
|
};
|
||||||
178
src/components/ui/context-menu.tsx
Normal file
178
src/components/ui/context-menu.tsx
Normal 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,
|
||||||
|
};
|
||||||
95
src/components/ui/dialog.tsx
Normal file
95
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
87
src/components/ui/drawer.tsx
Normal file
87
src/components/ui/drawer.tsx
Normal 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,
|
||||||
|
};
|
||||||
179
src/components/ui/dropdown-menu.tsx
Normal file
179
src/components/ui/dropdown-menu.tsx
Normal 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
129
src/components/ui/form.tsx
Normal 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 };
|
||||||
27
src/components/ui/hover-card.tsx
Normal file
27
src/components/ui/hover-card.tsx
Normal 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 };
|
||||||
61
src/components/ui/input-otp.tsx
Normal file
61
src/components/ui/input-otp.tsx
Normal 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 };
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 };
|
||||||
17
src/components/ui/label.tsx
Normal file
17
src/components/ui/label.tsx
Normal 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 };
|
||||||
207
src/components/ui/menubar.tsx
Normal file
207
src/components/ui/menubar.tsx
Normal 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,
|
||||||
|
};
|
||||||
120
src/components/ui/navigation-menu.tsx
Normal file
120
src/components/ui/navigation-menu.tsx
Normal 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,
|
||||||
|
};
|
||||||
81
src/components/ui/pagination.tsx
Normal file
81
src/components/ui/pagination.tsx
Normal 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,
|
||||||
|
};
|
||||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal 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 };
|
||||||
23
src/components/ui/progress.tsx
Normal file
23
src/components/ui/progress.tsx
Normal 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 };
|
||||||
36
src/components/ui/radio-group.tsx
Normal file
36
src/components/ui/radio-group.tsx
Normal 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 };
|
||||||
37
src/components/ui/resizable.tsx
Normal file
37
src/components/ui/resizable.tsx
Normal 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 };
|
||||||
38
src/components/ui/scroll-area.tsx
Normal file
38
src/components/ui/scroll-area.tsx
Normal 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 };
|
||||||
143
src/components/ui/select.tsx
Normal file
143
src/components/ui/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
20
src/components/ui/separator.tsx
Normal file
20
src/components/ui/separator.tsx
Normal 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
107
src/components/ui/sheet.tsx
Normal 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,
|
||||||
|
};
|
||||||
637
src/components/ui/sidebar.tsx
Normal file
637
src/components/ui/sidebar.tsx
Normal 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,
|
||||||
|
};
|
||||||
7
src/components/ui/skeleton.tsx
Normal file
7
src/components/ui/skeleton.tsx
Normal 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 };
|
||||||
23
src/components/ui/slider.tsx
Normal file
23
src/components/ui/slider.tsx
Normal 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 };
|
||||||
27
src/components/ui/sonner.tsx
Normal file
27
src/components/ui/sonner.tsx
Normal 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 };
|
||||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal 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 };
|
||||||
72
src/components/ui/table.tsx
Normal file
72
src/components/ui/table.tsx
Normal 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 };
|
||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal 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 };
|
||||||
21
src/components/ui/textarea.tsx
Normal file
21
src/components/ui/textarea.tsx
Normal 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
111
src/components/ui/toast.tsx
Normal 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,
|
||||||
|
};
|
||||||
24
src/components/ui/toaster.tsx
Normal file
24
src/components/ui/toaster.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ui/toggle-group.tsx
Normal file
49
src/components/ui/toggle-group.tsx
Normal 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 };
|
||||||
37
src/components/ui/toggle.tsx
Normal file
37
src/components/ui/toggle.tsx
Normal 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 };
|
||||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal 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 };
|
||||||
3
src/components/ui/use-toast.ts
Normal file
3
src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { useToast, toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
124
src/contexts/AuthContext.tsx
Normal file
124
src/contexts/AuthContext.tsx
Normal 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
216
src/data/mockData.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/data/mockFinancialData.ts
Normal file
120
src/data/mockFinancialData.ts
Normal 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
310
src/data/mockPartnerData.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
];
|
||||||
167
src/features/ad-control/components/EventPickerModal.tsx
Normal file
167
src/features/ad-control/components/EventPickerModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/features/ad-control/components/PlacementConfigDrawer.tsx
Normal file
282
src/features/ad-control/components/PlacementConfigDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
src/features/ad-control/components/PlacementList.tsx
Normal file
257
src/features/ad-control/components/PlacementList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/features/ad-control/components/SurfaceTabs.tsx
Normal file
74
src/features/ad-control/components/SurfaceTabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
src/features/ad-control/components/sponsored/CampaignWizard.tsx
Normal file
426
src/features/ad-control/components/sponsored/CampaignWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
src/features/ad-control/data/mockAdData.ts
Normal file
269
src/features/ad-control/data/mockAdData.ts
Normal 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' },
|
||||||
|
];
|
||||||
193
src/features/ad-control/data/mockAdsData.ts
Normal file
193
src/features/ad-control/data/mockAdsData.ts
Normal 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();
|
||||||
579
src/features/events/components/CreateEventSheet.tsx
Normal file
579
src/features/events/components/CreateEventSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/features/financials/components/SettlementTable.tsx
Normal file
116
src/features/financials/components/SettlementTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/features/financials/components/TaxSettingsSheet.tsx
Normal file
172
src/features/financials/components/TaxSettingsSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/features/financials/components/TransactionList.tsx
Normal file
114
src/features/financials/components/TransactionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/features/partners/PartnerDirectory.tsx
Normal file
303
src/features/partners/PartnerDirectory.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
src/features/partners/PartnerProfile.tsx
Normal file
397
src/features/partners/PartnerProfile.tsx
Normal 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
Reference in New Issue
Block a user