Compare commits

...

10 Commits

Author SHA1 Message Date
Vivek P Prakash
9cff4344d0 feat: integrate real backend API with centralized API client
- Add centralized apiPost() client (src/services/api.ts) that handles
  auth injection, error handling, and token expiry uniformly
- Implement real authentication against /accounts/api/login/ and /logout/
- Rewrite partnerApi and userAppApi to use centralized client
- Connect Partners page to /partner/list/ and /partner/create/ APIs
- Add Vite proxy rules for /accounts/api and /partner endpoints
- Update AuthUser type to match full backend user response
- Move NuqsAdapter inside BrowserRouter for correct routing context

Made-with: Cursor
2026-03-15 00:25:55 +05:30
457004a0ef docs: beautify README and add ASCII art 2026-03-07 12:15:28 +05:30
c025c18a7f merge: reconcile with remote main and add Review Management 2026-03-07 11:58:45 +05:30
1da66c1be5 feat: add Review Management module and UI layout fixes 2026-03-07 11:55:18 +05:30
CycroftX
49770dfe73 feat: Partner Command Center Module
- Extend types/partner.ts: riskScore, KYCDocument, PartnerEvent, RiskLevel
- Extend mockPartnerData.ts: risk scores, 15 KYC docs, 9 partner events, 6th partner
- Create lib/actions/partner-governance.ts: KYC verification, event approval, impersonation, 2FA/password reset, suspend/unsuspend
- Rewrite PartnerDirectory.tsx: card grid → data table with stats, risk gauge, filter tabs
- Rewrite PartnerProfile.tsx: tabs → 3-column layout (Identity | KYC Vault | Event Governance)
- Create KYCVaultPanel.tsx: per-doc approve/reject with progress bar and auto-verification
- Create EventApprovalQueue.tsx: pending events list with review dialog
- Create ImpersonationDialog.tsx: audit-aware confirmation with token generation
- Extend prisma/schema.prisma: PartnerProfile, PartnerDoc models, KYC/Event enums
- Add partner governance permission scopes to staff.ts
2026-02-11 10:06:30 +05:30
CycroftX
04e2db6571 feat: implement sponsored ads module end-to-end 2026-02-10 15:44:35 +05:30
CycroftX
3e1641d281 feat: Ad Control module — surfaces, placements, drag-and-drop reorder, event picker, targeting 2026-02-10 15:06:58 +05:30
CycroftX
2cfefc17dc feat: Bulk Actions floating bar, suspend/tag dialogs, dedicated server actions 2026-02-10 12:37:48 +05:30
CycroftX
f180b3d7d2 feat: Add Staff & Access Management Module with RBAC 2026-02-10 12:04:44 +05:30
CycroftX
0c8593ef22 feat: Add Multi-Gateway Configuration Module with Payment Settings Tab 2026-02-10 11:35:23 +05:30
83 changed files with 11927 additions and 1214 deletions

3
.gitignore vendored
View File

@@ -11,6 +11,9 @@ node_modules
dist
dist-ssr
*.local
.env
.env.*
.npm-cache/
# Editor directories and files
.vscode/*

270
MASTER_API_INVENTORY.md Normal file
View File

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

166
README.md
View File

@@ -1,142 +1,122 @@
```text
███████╗██╗ ██╗███████╗███╗ ██╗████████╗██╗███████╗██╗ ██╗
██╔════╝██║ ██║██╔════╝████╗ ██║╚══██╔══╝██║██╔════╝╚██╗ ██╔╝
█████╗ ██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║█████╗ ╚████╔╝
██╔══╝ ╚██╗ ██╔╝██╔══╝ ██║╚██╗██║ ██║ ██║██╔══╝ ╚██╔╝
███████╗ ╚████╔╝ ███████╗██║ ╚████║ ██║ ██║██║ ██║
╚══════╝ ╚═══╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═╝
COMMAND CENTER
```
# Eventify Command Center 🚀
![Status](https://img.shields.io/badge/Status-Active-success)
![Version](https://img.shields.io/badge/Version-1.0.0-blue)
![Tech](https://img.shields.io/badge/Built%20With-Vite%20%7C%20React%20%7C%20Shadcn-purple)
![Version](https://img.shields.io/badge/Version-1.1.0-blue)
![Stack](https://img.shields.io/badge/Stack-React%20%7C%20Vite%20%7C%20Tailwind%204%20%7C%20Shadcn-purple)
![License](https://img.shields.io/badge/License-Proprietary-red)
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.
The **Eventify Command Center** is the premium, high-performance administrative cockpit for the Eventify platform. Built with a sophisticated **Neobrutalism Lite** design language, it provides deep control over user management, event moderation, and platform feedback.
---
## 📸 Overview
## ✨ Primary Features
The Command Center is built to be an "Operating System for Events", offering high-density information displays and quick actions.
### 📨 Review Management (New!)
* **Inbox Zero Flow**: Moderate pending reviews with one-click Approve/Reject actions.
* **Smart Editing**: Edit review text in real-time via a smooth slide-over drawer with reviewer context.
* **Metrics Bar**: Live tracking of Pending (notification badges), Live, and Rejected counts.
* **Safety First**: Required reason selection for rejections and deletions to ensure audit trail integrity.
### 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.
### 👥 User Management (CRM)
* **360° Inspector**: Comprehensive view of user profiles, booking history, and platform activity.
* **Account Governance**: Suspend, Ban, or verify users with custom audit logging.
* **Smart Filtering**: Instant URL-based filtering for high-density user tables.
### 📅 Event Presence
* **Global Events List**: Track and manage all hosted events.
* **Ad Control**: Integrated Sponsored Ads management for boosting platform visibility.
### 📊 Real-time Monitoring
* **Premium Analytics**: High-level metrics for sales and user engagement.
* **Status Indicators**: Integrated platform uptime monitoring.
---
## 🏗 Architecture
## 🏗 High-Level Architecture
The application follows a **Feature-Based Architecture** ensuring scalability and maintainability.
The system utilizes a **Feature-Driven Architecture** decoupled from core pages for maximum modularity.
```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]
UI[ECC Dashboard UI] -->|Nuqs State| URL[URL State Management]
UI -->|Actions| Lib[Lib / Features]
Lib -->|TypeScript| Types[Review/User Types]
Lib -->|Mock Data| Data[Mock Services]
subgraph UI Layer
Client
Components[Shadcn UI Components]
subgraph Design System
NB[Neobrutalism Lite]
NM[Neumorphic Utilities]
end
subgraph Logic Layer
Actions
Hooks[Custom Hooks / Nuqs]
end
subgraph Data Layer
Service
Types[TypeScript Interfaces]
subgraph Feature Modules
RM[Review Management]
UM[User Management]
AC[Ad Control]
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`
### Technical Specs
* **Framework**: [Vite](https://vitejs.dev/) + React 18
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + Custom HSL Variables
* **Typography**: `Inter` (Body) + `Martian Mono` (Badges/Data)
* **State Management**: `nuqs` (Search Param Persistence) + React Context
* **Integrations**: `lucide-react` icons, `sonner` toasts, `radix-ui` primitives
---
## 🚀 Getting Started
## 🚀 Development & Deployment
### Prerequisites
- Node.js 18+
- npm 9+
### Installation
1. **Clone the repository**
### Local Setup
1. **Clone & Enter**
```bash
git clone https://code.bshtech.net/Sicherhaven/eventify-command-center.git
cd eventify-command-center
```
2. **Install dependencies**
2. **Initialize**
```bash
npm install
```
3. **Run Development Server**
3. **Ignite**
```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`:
### Production Deployment (`sicherh`)
The application is hosted on our internal **Sicherh** infrastructure.
```bash
# SSH into the server
# Push latest changes
git push origin main
# Update Production
ssh sicherh
# Navigate and Pull
cd eventify-command-center
git pull
# Build and Restart
npm install
cd /root/eventify-command-center
git pull origin main
npm run build
pm2 restart next-server
cp -rv dist/* /var/www/admin.prototype.eventifyplus.com/
```
---
## 🛡 Security & Permissions
## 📂 Design Tokens
- **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.
| Property | Value | Utility |
|----------|-------|---------|
| Primary | `#1E3A8A` | `bg-primary` |
| Accent | `#10B981` | `text-emerald-500` (Approval) |
| Feedback | `#EF4444` | `text-red-500` (Rejection) |
| Shadow | `3px 3px 0px 0px` | `shadow-neu` (Neobrutalism) |
---
> Built with ❤️ by **BSH Technologies** for the Eventify Platform.
> Built and Managed by **BSH Technologies** for the **Eventify Platform**.

BIN
bun.lockb

Binary file not shown.

217
prisma/schema.prisma Normal file
View File

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

View File

@@ -8,12 +8,17 @@ 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 Partners from "./pages/Partners";
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();
@@ -40,7 +45,7 @@ const App = () => (
path="/partners"
element={
<ProtectedRoute>
<PartnerDirectory />
<Partners />
</ProtectedRoute>
}
/>
@@ -68,6 +73,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="/ad-control"
element={
<ProtectedRoute>
<AdControl />
</ProtectedRoute>
}
/>
<Route
path="/financials"
element={
@@ -84,6 +97,38 @@ const App = () => (
</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>

View File

@@ -1,12 +1,15 @@
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Users,
Calendar,
User,
DollarSign,
import {
LayoutDashboard,
Users,
Calendar,
User,
DollarSign,
Settings,
Ticket
Ticket,
Megaphone,
Zap,
MessageSquareText
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -14,10 +17,13 @@ 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();
@@ -38,17 +44,17 @@ export function Sidebar() {
{/* Navigation */}
<nav className="flex flex-col gap-2 p-4">
{navItems.map((item) => {
const isActive = location.pathname === item.href ||
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"
isActive
? "neu-button-active"
: "neu-button hover:shadow-neu-lg"
)}
>
@@ -57,11 +63,21 @@ export function Sidebar() {
isActive ? "text-primary-foreground" : "text-muted-foreground"
)} />
<span className={cn(
"font-medium transition-colors",
"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>
);
})}

View File

@@ -72,7 +72,7 @@ export function TopBar({ title, description }: TopBarProps) {
</div>
<div className="text-left">
<p className="text-sm font-medium text-foreground">{getDisplayName()}</p>
<p className="text-xs text-muted-foreground">Admin</p>
<p className="text-xs text-muted-foreground capitalize">{user?.role || 'Admin'}</p>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>

View File

@@ -32,7 +32,7 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@@ -59,24 +59,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setIsLoading(true);
const response = await authLogin(username, password);
// Store auth data
storeAuth(response);
// Set user state
const authUser: AuthUser = {
username: response.username,
...response.user,
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}!`,
description: `Welcome back, ${response.user.first_name || username}!`,
});
} catch (error) {
console.error('Login error:', error);
@@ -97,7 +90,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const logout = async () => {
try {
if (user) {
await authLogout(user.username, user.token);
await authLogout();
}
} catch (error) {
console.error('Logout error:', error);

View File

@@ -1,5 +1,5 @@
import { Partner, DealTerm, LedgerEntry, PartnerDocument } from '../types/partner';
import { subDays, subMonths } from 'date-fns';
import { Partner, DealTerm, LedgerEntry, PartnerDocument, KYCDocument, PartnerEvent } from '../types/partner';
import { subDays, subMonths, subHours, addDays } from 'date-fns';
export const mockPartners: Partner[] = [
{
@@ -23,6 +23,8 @@ export const mockPartners: Partner[] = [
},
tags: ['Premium', 'Indoor', 'Capacity: 5000'],
joinedAt: subMonths(new Date(), 6).toISOString(),
verificationStatus: 'Verified',
riskScore: 12,
},
{
id: 'p2',
@@ -44,6 +46,8 @@ export const mockPartners: Partner[] = [
},
tags: ['Influencer Network', 'Social Media'],
joinedAt: subMonths(new Date(), 3).toISOString(),
verificationStatus: 'Verified',
riskScore: 45,
},
{
id: 'p3',
@@ -66,12 +70,14 @@ export const mockPartners: Partner[] = [
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: 'Invited',
status: 'Active',
logo: 'https://ui-avatars.com/api/?name=Global+Sponsors&background=10B981&color=fff',
primaryContact: {
name: 'Jessica Pearson',
@@ -79,15 +85,16 @@ export const mockPartners: Partner[] = [
role: 'Brand Director',
},
metrics: {
activeDeals: 0,
totalRevenue: 0,
activeDeals: 3,
totalRevenue: 2200000,
openBalance: 0,
lastActivity: subDays(new Date(), 5).toISOString(),
eventsCount: 0,
eventsCount: 6,
},
tags: ['Corporate', 'High Value'],
joinedAt: subDays(new Date(), 5).toISOString(),
verificationStatus: 'Verified',
riskScore: 8,
},
{
id: 'p5',
@@ -98,7 +105,7 @@ export const mockPartners: Partner[] = [
primaryContact: {
name: 'John Doe',
email: 'john@newage.com',
role: 'Owner'
role: 'Owner',
},
metrics: {
activeDeals: 0,
@@ -110,9 +117,76 @@ export const mockPartners: Partner[] = [
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',
@@ -140,9 +214,10 @@ export const mockDealTerms: DealTerm[] = [
effectiveFrom: subMonths(new Date(), 3).toISOString(),
status: 'Active',
version: 2,
}
},
];
// ── Legacy Ledger ───────────────────────────────────────────────────
export const mockLedger: LedgerEntry[] = [
{
id: 'le1',
@@ -176,9 +251,10 @@ export const mockLedger: LedgerEntry[] = [
currency: 'INR',
createdAt: subDays(new Date(), 1).toISOString(),
status: 'Pending',
}
},
];
// ── Legacy Documents ────────────────────────────────────────────────
export const mockDocuments: PartnerDocument[] = [
{
id: 'doc1',
@@ -230,5 +306,5 @@ export const mockDocuments: PartnerDocument[] = [
status: 'Pending',
uploadedBy: 'John Doe',
uploadedAt: subDays(new Date(), 1).toISOString(),
}
},
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,160 +1,306 @@
import { useState, useMemo } from 'react';
import { Search, Filter, Plus, Check } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { AppLayout } from '@/components/layout/AppLayout';
import { PartnerCard } from './components/PartnerCard';
import { mockPartners } from '@/data/mockPartnerData';
import { AddPartnerSheet } from './components/AddPartnerSheet';
import { Input } from '@/components/ui/input';
import { mockPartnerEvents } from '@/data/mockPartnerData';
import { Partner, getRiskLevel } from '@/types/partner';
import { StatusBadge, TypeBadge } from './components/PartnerBadges';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuItem
} from "@/components/ui/dropdown-menu";
import { cn } from '@/lib/utils'; // Assuming cn exists
} 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';
import { AddPartnerSheet } from './components/AddPartnerSheet';
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 [statusFilters, setStatusFilters] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState("all");
const [activeTab, setActiveTab] = useState('all');
const allStatuses = ['Active', 'Invited', 'Suspended'];
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 result = mockPartners;
let partners = [...mockPartners];
// 1. Filter by Tab (KYC status)
if (activeTab === 'pending_kyc') {
result = result.filter(p => p.verificationStatus === 'Pending');
}
// 2. Filter by Search
if (searchQuery) {
const lowerQuery = searchQuery.toLowerCase();
result = result.filter(partner =>
partner.name.toLowerCase().includes(lowerQuery) ||
partner.type.toLowerCase().includes(lowerQuery) ||
partner.primaryContact.name.toLowerCase().includes(lowerQuery)
// 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)
);
}
// 3. Filter by Status
if (statusFilters.length > 0) {
result = result.filter(p => statusFilters.includes(p.status));
// 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 result;
}, [mockPartners, searchQuery, statusFilters, activeTab]);
return partners;
}, [searchQuery, activeTab, oneWeekAgo]);
const toggleStatusFilter = (status: string) => {
setStatusFilters(current =>
current.includes(status)
? current.filter(s => s !== status)
: [...current, status]
);
};
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">
<div className="p-6 max-w-[1700px] mx-auto space-y-8">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Partner Management</h1>
<p className="text-muted-foreground">Manage your relationships with venues, promoters, sponsors, and vendors.</p>
{/* 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="flex flex-col sm:flex-row gap-4 justify-between items-center">
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search partners..."
className="pl-10 bg-secondary border-border/50 focus:border-accent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={cn("gap-2", statusFilters.length > 0 && "text-accent border-accent")}>
<Filter className="h-4 w-4" />
Filter {statusFilters.length > 0 && `(${statusFilters.length})`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by Status</DropdownMenuLabel>
<DropdownMenuSeparator />
{allStatuses.map(status => (
<DropdownMenuCheckboxItem
key={status}
checked={statusFilters.includes(status)}
onCheckedChange={() => toggleStatusFilter(status)}
>
{status}
</DropdownMenuCheckboxItem>
))}
{statusFilters.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setStatusFilters([])}
className="justify-center text-error font-medium"
>
Clear Filters
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AddPartnerSheet>
<Button className="gap-2 bg-accent text-white hover:bg-accent/90 shadow-lg shadow-accent/20">
<Plus className="h-4 w-4" />
Add Partner
</Button>
</AddPartnerSheet>
<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>
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
<TabsTrigger value="all">All Partners</TabsTrigger>
<TabsTrigger value="pending_kyc">
Pending KYC
{mockPartners.filter(p => p.verificationStatus === 'Pending').length > 0 && (
<span className="ml-2 bg-warning/20 text-warning px-1.5 py-0.5 rounded-full text-[10px]">
{mockPartners.filter(p => p.verificationStatus === 'Pending').length}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-6">
{/* Render grid... handled below */}
</TabsContent>
<TabsContent value="pending_kyc" className="mt-6">
{/* Render grid... handled below */}
</TabsContent>
</Tabs>
{filteredPartners.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredPartners.map(partner => (
<PartnerCard key={partner.id} partner={partner} />
))}
<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>
) : (
<div className="text-center py-20 bg-card/20 rounded-xl border border-dashed border-border/50">
<h3 className="text-lg font-medium text-foreground">No partners found</h3>
<p className="text-muted-foreground mt-2">Try adjusting your search or filters</p>
<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>
<AddPartnerSheet>
<Button className="gap-2 shrink-0">
<Plus className="h-4 w-4" /> Add Partner
</Button>
</AddPartnerSheet>
</div>
{/* Tabs + Table */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="all" className="gap-1.5">
All <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1">{mockPartners.length}</Badge>
</TabsTrigger>
<TabsTrigger value="pending_kyc" className="gap-1.5">
Pending KYC <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1 bg-warning/10 text-warning">{stats.pendingKYC}</Badge>
</TabsTrigger>
<TabsTrigger value="high_risk" className="gap-1.5">
High Risk <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1 bg-destructive/10 text-destructive">{stats.highRisk}</Badge>
</TabsTrigger>
<TabsTrigger value="new_this_week" className="gap-1.5">
<CalendarPlus className="h-3.5 w-3.5" /> New This Week
</TabsTrigger>
</TabsList>
{/* Shared table for all tabs */}
<div className="neu-card overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Partner</TableHead>
<TableHead>Verification</TableHead>
<TableHead className="text-center">Active Events</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-center">Risk Score</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{filteredPartners.length > 0 ? (
filteredPartners.map(partner => (
<TableRow
key={partner.id}
className="cursor-pointer hover:bg-secondary/30"
onClick={() => navigate(`/partners/${partner.id}`)}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-secondary flex items-center justify-center overflow-hidden border border-border/50 shrink-0">
{partner.logo ? (
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
) : (
<span className="text-xs font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
)}
</div>
<div>
<p className="font-medium text-sm">{partner.name}</p>
<p className="text-xs text-muted-foreground">{partner.primaryContact.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<VerificationBadge status={partner.verificationStatus} />
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<span className="font-medium">{partner.metrics.eventsCount}</span>
{(pendingEventsMap[partner.id] || 0) > 0 && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 text-[9px] h-4 px-1">
{pendingEventsMap[partner.id]} pending
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">
{partner.metrics.totalRevenue.toLocaleString()}
</TableCell>
<TableCell>
<div className="flex justify-center">
<RiskGauge score={partner.riskScore} />
</div>
</TableCell>
<TableCell>
<StatusBadge status={partner.status} />
</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/partners/${partner.id}`)}>
<Eye className="h-4 w-4 mr-2" /> View Details
</DropdownMenuItem>
<DropdownMenuItem>
<TrendingUp className="h-4 w-4 mr-2" /> View Events
</DropdownMenuItem>
<DropdownMenuSeparator />
{partner.status === 'Suspended' ? (
<DropdownMenuItem className="text-success">
<UserCheck className="h-4 w-4 mr-2" /> Revoke Suspension
</DropdownMenuItem>
) : (
<DropdownMenuItem className="text-destructive">
<Ban className="h-4 w-4 mr-2" /> Suspend Partner
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<Users className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">No partners found</p>
<p className="text-muted-foreground text-xs mt-1">Try adjusting your search or filters</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Tabs>
</AppLayout>
);
}

View File

@@ -1,277 +1,397 @@
import { useParams } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { AppLayout } from '@/components/layout/AppLayout';
import { mockPartners, mockDealTerms, mockLedger, mockDocuments } from '@/data/mockPartnerData';
import { mockPartners, mockDealTerms, mockLedger, mockKYCDocuments, mockPartnerEvents } from '@/data/mockPartnerData';
import { getRiskLevel } from '@/types/partner';
import { StatusBadge, TypeBadge } from './components/PartnerBadges';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { KYCVaultPanel } from './components/KYCVaultPanel';
import { EventApprovalQueue } from './components/EventApprovalQueue';
import { ImpersonationDialog } from './components/ImpersonationDialog';
import { Button } from '@/components/ui/button';
import { Calendar, Download, Edit, FileText, Mail, Phone, ExternalLink, Wallet } from 'lucide-react';
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 }>();
// In a real app, fetch data based on ID
const partner = mockPartners.find(p => p.id === id) || mockPartners[0];
const dealTerms = mockDealTerms.filter(dt => dt.partnerId === partner.id);
const ledger = mockLedger.filter(l => l.partnerId === partner.id);
const documents = mockDocuments.filter(d => d.partnerId === partner.id);
const navigate = useNavigate();
const partner = mockPartners.find(p => p.id === id);
if (!partner) return <div>Partner not found</div>;
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}>
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header Profile */}
<div className="relative overflow-hidden rounded-2xl bg-card border border-border/50 shadow-lg group">
<div className="absolute inset-0 bg-gradient-to-r from-accent/5 to-transparent opacity-50" />
<div className="relative p-8 flex flex-col md:flex-row gap-8 items-start">
<div className="h-28 w-28 rounded-2xl bg-secondary flex items-center justify-center overflow-hidden border-2 border-border shadow-2xl">
{partner.logo ? (
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
) : (
<span className="text-3xl font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
)}
</div>
<div className="flex-1 space-y-4">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-3">
{partner.name}
<StatusBadge status={partner.status} />
</h1>
<div className="flex items-center gap-4 mt-2 text-muted-foreground">
<TypeBadge type={partner.type} />
<span className="flex items-center gap-1 text-sm"><Calendar className="h-4 w-4" /> Joined {new Date(partner.joinedAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" className="gap-2"><Edit className="h-4 w-4" /> Edit Profile</Button>
<Button className="bg-accent text-white gap-2"><Wallet className="h-4 w-4" /> New Settlement</Button>
</div>
</div>
<div className="flex flex-wrap gap-6 pt-4 border-t border-border/30">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-secondary/50 flex items-center justify-center text-primary">
<span className="text-xs font-bold">{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}</p>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-foreground/80 bg-secondary/30 px-4 py-2 rounded-lg border border-border/30">
<a href={`mailto:${partner.primaryContact.email}`} className="flex items-center gap-2 hover:text-accent"><Mail className="h-4 w-4" /> {partner.primaryContact.email}</a>
{partner.primaryContact.phone && (
<span className="flex items-center gap-2 border-l border-border pl-4"><Phone className="h-4 w-4" /> {partner.primaryContact.phone}</span>
)}
</div>
</div>
{/* 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>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Revenue</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.totalRevenue.toLocaleString()}</p>
</div>
<div className="h-10 w-10 rounded-full bg-success/10 flex items-center justify-center text-success">
<Wallet className="h-5 w-5" />
</div>
</div>
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Open Balance</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.openBalance.toLocaleString()}</p>
</div>
<div className="h-10 w-10 rounded-full bg-warning/10 flex items-center justify-center text-warning">
<Wallet className="h-5 w-5" />
</div>
</div>
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Active Deals</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.activeDeals}</p>
</div>
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<FileText className="h-5 w-5" />
</div>
</div>
<div className="neu-card p-4 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Events</p>
<p className="text-2xl font-bold mt-1">{partner.metrics.eventsCount}</p>
</div>
<div className="h-10 w-10 rounded-full bg-accent/10 flex items-center justify-center text-accent">
<Calendar className="h-5 w-5" />
</div>
</div>
</div>
{/* Tabs Content */}
<div className="neu-card min-h-[500px]">
<Tabs defaultValue="overview" className="w-full">
<div className="border-b border-border/40 px-6 pt-4">
<TabsList className="bg-transparent h-auto p-0 gap-6">
<TabsTrigger value="overview" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Overview</TabsTrigger>
<TabsTrigger value="assignments" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Assignments</TabsTrigger>
<TabsTrigger value="terms" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Deal Terms</TabsTrigger>
<TabsTrigger value="finance" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Financials</TabsTrigger>
<TabsTrigger value="docs" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Documents</TabsTrigger>
</TabsList>
</div>
<div className="p-6">
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-2 gap-8">
<div className="space-y-4">
<h3 className="font-semibold text-lg">Partner Details</h3>
<div className="grid grid-cols-2 gap-y-4 text-sm">
<span className="text-muted-foreground">Legal Name</span>
<span>{partner.companyDetails?.legalName || partner.name}</span>
<span className="text-muted-foreground">Tax ID</span>
<span>{partner.companyDetails?.taxId || '-'}</span>
<span className="text-muted-foreground">Website</span>
<a href={partner.companyDetails?.website} target="_blank" className="text-accent hover:underline flex items-center gap-1">{partner.companyDetails?.website || '-'} <ExternalLink className="h-3 w-3" /></a>
<span className="text-muted-foreground">Address</span>
<span>{partner.companyDetails?.address || '-'}</span>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-lg">Tags & Notes</h3>
<div className="flex flex-wrap gap-2">
{partner.tags.map(tag => (
<Badge key={tag} variant="secondary" className="px-3 py-1">{tag}</Badge>
))}
</div>
<div className="p-4 bg-secondary/30 rounded-lg text-sm text-balance">
{partner.notes || "No notes added for this partner."}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="finance">
<div className="flex justify-between items-center mb-6">
<h3 className="font-semibold text-lg">Ledger & Settlements</h3>
<Button variant="outline" size="sm" className="gap-2"><Download className="h-4 w-4" /> Export CSV</Button>
</div>
<div className="border border-border/50 rounded-lg overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-secondary/50 text-muted-foreground">
<tr>
<th className="p-3">Date</th>
<th className="p-3">Description</th>
<th className="p-3">Type</th>
<th className="p-3 text-right">Amount</th>
<th className="p-3 text-center">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{ledger.map(entry => (
<tr key={entry.id} className="hover:bg-accent/5">
<td className="p-3">{new Date(entry.createdAt).toLocaleDateString()}</td>
<td className="p-3">
<div className="font-medium">{entry.description}</div>
{entry.referenceId && <div className="text-xs text-muted-foreground">Ref: {entry.referenceId}</div>}
</td>
<td className="p-3"><Badge variant="outline">{entry.type}</Badge></td>
<td className={cn("p-3 text-right font-medium", entry.amount < 0 ? "text-error" : "text-success")}>
{entry.amount < 0 ? '-' : '+'}{Math.abs(entry.amount).toLocaleString()}
</td>
<td className="p-3 text-center">
<span className={cn("text-xs px-2 py-1 rounded-full border",
entry.status === 'Cleared' ? 'bg-success/10 border-success/20 text-success' :
entry.status === 'Pending' ? 'bg-warning/10 border-warning/20 text-warning' : 'bg-muted border-border'
)}>{entry.status}</span>
</td>
</tr>
))}
</tbody>
</table>
{ledger.length === 0 && <div className="p-8 text-center text-muted-foreground">No transactions found</div>}
</div>
</TabsContent>
<TabsContent value="docs">
<div className="flex justify-between items-center mb-6">
<h3 className="font-semibold text-lg">Contracts & Documents</h3>
<Button variant="outline" size="sm" className="gap-2"><Plus className="h-4 w-4" /> Upload Document</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{documents.map(doc => (
<div key={doc.id} className="p-4 border border-border/30 rounded-lg bg-card/50 flex items-start gap-3 hover:border-accent/40 transition-colors">
<div className="h-10 w-10 bg-secondary rounded-lg flex items-center justify-center text-muted-foreground">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 overflow-hidden">
<p className="font-medium truncate">{doc.name}</p>
<p className="text-xs text-muted-foreground capitalize">{doc.type} {doc.status}</p>
<p className="text-xs text-muted-foreground mt-1">Uploaded {new Date(doc.uploadedAt).toLocaleDateString()}</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8"><Download className="h-4 w-4" /></Button>
</div>
))}
</div>
</TabsContent>
<TabsContent value="terms">
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-lg">Active Deal Terms</h3>
<Button size="sm">Add New Term</Button>
</div>
{dealTerms.map(term => (
<div key={term.id} className="p-4 border border-border/50 rounded-xl bg-gradient-to-br from-card to-secondary/30">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-bold flex items-center gap-2">
{term.name}
<Badge variant="secondary" className="text-xs font-normal">v{term.version}</Badge>
</h4>
<p className="text-sm text-muted-foreground mt-1">Effective from {new Date(term.effectiveFrom).toLocaleDateString()}</p>
</div>
<Badge variant="outline" className="bg-success/5 border-success/20 text-success">{term.status}</Badge>
</div>
<div className="mt-4 p-3 bg-secondary/50 rounded-lg text-sm grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground block text-xs uppercase">Type</span>
<span className="font-medium">{term.type}</span>
</div>
<div>
<span className="text-muted-foreground block text-xs uppercase">Parameters</span>
<span className="font-medium">
{term.type === 'RevenueShare' ? `${term.params.percentage}% Share` :
term.type === 'CommissionPerTicket' ? `${term.params.amount} per ticket` : 'Custom'}
</span>
</div>
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="assignments">
<div className="p-8 text-center border-2 border-dashed border-border/50 rounded-xl">
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium">No event assignments yet</h3>
<p className="text-muted-foreground mb-4">Assign this partner to an upcoming event</p>
<Button>Assign to Event</Button>
</div>
</TabsContent>
</div>
</Tabs>
</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>
);
}
function Plus({ className }: { className?: string }) {
return <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M5 12h14" /><path d="M12 5v14" /></svg>
}

View File

@@ -3,7 +3,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Loader2, Upload } from "lucide-react";
import { Loader2 } from "lucide-react";
import {
Sheet,
@@ -17,7 +17,6 @@ import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -32,16 +31,15 @@ import {
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { createPartner } from "@/services/partnerApi";
const partnerFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
type: z.enum(['Venue', 'Promoter', 'Sponsor', 'Vendor', 'Affiliate', 'Other']),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
website: z.string().url().optional().or(z.literal("")),
address: z.string().optional(),
contactName: z.string().min(2, "Contact name required"),
contactRole: z.string().optional(),
partner_type: z.enum(['Venue', 'Promoter', 'Sponsor', 'Vendor', 'Affiliate', 'Other']),
primary_contact_person_email: z.string().email("Invalid email address"),
primary_contact_person_phone: z.string().optional(),
website_url: z.string().url().optional().or(z.literal("")),
primary_contact_person_name: z.string().min(2, "Contact name required"),
});
type FormValues = z.infer<typeof partnerFormSchema>;
@@ -58,27 +56,27 @@ export function AddPartnerSheet({ children }: AddPartnerSheetProps) {
resolver: zodResolver(partnerFormSchema),
defaultValues: {
name: "",
type: "Venue",
email: "",
phone: "",
website: "",
address: "",
contactName: "",
contactRole: "",
partner_type: "Venue",
primary_contact_person_email: "",
primary_contact_person_phone: "",
website_url: "",
primary_contact_person_name: "",
},
});
async function onSubmit(data: FormValues) {
setIsSubmitting(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log("Partner created:", data);
toast.success("Partner added successfully");
setIsSubmitting(false);
setOpen(false);
form.reset();
try {
await createPartner(data);
toast.success("Partner added successfully");
setOpen(false);
form.reset();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create partner';
toast.error(message);
} finally {
setIsSubmitting(false);
}
}
return (
@@ -113,7 +111,7 @@ export function AddPartnerSheet({ children }: AddPartnerSheetProps) {
<FormField
control={form.control}
name="type"
name="partner_type"
render={({ field }) => (
<FormItem>
<FormLabel>Partner Type</FormLabel>
@@ -142,7 +140,7 @@ export function AddPartnerSheet({ children }: AddPartnerSheetProps) {
<FormField
control={form.control}
name="contactName"
name="primary_contact_person_name"
render={({ field }) => (
<FormItem>
<FormLabel>Contact Name</FormLabel>
@@ -157,7 +155,7 @@ export function AddPartnerSheet({ children }: AddPartnerSheetProps) {
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="email"
name="primary_contact_person_email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
@@ -170,7 +168,7 @@ export function AddPartnerSheet({ children }: AddPartnerSheetProps) {
/>
<FormField
control={form.control}
name="phone"
name="primary_contact_person_phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
@@ -186,7 +184,7 @@ export function AddPartnerSheet({ children }: AddPartnerSheetProps) {
<FormField
control={form.control}
name="website"
name="website_url"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useCallback } from 'react';
import { PartnerEvent } from '@/types/partner';
import { approvePartnerEvent, rejectPartnerEvent } from '@/lib/actions/partner-governance';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Calendar,
MapPin,
Ticket,
CheckCircle2,
XCircle,
Clock,
Eye,
DollarSign,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface EventApprovalQueueProps {
partnerId: string;
events: PartnerEvent[];
onEventUpdated?: () => void;
}
const EVENT_STATUS_STYLES: Record<string, string> = {
PENDING_REVIEW: 'bg-warning/10 text-warning border-warning/20',
LIVE: 'bg-success/10 text-success border-success/20',
DRAFT: 'bg-muted text-muted-foreground border-border',
COMPLETED: 'bg-primary/10 text-primary border-primary/20',
CANCELLED: 'bg-destructive/10 text-destructive border-destructive/20',
REJECTED: 'bg-destructive/10 text-destructive border-destructive/20',
};
export function EventApprovalQueue({ partnerId, events, onEventUpdated }: EventApprovalQueueProps) {
const [eventList, setEventList] = useState(events);
const [reviewingEvent, setReviewingEvent] = useState<PartnerEvent | null>(null);
const [rejectionReason, setRejectionReason] = useState('');
const [processing, setProcessing] = useState(false);
const pendingEvents = eventList.filter(e => e.status === 'PENDING_REVIEW');
const otherEvents = eventList.filter(e => e.status !== 'PENDING_REVIEW');
const handleApprove = useCallback(async (eventId: string) => {
setProcessing(true);
try {
const result = await approvePartnerEvent(eventId);
if (result.success) {
toast.success(result.message);
setEventList(prev =>
prev.map(e => e.id === eventId ? { ...e, status: 'LIVE' as const } : e)
);
setReviewingEvent(null);
onEventUpdated?.();
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to approve event.');
} finally {
setProcessing(false);
}
}, [onEventUpdated]);
const handleReject = useCallback(async (eventId: string) => {
if (!rejectionReason.trim()) {
toast.error('Please provide a reason for rejection.');
return;
}
setProcessing(true);
try {
const result = await rejectPartnerEvent(eventId, rejectionReason);
if (result.success) {
toast.success(result.message);
setEventList(prev =>
prev.map(e => e.id === eventId ? { ...e, status: 'REJECTED' as const, rejectionReason } : e)
);
setReviewingEvent(null);
setRejectionReason('');
onEventUpdated?.();
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to reject event.');
} finally {
setProcessing(false);
}
}, [rejectionReason, onEventUpdated]);
const EventCard = ({ event, showActions }: { event: PartnerEvent; showActions: boolean }) => (
<div className="p-3 rounded-lg border border-border/50 bg-card/50 hover:border-border transition-colors space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{event.title}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(event.date).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{event.venue}
</span>
</div>
</div>
<Badge variant="outline" className={cn('text-[10px] shrink-0', EVENT_STATUS_STYLES[event.status])}>
{event.status.replace('_', ' ')}
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Ticket className="h-3 w-3" />
{event.ticketsSold}/{event.totalTickets} sold
</span>
{event.ticketPrice > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{event.ticketPrice}
</span>
)}
{event.category && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">{event.category}</Badge>
)}
</div>
{showActions && event.status === 'PENDING_REVIEW' && (
<div className="flex gap-2 pt-1">
<Button
size="sm"
variant="outline"
className="flex-1 text-xs gap-1.5 h-7"
onClick={() => setReviewingEvent(event)}
>
<Eye className="h-3 w-3" /> Review
</Button>
<Button
size="sm"
className="flex-1 text-xs gap-1.5 h-7 bg-success hover:bg-success/90 text-white"
onClick={() => handleApprove(event.id)}
>
<CheckCircle2 className="h-3 w-3" /> Approve
</Button>
</div>
)}
{event.status === 'REJECTED' && event.rejectionReason && (
<p className="text-xs text-destructive/80 italic pt-1">
Rejected: {event.rejectionReason}
</p>
)}
</div>
);
return (
<div className="space-y-4">
{/* Pending Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Pending Approval
</h4>
{pendingEvents.length > 0 && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 text-[10px]">
{pendingEvents.length} pending
</Badge>
)}
</div>
{pendingEvents.length > 0 ? (
<div className="space-y-2">
{pendingEvents.map(event => (
<EventCard key={event.id} event={event} showActions={true} />
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground text-sm border border-dashed border-border/50 rounded-lg">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-30" />
No events pending review
</div>
)}
</div>
{/* Other Events */}
{otherEvents.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
All Events
</h4>
<div className="space-y-2">
{otherEvents.map(event => (
<EventCard key={event.id} event={event} showActions={false} />
))}
</div>
</div>
)}
{/* Review Dialog */}
<Dialog open={!!reviewingEvent} onOpenChange={(open) => { if (!open) { setReviewingEvent(null); setRejectionReason(''); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Review Event</DialogTitle>
<DialogDescription>
Review and approve or decline this event submission.
</DialogDescription>
</DialogHeader>
{reviewingEvent && (
<div className="space-y-4">
<div className="p-4 bg-secondary/30 rounded-lg space-y-3">
<h3 className="font-semibold">{reviewingEvent.title}</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
{new Date(reviewingEvent.date).toLocaleDateString()}
{reviewingEvent.time && ` at ${reviewingEvent.time}`}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4" />
{reviewingEvent.venue}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Ticket className="h-4 w-4" />
{reviewingEvent.totalTickets} tickets
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<DollarSign className="h-4 w-4" />
{reviewingEvent.ticketPrice} each
</div>
</div>
{reviewingEvent.category && (
<Badge variant="secondary">{reviewingEvent.category}</Badge>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Rejection Reason (if declining)</label>
<Textarea
placeholder="Explain what needs to be fixed..."
value={rejectionReason}
onChange={e => setRejectionReason(e.target.value)}
className="min-h-[80px] resize-none"
/>
</div>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="destructive"
className="gap-1.5"
onClick={() => reviewingEvent && handleReject(reviewingEvent.id)}
disabled={processing}
>
<XCircle className="h-4 w-4" /> Decline
</Button>
<Button
className="bg-success hover:bg-success/90 text-white gap-1.5"
onClick={() => reviewingEvent && handleApprove(reviewingEvent.id)}
disabled={processing}
>
<CheckCircle2 className="h-4 w-4" /> Approve & Go Live
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { useState } from 'react';
import { generateImpersonationToken } from '@/lib/actions/partner-governance';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from '@/components/ui/dialog';
import { AlertTriangle, ExternalLink, LogIn, Shield } from 'lucide-react';
import { toast } from 'sonner';
interface ImpersonationDialogProps {
partnerId: string;
partnerName: string;
children: React.ReactNode;
}
export function ImpersonationDialog({ partnerId, partnerName, children }: ImpersonationDialogProps) {
const [open, setOpen] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [processing, setProcessing] = useState(false);
const handleImpersonate = async () => {
if (!acknowledged) {
toast.error('Please acknowledge the audit warning.');
return;
}
setProcessing(true);
try {
const result = await generateImpersonationToken(partnerId);
if (result.success && result.redirectUrl) {
toast.success(result.message);
window.open(result.redirectUrl, '_blank');
setOpen(false);
setAcknowledged(false);
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to create impersonation session.');
} finally {
setProcessing(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) setAcknowledged(false); }}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LogIn className="h-5 w-5 text-warning" />
Login as Partner
</DialogTitle>
<DialogDescription>
You are about to impersonate <strong>{partnerName}</strong>'s account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-3 p-3 bg-warning/5 border border-warning/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="text-sm space-y-1">
<p className="font-medium text-warning">Security Notice</p>
<p className="text-muted-foreground text-xs">
This will generate a short-lived impersonation token and open the Partner Dashboard
in a new tab. All actions performed during impersonation are logged and attributed
to your admin account.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-secondary/30 rounded-lg">
<Shield className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground space-y-1.5">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Create a time-limited session token</li>
<li>Log this action in the audit trail</li>
<li>Open the partner dashboard in a new tab</li>
<li>Auto-expire after 30 minutes of inactivity</li>
</ul>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="audit-ack"
checked={acknowledged}
onCheckedChange={(checked) => setAcknowledged(checked === true)}
/>
<label htmlFor="audit-ack" className="text-sm font-medium leading-none cursor-pointer">
I understand this action is logged for audit purposes
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button
onClick={handleImpersonate}
disabled={!acknowledged || processing}
className="gap-2 bg-warning hover:bg-warning/90 text-warning-foreground"
>
<ExternalLink className="h-4 w-4" />
{processing ? 'Creating Session...' : 'Open Partner Dashboard'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import { useState, useCallback } from 'react';
import { KYCDocument, KYCDocStatus } from '@/types/partner';
import { verifyPartnerDocument, getPartnerKYCStatus } from '@/lib/actions/partner-governance';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Progress } from '@/components/ui/progress';
import {
FileText,
CheckCircle2,
XCircle,
Clock,
Eye,
ShieldCheck,
ShieldAlert,
ShieldX,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface KYCVaultPanelProps {
partnerId: string;
partnerName: string;
verificationStatus: 'Pending' | 'Verified' | 'Rejected';
documents: KYCDocument[];
onDocumentVerified?: () => void;
}
const STATUS_ICON: Record<KYCDocStatus, React.ReactNode> = {
APPROVED: <CheckCircle2 className="h-4 w-4 text-success" />,
REJECTED: <XCircle className="h-4 w-4 text-destructive" />,
PENDING: <Clock className="h-4 w-4 text-warning" />,
};
const STATUS_BADGE: Record<KYCDocStatus, string> = {
APPROVED: 'bg-success/10 text-success border-success/20',
REJECTED: 'bg-destructive/10 text-destructive border-destructive/20',
PENDING: 'bg-warning/10 text-warning border-warning/20',
};
export function KYCVaultPanel({
partnerId,
partnerName,
verificationStatus,
documents,
onDocumentVerified,
}: KYCVaultPanelProps) {
const [docs, setDocs] = useState(documents);
const [expandedDoc, setExpandedDoc] = useState<string | null>(null);
const [rejectionReasons, setRejectionReasons] = useState<Record<string, string>>({});
const [processing, setProcessing] = useState<string | null>(null);
const [vStatus, setVStatus] = useState(verificationStatus);
const mandatoryDocs = docs.filter(d => d.mandatory);
const approvedMandatory = mandatoryDocs.filter(d => d.status === 'APPROVED');
const completionPercent = mandatoryDocs.length > 0
? Math.round((approvedMandatory.length / mandatoryDocs.length) * 100)
: 0;
const handleVerify = useCallback(async (docId: string, status: 'APPROVED' | 'REJECTED') => {
if (status === 'REJECTED' && !rejectionReasons[docId]?.trim()) {
toast.error('Please provide a reason for rejection.');
return;
}
setProcessing(docId);
try {
const result = await verifyPartnerDocument(
docId,
status,
status === 'REJECTED' ? rejectionReasons[docId] : undefined
);
if (result.success) {
toast.success(result.message);
setDocs(prev =>
prev.map(d => d.id === docId ? { ...d, status, reviewedBy: 'Current Admin', reviewedAt: new Date().toISOString(), ...(status === 'REJECTED' ? { adminNote: rejectionReasons[docId] } : {}) } : d)
);
setExpandedDoc(null);
if (result.autoVerified) {
setVStatus('Verified');
}
onDocumentVerified?.();
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to process document.');
} finally {
setProcessing(null);
}
}, [rejectionReasons, onDocumentVerified]);
const overallIcon = vStatus === 'Verified'
? <ShieldCheck className="h-5 w-5 text-success" />
: vStatus === 'Rejected'
? <ShieldX className="h-5 w-5 text-destructive" />
: <ShieldAlert className="h-5 w-5 text-warning" />;
const overallBg = vStatus === 'Verified'
? 'bg-success/5 border-success/20'
: vStatus === 'Rejected'
? 'bg-destructive/5 border-destructive/20'
: 'bg-warning/5 border-warning/20';
return (
<div className="space-y-4">
{/* Overall Status Banner */}
<div className={cn('flex items-center gap-3 p-4 rounded-xl border', overallBg)}>
{overallIcon}
<div className="flex-1">
<p className="font-semibold text-sm">
{vStatus === 'Verified' ? 'Fully Verified' : vStatus === 'Rejected' ? 'Verification Failed' : 'Needs Review'}
</p>
<p className="text-xs text-muted-foreground">
{approvedMandatory.length}/{mandatoryDocs.length} mandatory documents approved
</p>
</div>
<Badge variant="outline" className={cn('text-xs', vStatus === 'Verified' ? 'bg-success/10 text-success border-success/20' : vStatus === 'Rejected' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning border-warning/20')}>
{vStatus}
</Badge>
</div>
{/* Progress Bar */}
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Completion</span>
<span className="font-medium">{completionPercent}%</span>
</div>
<Progress value={completionPercent} className="h-2" />
</div>
{/* Documents List */}
<div className="space-y-2">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Documents</h4>
{docs.map(doc => {
const isExpanded = expandedDoc === doc.id;
const isProcessing = processing === doc.id;
return (
<div key={doc.id} className="rounded-lg border border-border/50 bg-card/50 overflow-hidden">
{/* Doc Row */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
onClick={() => setExpandedDoc(isExpanded ? null : doc.id)}
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{doc.type} {doc.mandatory && '• Required'}
</p>
</div>
<div className="flex items-center gap-2">
{STATUS_ICON[doc.status]}
<Badge variant="outline" className={cn('text-[10px] h-5 px-1.5', STATUS_BADGE[doc.status])}>
{doc.status}
</Badge>
{doc.status === 'PENDING' && (
isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
)}
</div>
</div>
{/* Expanded Actions */}
{isExpanded && doc.status === 'PENDING' && (
<div className="px-3 pb-3 pt-1 border-t border-border/30 space-y-3">
<Button variant="ghost" size="sm" className="gap-2 text-xs w-full justify-start text-muted-foreground hover:text-foreground">
<Eye className="h-3.5 w-3.5" /> Preview Document
</Button>
<Textarea
placeholder="Rejection reason (required if rejecting)..."
className="text-xs min-h-[60px] resize-none"
value={rejectionReasons[doc.id] || ''}
onChange={e =>
setRejectionReasons(prev => ({ ...prev, [doc.id]: e.target.value }))
}
/>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 bg-success hover:bg-success/90 text-white gap-1.5 text-xs"
onClick={(e) => { e.stopPropagation(); handleVerify(doc.id, 'APPROVED'); }}
disabled={isProcessing}
>
<CheckCircle2 className="h-3.5 w-3.5" /> Approve
</Button>
<Button
size="sm"
variant="destructive"
className="flex-1 gap-1.5 text-xs"
onClick={(e) => { e.stopPropagation(); handleVerify(doc.id, 'REJECTED'); }}
disabled={isProcessing}
>
<XCircle className="h-3.5 w-3.5" /> Reject
</Button>
</div>
</div>
)}
{/* Rejected Note */}
{doc.status === 'REJECTED' && doc.adminNote && (
<div className="px-3 pb-3 pt-1 border-t border-border/30">
<p className="text-xs text-destructive/80 italic">
Reason: {doc.adminNote}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Trash2 } from 'lucide-react';
import type { Review, RejectReason } from '@/types/review';
interface DeleteReviewDialogProps {
review: Review | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (review: Review, reason: RejectReason) => void;
}
export function DeleteReviewDialog({
review,
open,
onOpenChange,
onConfirm,
}: DeleteReviewDialogProps) {
const [reason, setReason] = useState<RejectReason | ''>('');
if (!review) return null;
const handleConfirm = () => {
if (reason) {
onConfirm(review, reason as RejectReason);
setReason('');
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="rounded-2xl border-2 border-black/10 bg-white shadow-[5px_5px_0px_0px_rgba(0,0,0,0.08)] max-w-md">
<AlertDialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-red-100 border-2 border-red-200">
<Trash2 className="h-5 w-5 text-red-600" />
</div>
<AlertDialogTitle className="text-lg font-bold">
Delete Review
</AlertDialogTitle>
</div>
<AlertDialogDescription className="text-sm text-muted-foreground leading-relaxed">
This will permanently remove the review by{' '}
<span className="font-semibold text-foreground">{review.reviewerName}</span>{' '}
from <span className="font-semibold text-foreground">{review.eventName}</span>.
The reviewer will be notified.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-3">
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground block mb-2">
Reason for deletion *
</label>
<Select value={reason} onValueChange={(val) => setReason(val as RejectReason)}>
<SelectTrigger className="rounded-xl border-2 border-black/10">
<SelectValue placeholder="Select a reason..." />
</SelectTrigger>
<SelectContent className="rounded-xl border-2 border-black/10">
<SelectItem value="spam">Spam</SelectItem>
<SelectItem value="inappropriate">Inappropriate Content</SelectItem>
<SelectItem value="fake">Fake Review</SelectItem>
</SelectContent>
</Select>
</div>
<AlertDialogFooter className="gap-2">
<AlertDialogCancel className="rounded-xl border-2 border-black/10">
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
className="rounded-xl border-2 border-red-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.1)] font-bold"
onClick={handleConfirm}
disabled={!reason}
>
Delete Review
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,147 @@
import { Eye, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StarRating } from './StarRating';
import type { Review } from '@/types/review';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/data/mockData';
interface LiveReviewsTableProps {
reviews: Review[];
onView: (review: Review) => void;
onEdit: (review: Review) => void;
onDelete: (review: Review) => void;
}
export function LiveReviewsTable({
reviews,
onView,
onEdit,
onDelete,
}: LiveReviewsTableProps) {
return (
<div className="rounded-xl border-2 border-black/10 bg-white overflow-hidden shadow-[3px_3px_0px_0px_rgba(0,0,0,0.08)]">
{/* Table Header */}
<div className="grid grid-cols-[minmax(160px,1.5fr)_minmax(120px,1fr)_130px_minmax(160px,2fr)_70px_80px_120px] gap-4 px-5 py-3 bg-gray-50 border-b-2 border-black/10 text-xs font-bold uppercase tracking-wider text-muted-foreground">
<div>Reviewer</div>
<div>Event</div>
<div>Rating</div>
<div>Review</div>
<div>Status</div>
<div>Date</div>
<div className="text-right">Actions</div>
</div>
{/* Table Body */}
<div className="divide-y divide-gray-100">
{reviews.map((review) => (
<div
key={review.id}
className={cn(
'grid grid-cols-[minmax(160px,1.5fr)_minmax(120px,1fr)_130px_minmax(160px,2fr)_70px_80px_120px] gap-4 px-5 py-4 items-center',
'transition-all duration-150 hover:bg-gray-50/80 group'
)}
>
{/* Reviewer */}
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-9 w-9 border-2 border-black/10 flex-shrink-0">
<AvatarImage src={review.reviewerAvatar} alt={review.reviewerName} />
<AvatarFallback className="text-xs font-bold bg-primary/10">
{review.reviewerName.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{review.reviewerName}
</p>
<p className="text-xs text-muted-foreground truncate">
{review.reviewerEmail}
</p>
</div>
</div>
{/* Event */}
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{review.eventName}
</p>
</div>
{/* Rating */}
<div>
<StarRating rating={review.rating} size="sm" />
</div>
{/* Review Snippet */}
<div className="min-w-0">
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
{review.reviewText}
</p>
</div>
{/* Status */}
<div>
<Badge className="bg-emerald-100 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 font-bold text-[10px] uppercase tracking-wider">
Live
</Badge>
</div>
{/* Date */}
<div>
<span className="text-xs font-mono text-muted-foreground">
{formatRelativeTime(review.submissionDate)}
</span>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg border-2 border-transparent hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 transition-all"
onClick={() => onView(review)}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Details</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg border-2 border-transparent hover:border-amber-300 hover:bg-amber-50 hover:text-amber-600 transition-all"
onClick={() => onEdit(review)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg border-2 border-transparent hover:border-red-300 hover:bg-red-50 hover:text-red-600 transition-all"
onClick={() => onDelete(review)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { Check, X, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StarRating } from './StarRating';
import type { Review } from '@/types/review';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/data/mockData';
interface PendingReviewsTableProps {
reviews: Review[];
onApprove: (review: Review) => void;
onReject: (review: Review) => void;
onEdit: (review: Review) => void;
}
export function PendingReviewsTable({
reviews,
onApprove,
onReject,
onEdit,
}: PendingReviewsTableProps) {
return (
<div className="rounded-xl border-2 border-black/10 bg-white overflow-hidden shadow-[3px_3px_0px_0px_rgba(0,0,0,0.08)]">
{/* Table Header */}
<div className="grid grid-cols-[minmax(180px,1.5fr)_minmax(120px,1fr)_130px_minmax(180px,2fr)_80px_120px] gap-4 px-5 py-3 bg-gray-50 border-b-2 border-black/10 text-xs font-bold uppercase tracking-wider text-muted-foreground">
<div>Reviewer</div>
<div>Event</div>
<div>Rating</div>
<div>Review</div>
<div>Date</div>
<div className="text-right">Actions</div>
</div>
{/* Table Body */}
<div className="divide-y divide-gray-100">
{reviews.map((review) => (
<div
key={review.id}
className={cn(
'grid grid-cols-[minmax(180px,1.5fr)_minmax(120px,1fr)_130px_minmax(180px,2fr)_80px_120px] gap-4 px-5 py-4 items-center',
'transition-all duration-150 hover:bg-gray-50/80 group'
)}
>
{/* Reviewer */}
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-9 w-9 border-2 border-black/10 flex-shrink-0">
<AvatarImage src={review.reviewerAvatar} alt={review.reviewerName} />
<AvatarFallback className="text-xs font-bold bg-primary/10">
{review.reviewerName.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{review.reviewerName}
</p>
<p className="text-xs text-muted-foreground truncate">
{review.reviewerEmail}
</p>
</div>
</div>
{/* Event */}
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{review.eventName}
</p>
</div>
{/* Rating */}
<div>
<StarRating rating={review.rating} size="sm" />
</div>
{/* Review Snippet */}
<div className="min-w-0">
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
{review.reviewText}
</p>
</div>
{/* Date */}
<div>
<span className="text-xs font-mono text-muted-foreground">
{formatRelativeTime(review.submissionDate)}
</span>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg border-2 border-transparent hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-600 transition-all"
onClick={() => onApprove(review)}
>
<Check className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Approve</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg border-2 border-transparent hover:border-red-300 hover:bg-red-50 hover:text-red-600 transition-all"
onClick={() => onReject(review)}
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reject</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg border-2 border-transparent hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 transition-all"
onClick={() => onEdit(review)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit / Moderate</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ShieldX } from 'lucide-react';
import type { Review, RejectReason } from '@/types/review';
interface RejectReviewDialogProps {
review: Review | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (review: Review, reason: RejectReason) => void;
}
export function RejectReviewDialog({
review,
open,
onOpenChange,
onConfirm,
}: RejectReviewDialogProps) {
const [reason, setReason] = useState<RejectReason | ''>('');
if (!review) return null;
const handleConfirm = () => {
if (reason) {
onConfirm(review, reason as RejectReason);
setReason('');
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="rounded-2xl border-2 border-black/10 bg-white shadow-[5px_5px_0px_0px_rgba(0,0,0,0.08)] max-w-md">
<AlertDialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-red-100 border-2 border-red-200">
<ShieldX className="h-5 w-5 text-red-600" />
</div>
<AlertDialogTitle className="text-lg font-bold">
Reject Review
</AlertDialogTitle>
</div>
<AlertDialogDescription className="text-sm text-muted-foreground leading-relaxed">
Are you sure you want to reject the review by{' '}
<span className="font-semibold text-foreground">{review.reviewerName}</span>{' '}
for <span className="font-semibold text-foreground">{review.eventName}</span>?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-3">
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground block mb-2">
Reason for rejection *
</label>
<Select value={reason} onValueChange={(val) => setReason(val as RejectReason)}>
<SelectTrigger className="rounded-xl border-2 border-black/10">
<SelectValue placeholder="Select a reason..." />
</SelectTrigger>
<SelectContent className="rounded-xl border-2 border-black/10">
<SelectItem value="spam">Spam</SelectItem>
<SelectItem value="inappropriate">Inappropriate Content</SelectItem>
<SelectItem value="fake">Fake Review</SelectItem>
</SelectContent>
</Select>
</div>
<AlertDialogFooter className="gap-2">
<AlertDialogCancel className="rounded-xl border-2 border-black/10">
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
className="rounded-xl border-2 border-red-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.1)] font-bold"
onClick={handleConfirm}
disabled={!reason}
>
Reject Review
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,169 @@
import { useState, useEffect } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { StarRating } from './StarRating';
import { Calendar, MapPin, Trophy, MessageSquareText, Save, Check } from 'lucide-react';
import type { Review } from '@/types/review';
import { cn } from '@/lib/utils';
interface ReviewDrawerProps {
review: Review | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaveAndApprove: (review: Review, editedText: string) => void;
onSave: (review: Review, editedText: string) => void;
mode?: 'pending' | 'live';
}
export function ReviewDrawer({
review,
open,
onOpenChange,
onSaveAndApprove,
onSave,
mode = 'pending',
}: ReviewDrawerProps) {
const [editedText, setEditedText] = useState('');
useEffect(() => {
if (review) {
setEditedText(review.reviewText);
}
}, [review]);
if (!review) return null;
const rankColors: Record<string, string> = {
Explorer: 'bg-gray-100 text-gray-700 border-gray-200',
Contributor: 'bg-blue-100 text-blue-700 border-blue-200',
Enthusiast: 'bg-purple-100 text-purple-700 border-purple-200',
Champion: 'bg-amber-100 text-amber-700 border-amber-200',
Legend: 'bg-emerald-100 text-emerald-700 border-emerald-200',
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full sm:max-w-lg bg-white border-l-2 border-black/10 p-0 flex flex-col"
>
{/* Header */}
<SheetHeader className="px-6 pt-6 pb-4 border-b-2 border-black/10 bg-gray-50">
<SheetTitle className="text-lg font-bold">
{mode === 'pending' ? 'Moderate Review' : 'Edit Review'}
</SheetTitle>
<SheetDescription>
Review submitted for <span className="font-semibold text-foreground">{review.eventName}</span>
</SheetDescription>
</SheetHeader>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
{/* Reviewer Card */}
<div className="rounded-xl border-2 border-black/10 bg-gray-50 p-4 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.05)]">
<div className="flex items-center gap-3 mb-3">
<Avatar className="h-11 w-11 border-2 border-black/10">
<AvatarImage src={review.reviewerAvatar} alt={review.reviewerName} />
<AvatarFallback className="text-sm font-bold bg-primary/10">
{review.reviewerName.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div>
<p className="font-bold text-foreground">{review.reviewerName}</p>
<p className="text-xs text-muted-foreground">{review.reviewerEmail}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge className={cn('border font-bold text-[10px] uppercase tracking-wider', rankColors[review.reviewerRank])}>
<Trophy className="h-3 w-3 mr-1" />
{review.reviewerRank}
</Badge>
<Badge variant="outline" className="font-mono text-[10px]">
<MessageSquareText className="h-3 w-3 mr-1" />
{review.reviewerTotalReviews} reviews
</Badge>
</div>
</div>
{/* Event Details Card */}
<div className="rounded-xl border-2 border-black/10 bg-gray-50 p-4 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.05)]">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
Event Details
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{review.eventName}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">{review.eventDate}</span>
</div>
</div>
</div>
{/* Rating */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Rating
</h4>
<StarRating rating={review.rating} size="md" showBadge={true} />
</div>
{/* Editable Review Text */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Review Text
</h4>
<Textarea
value={editedText}
onChange={(e) => setEditedText(e.target.value)}
className="min-h-[180px] rounded-xl border-2 border-black/10 bg-white text-sm leading-relaxed resize-none focus:border-primary"
placeholder="Review text..."
/>
<p className="text-xs text-muted-foreground mt-1.5 font-mono">
{editedText.length} characters
</p>
</div>
</div>
{/* Sticky Footer */}
<div className="border-t-2 border-black/10 bg-gray-50 px-6 py-4 flex items-center gap-3">
<Button
variant="outline"
className="flex-1 rounded-xl border-2 border-black/10"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
{mode === 'pending' ? (
<Button
className="flex-1 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white border-2 border-emerald-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.1)] font-bold gap-2"
onClick={() => onSaveAndApprove(review, editedText)}
>
<Check className="h-4 w-4" />
Save & Approve
</Button>
) : (
<Button
className="flex-1 rounded-xl bg-primary hover:bg-primary/90 text-white border-2 border-black/20 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.1)] font-bold gap-2"
onClick={() => onSave(review, editedText)}
>
<Save className="h-4 w-4" />
Save Changes
</Button>
)}
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,29 @@
import { Inbox, PartyPopper, Sparkles } from 'lucide-react';
export function ReviewEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-20 px-8 text-center">
{/* Icon cluster */}
<div className="relative mb-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-emerald-50 border-2 border-emerald-200 shadow-[3px_3px_0px_0px_rgba(16,185,129,0.2)]">
<Inbox className="h-10 w-10 text-emerald-500" />
</div>
<div className="absolute -top-2 -right-3 flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 border-2 border-amber-200 animate-bounce">
<PartyPopper className="h-4 w-4 text-amber-500" />
</div>
<div className="absolute -bottom-1 -left-3 flex h-7 w-7 items-center justify-center rounded-full bg-blue-50 border-2 border-blue-200">
<Sparkles className="h-3.5 w-3.5 text-blue-500" />
</div>
</div>
{/* Text */}
<h3 className="text-xl font-bold text-foreground mb-2">
Inbox Zero! 🎉
</h3>
<p className="text-muted-foreground max-w-sm leading-relaxed">
All reviews have been moderated. You're up to date! New reviews will
appear here as they come in.
</p>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Clock, MessageSquareText, ShieldX } from 'lucide-react';
import type { ReviewMetrics } from '@/types/review';
import { cn } from '@/lib/utils';
interface ReviewMetricsBarProps {
metrics: ReviewMetrics;
}
const metricCards = [
{
key: 'totalPending' as const,
label: 'Total Pending',
icon: Clock,
iconColor: 'text-amber-500',
bgAccent: 'bg-amber-50',
borderAccent: 'border-amber-200',
},
{
key: 'liveReviews' as const,
label: 'Live Reviews',
icon: MessageSquareText,
iconColor: 'text-emerald-500',
bgAccent: 'bg-emerald-50',
borderAccent: 'border-emerald-200',
},
{
key: 'rejected' as const,
label: 'Rejected',
icon: ShieldX,
iconColor: 'text-red-500',
bgAccent: 'bg-red-50',
borderAccent: 'border-red-200',
},
];
export function ReviewMetricsBar({ metrics }: ReviewMetricsBarProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{metricCards.map((card) => (
<div
key={card.key}
className={cn(
'relative overflow-hidden rounded-xl border-2 border-black/10 bg-white p-5 transition-all duration-200',
'shadow-[3px_3px_0px_0px_rgba(0,0,0,0.08)]',
'hover:shadow-[5px_5px_0px_0px_rgba(0,0,0,0.1)] hover:-translate-y-0.5'
)}
>
{/* Accent strip */}
<div className={cn('absolute top-0 left-0 w-full h-1', card.bgAccent)} />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">
{card.label}
</p>
<p className="text-3xl font-bold font-mono tracking-tight text-foreground">
{metrics[card.key].toLocaleString('en-IN')}
</p>
</div>
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-xl border-2',
card.borderAccent,
card.bgAccent
)}
>
<card.icon className={cn('h-6 w-6', card.iconColor)} />
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { Star } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StarRatingProps {
rating: number;
size?: 'sm' | 'md';
showBadge?: boolean;
}
export function StarRating({ rating, size = 'sm', showBadge = true }: StarRatingProps) {
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4.5 w-4.5';
const badgeColor =
rating >= 4
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: rating === 3
? 'bg-amber-100 text-amber-700 border-amber-200'
: 'bg-red-100 text-red-700 border-red-200';
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
iconSize,
'transition-colors',
i < rating
? 'fill-amber-400 text-amber-400'
: 'fill-transparent text-gray-300'
)}
/>
))}
</div>
{showBadge && (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-bold font-mono tracking-tight',
badgeColor
)}
>
{rating}.0
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,419 @@
import type { Review, ReviewMetrics } from '@/types/review';
// Realistic Indian names and events
export const mockPendingReviews: Review[] = [
{
id: 'rev-p-001',
reviewerName: 'Aarav Mehta',
reviewerEmail: 'aarav.mehta@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aarav',
eventName: 'Mumbai Music Festival 2026',
eventId: 'evt-001',
eventDate: '2026-03-15',
rating: 5,
reviewText: 'Absolutely phenomenal experience! The lineup was incredible and the venue management was top-notch. The sound quality was perfect and the food stalls had great variety. Would definitely attend again next year!',
submissionDate: new Date(Date.now() - 1000 * 60 * 30),
status: 'pending',
reviewerRank: 'Champion',
reviewerTotalReviews: 24,
},
{
id: 'rev-p-002',
reviewerName: 'Priya Sharma',
reviewerEmail: 'priya.sharma@outlook.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Priya',
eventName: 'Delhi Tech Summit',
eventId: 'evt-002',
eventDate: '2026-03-20',
rating: 4,
reviewText: 'Great sessions on AI and cloud computing. The networking opportunities were fantastic. Only downside was the long queue at registration. Speakers were knowledgeable and approachable.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 2),
status: 'pending',
reviewerRank: 'Enthusiast',
reviewerTotalReviews: 12,
},
{
id: 'rev-p-003',
reviewerName: 'Rohit Patel',
reviewerEmail: 'rohit.p@yahoo.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Rohit',
eventName: 'Bangalore Comedy Night',
eventId: 'evt-003',
eventDate: '2026-02-28',
rating: 2,
reviewText: 'this was terrible waste of money dont go. bad comedians and the venue was dirty. worst event ever!!!',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 5),
status: 'pending',
reviewerRank: 'Explorer',
reviewerTotalReviews: 2,
},
{
id: 'rev-p-004',
reviewerName: 'Sneha Kulkarni',
reviewerEmail: 'sneha.k@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sneha',
eventName: 'Pune Food Festival',
eventId: 'evt-004',
eventDate: '2026-03-05',
rating: 5,
reviewText: 'A foodie paradise! Over 50 stalls with cuisines from across India. The live cooking demonstrations were a highlight. Very well organized with clean restrooms and ample seating.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 8),
status: 'pending',
reviewerRank: 'Contributor',
reviewerTotalReviews: 8,
},
{
id: 'rev-p-005',
reviewerName: 'Vikram Singh',
reviewerEmail: 'vikram.s@hotmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Vikram',
eventName: 'Jaipur Literature Fest',
eventId: 'evt-005',
eventDate: '2026-02-20',
rating: 4,
reviewText: 'Wonderful literary gathering with some of the best authors. The panel discussions were thought-provoking. Could have had better parking arrangements though.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 12),
status: 'pending',
reviewerRank: 'Enthusiast',
reviewerTotalReviews: 15,
},
{
id: 'rev-p-006',
reviewerName: 'Ananya Reddy',
reviewerEmail: 'ananya.r@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Ananya',
eventName: 'Hyderabad Dance Championship',
eventId: 'evt-006',
eventDate: '2026-03-01',
rating: 3,
reviewText: 'The performances were good but the event was delayed by 2 hours. Sound system had issues during the first few performances. Improved towards the end.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 18),
status: 'pending',
reviewerRank: 'Explorer',
reviewerTotalReviews: 3,
},
{
id: 'rev-p-007',
reviewerName: 'Karthik Nair',
reviewerEmail: 'karthik.n@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Karthik',
eventName: 'Chennai Classical Music Night',
eventId: 'evt-007',
eventDate: '2026-02-25',
rating: 5,
reviewText: 'A soul-stirring performance by masters of Carnatic music. The ambiance was perfect and the acoustics were brilliant. This is what true cultural events should look like.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24),
status: 'pending',
reviewerRank: 'Legend',
reviewerTotalReviews: 52,
},
{
id: 'rev-p-008',
reviewerName: 'Meera Joshi',
reviewerEmail: 'meera.j@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Meera',
eventName: 'Goa Beach Party NYE',
eventId: 'evt-008',
eventDate: '2025-12-31',
rating: 1,
reviewText: 'SCAM ALERT! Paid ₹5000 for VIP but treated like cattle. No seating, warm drinks, rude staff. AVOID AT ALL COSTS. Want my money back!!! This event organizer should be banned.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 30),
status: 'pending',
reviewerRank: 'Explorer',
reviewerTotalReviews: 1,
},
{
id: 'rev-p-009',
reviewerName: 'Arjun Desai',
reviewerEmail: 'arjun.d@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Arjun',
eventName: 'Ahmedabad Startup Meetup',
eventId: 'evt-009',
eventDate: '2026-03-10',
rating: 4,
reviewText: 'Great networking event for the Gujarat startup ecosystem. Met some amazing founders and VCs. The pitching sessions were very informative. Snacks could have been better.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 36),
status: 'pending',
reviewerRank: 'Contributor',
reviewerTotalReviews: 7,
},
{
id: 'rev-p-010',
reviewerName: 'Divya Iyer',
reviewerEmail: 'divya.iyer@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Divya',
eventName: 'Kochi Art Biennale',
eventId: 'evt-010',
eventDate: '2026-01-15',
rating: 5,
reviewText: 'Breathtaking art installations! The curation was world-class. Loved the interactive exhibits and the heritage venue. A must-visit for art enthusiasts. Spent the whole day and still wanted more.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 48),
status: 'pending',
reviewerRank: 'Champion',
reviewerTotalReviews: 31,
},
{
id: 'rev-p-011',
reviewerName: 'Rajesh Gupta',
reviewerEmail: 'rajesh.g@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Rajesh',
eventName: 'Kolkata Book Fair',
eventId: 'evt-011',
eventDate: '2026-02-10',
rating: 3,
reviewText: 'The book fair had a good collection but was extremely crowded. Difficult to browse peacefully. The stall arrangements could be improved for better navigation.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 56),
status: 'pending',
reviewerRank: 'Contributor',
reviewerTotalReviews: 9,
},
{
id: 'rev-p-012',
reviewerName: 'Nisha Verma',
reviewerEmail: 'nisha.v@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Nisha',
eventName: 'Lucknow Kebab Festival',
eventId: 'evt-012',
eventDate: '2026-03-02',
rating: 4,
reviewText: 'Nawabi food at its finest! The kebabs were out of this world. Live ghazal performances added to the charm. Only wish they had more vegetarian options.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 72),
status: 'pending',
reviewerRank: 'Enthusiast',
reviewerTotalReviews: 18,
},
];
export const mockLiveReviews: Review[] = [
{
id: 'rev-l-001',
reviewerName: 'Aditya Kumar',
reviewerEmail: 'aditya.k@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aditya',
eventName: 'Mumbai Music Festival 2026',
eventId: 'evt-001',
eventDate: '2026-03-15',
rating: 5,
reviewText: 'Best music festival I have ever attended in India! The energy was electric from start to finish. Every artist delivered an outstanding performance.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5),
status: 'live',
reviewerRank: 'Legend',
reviewerTotalReviews: 45,
},
{
id: 'rev-l-002',
reviewerName: 'Pooja Malhotra',
reviewerEmail: 'pooja.m@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Pooja',
eventName: 'Delhi Tech Summit',
eventId: 'evt-002',
eventDate: '2026-03-20',
rating: 4,
reviewText: 'Well organized tech conference with relevant topics. The workshop on GenAI was particularly insightful. Networking lunch was a great addition.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
status: 'live',
reviewerRank: 'Champion',
reviewerTotalReviews: 28,
},
{
id: 'rev-l-003',
reviewerName: 'Siddharth Rao',
reviewerEmail: 'sid.rao@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Siddharth',
eventName: 'Pune Food Festival',
eventId: 'evt-004',
eventDate: '2026-03-05',
rating: 5,
reviewText: 'Incredible food festival! The biryani competition was a highlight. Great family-friendly atmosphere with activities for kids too.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4),
status: 'live',
reviewerRank: 'Enthusiast',
reviewerTotalReviews: 16,
},
{
id: 'rev-l-004',
reviewerName: 'Lakshmi Narayanan',
reviewerEmail: 'lakshmi.n@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Lakshmi',
eventName: 'Chennai Classical Music Night',
eventId: 'evt-007',
eventDate: '2026-02-25',
rating: 5,
reviewText: 'Mesmerizing ragas under the stars! The maestros were in top form. A spiritually uplifting experience that reminded me why I love Carnatic music.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6),
status: 'live',
reviewerRank: 'Legend',
reviewerTotalReviews: 67,
},
{
id: 'rev-l-005',
reviewerName: 'Tanvi Bhatt',
reviewerEmail: 'tanvi.b@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Tanvi',
eventName: 'Jaipur Literature Fest',
eventId: 'evt-005',
eventDate: '2026-02-20',
rating: 3,
reviewText: 'Good festival overall but felt commercialized compared to previous years. Some sessions were overcrowded. The heritage venue was beautiful though.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
status: 'live',
reviewerRank: 'Contributor',
reviewerTotalReviews: 11,
},
{
id: 'rev-l-006',
reviewerName: 'Manish Tiwari',
reviewerEmail: 'manish.t@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Manish',
eventName: 'Bangalore Comedy Night',
eventId: 'evt-003',
eventDate: '2026-02-28',
rating: 4,
reviewText: 'Hilarious night out! The headliner was absolutely brilliant. Good venue with decent drinks. Would recommend for a fun weekend.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2),
status: 'live',
reviewerRank: 'Explorer',
reviewerTotalReviews: 5,
},
{
id: 'rev-l-007',
reviewerName: 'Ritika Saxena',
reviewerEmail: 'ritika.s@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Ritika',
eventName: 'Kochi Art Biennale',
eventId: 'evt-010',
eventDate: '2026-01-15',
rating: 5,
reviewText: 'A feast for the eyes! The international artists brought such diverse perspectives. The Fort Kochi backdrop made everything even more magical.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 8),
status: 'live',
reviewerRank: 'Champion',
reviewerTotalReviews: 22,
},
{
id: 'rev-l-008',
reviewerName: 'Deepak Menon',
reviewerEmail: 'deepak.m@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Deepak',
eventName: 'Ahmedabad Startup Meetup',
eventId: 'evt-009',
eventDate: '2026-03-10',
rating: 4,
reviewText: 'Inspiring event for budding entrepreneurs. The mentor sessions were invaluable. Great to see the startup culture growing in Gujarat.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 1),
status: 'live',
reviewerRank: 'Contributor',
reviewerTotalReviews: 6,
},
{
id: 'rev-l-009',
reviewerName: 'Kavita Deshmukh',
reviewerEmail: 'kavita.d@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Kavita',
eventName: 'Hyderabad Dance Championship',
eventId: 'evt-006',
eventDate: '2026-03-01',
rating: 4,
reviewText: 'Electrifying dance battles! The talent on display was extraordinary. Judging was fair and the audience engagement was fantastic.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
status: 'live',
reviewerRank: 'Enthusiast',
reviewerTotalReviews: 14,
},
{
id: 'rev-l-010',
reviewerName: 'Suresh Pillai',
reviewerEmail: 'suresh.p@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Suresh',
eventName: 'Lucknow Kebab Festival',
eventId: 'evt-012',
eventDate: '2026-03-02',
rating: 5,
reviewText: 'The flavors of Awadhi cuisine at their finest! Every stall was a winner. The live music paired perfectly with the food experience.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4),
status: 'live',
reviewerRank: 'Champion',
reviewerTotalReviews: 33,
},
{
id: 'rev-l-011',
reviewerName: 'Anjali Kapoor',
reviewerEmail: 'anjali.k@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Anjali',
eventName: 'Kolkata Book Fair',
eventId: 'evt-011',
eventDate: '2026-02-10',
rating: 4,
reviewText: 'A book lovers dream! Found rare editions and met some amazing authors. The Bengali literature section was particularly impressive.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 9),
status: 'live',
reviewerRank: 'Enthusiast',
reviewerTotalReviews: 19,
},
{
id: 'rev-l-012',
reviewerName: 'Harsh Vardhan',
reviewerEmail: 'harsh.v@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Harsh',
eventName: 'Mumbai Music Festival 2026',
eventId: 'evt-001',
eventDate: '2026-03-15',
rating: 4,
reviewText: 'Amazing music, amazing vibes! The indie artists stole the show for me. Great merch collection too. Will be back for sure!',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5),
status: 'live',
reviewerRank: 'Contributor',
reviewerTotalReviews: 10,
},
{
id: 'rev-l-013',
reviewerName: 'Geeta Raman',
reviewerEmail: 'geeta.r@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Geeta',
eventName: 'Pune Food Festival',
eventId: 'evt-004',
eventDate: '2026-03-05',
rating: 3,
reviewText: 'Decent food variety but prices were on the higher side for the portions. The dessert section was the highlight. Parking was a nightmare.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6),
status: 'live',
reviewerRank: 'Explorer',
reviewerTotalReviews: 4,
},
{
id: 'rev-l-014',
reviewerName: 'Naveen Choudhary',
reviewerEmail: 'naveen.c@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Naveen',
eventName: 'Delhi Tech Summit',
eventId: 'evt-002',
eventDate: '2026-03-20',
rating: 5,
reviewText: 'Top-tier tech conference! The keynote on quantum computing was mind-blowing. Best investment of my time this quarter.',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2),
status: 'live',
reviewerRank: 'Legend',
reviewerTotalReviews: 41,
},
{
id: 'rev-l-015',
reviewerName: 'Shalini Mishra',
reviewerEmail: 'shalini.m@gmail.com',
reviewerAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Shalini',
eventName: 'Jaipur Literature Fest',
eventId: 'evt-005',
eventDate: '2026-02-20',
rating: 4,
reviewText: 'Literary heaven in the Pink City! The poetry sessions were deeply moving. Met my favorite author and got a signed copy. Unforgettable!',
submissionDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 10),
status: 'live',
reviewerRank: 'Champion',
reviewerTotalReviews: 27,
},
];
export const mockReviewMetrics: ReviewMetrics = {
totalPending: 12,
liveReviews: 1245,
rejected: 34,
};

View File

@@ -0,0 +1,219 @@
'use client';
import { useState, useEffect } from 'react';
import { GatewayProvider, GatewayCredentials } from '@/lib/types/settings';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { verifyGatewayCredentials, saveGatewayConfig } from '@/lib/actions/payment-settings';
import { toast } from 'sonner';
import { Lock, CheckBase, Loader2, Copy } from 'lucide-react';
import { decrypt } from '@/lib/payment-encryption';
interface GatewayConfigSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: GatewayProvider;
initialConfig: GatewayCredentials;
onSave: () => void;
}
export function GatewayConfigSheet({ open, onOpenChange, provider, initialConfig, onSave }: GatewayConfigSheetProps) {
const [config, setConfig] = useState<GatewayCredentials>(initialConfig);
const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(false);
// Reset config when provider changes
useEffect(() => {
// Decrypt sensitive fields for editing
const decrypted = { ...initialConfig };
if (decrypted.salt) decrypted.salt = decrypt(decrypted.salt);
setConfig(decrypted);
}, [provider, initialConfig, open]);
const handleVerify = async () => {
setVerifying(true);
try {
const res = await verifyGatewayCredentials(provider, config);
if (res.success) {
toast.success('Credentials Verified', { description: res.message });
} else {
toast.error('Verification Failed', { description: res.message });
}
} catch (error) {
toast.error('Verification Error');
} finally {
setVerifying(false);
}
};
const handleSave = async () => {
setLoading(true);
try {
const res = await saveGatewayConfig(provider, config);
if (res.success) {
toast.success(`Saved ${provider} configuration`);
onSave();
onOpenChange(false);
} else {
toast.error(res.message);
}
} catch (error) {
toast.error('Failed to save configuration');
} finally {
setLoading(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[400px] sm:w-[540px] overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2 capitalize">
{provider} Configuration
</SheetTitle>
<SheetDescription>
Configure API keys and secrets for {provider}.
</SheetDescription>
</SheetHeader>
<div className="py-6 space-y-6">
{/* Environment Toggle */}
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/30">
<div className="space-y-0.5">
<Label>Mode</Label>
<div className="text-xs text-muted-foreground">
{config.mode === 'live' ? 'Production / Live Traffic' : 'Sandbox / Test Mode'}
</div>
</div>
<Switch
checked={config.mode === 'live'}
onCheckedChange={(c) => setConfig({ ...config, mode: c ? 'live' : 'test' })}
/>
</div>
<Tabs defaultValue="credentials">
<TabsList className="w-full">
<TabsTrigger value="credentials" className="flex-1">Credentials</TabsTrigger>
<TabsTrigger value="features" className="flex-1">Features</TabsTrigger>
<TabsTrigger value="webhooks" className="flex-1">Webhooks</TabsTrigger>
</TabsList>
<TabsContent value="credentials" className="space-y-4 pt-4">
{/* Dynamic inputs based on provider */}
{(provider === 'razorpay' || provider === 'easebuzz') && (
<div className="space-y-2">
<Label>Key ID / Merchant Key</Label>
<Input
value={config.keyId || config.merchantId || ''}
onChange={(e) => setConfig({ ...config, keyId: e.target.value, merchantId: e.target.value })}
placeholder="rzp_test_..."
/>
</div>
)}
{(provider === 'stripe') && (
<div className="space-y-2">
<Label>Public Key</Label>
<Input
value={config.publicKey || ''}
onChange={(e) => setConfig({ ...config, publicKey: e.target.value })}
placeholder="pk_test_..."
/>
</div>
)}
{(provider === 'payu' || provider === 'easebuzz') && (
<div className="space-y-2">
<Label>Salt / Secret Key</Label>
<div className="relative">
<Input
type="password"
value={config.salt || ''}
onChange={(e) => setConfig({ ...config, salt: e.target.value })}
placeholder="Enter secret salt"
className="pr-10"
/>
<Lock className="absolute right-3 top-3 h-4 w-4 text-muted-foreground" />
</div>
</div>
)}
<Button variant="secondary" className="w-full mt-4" onClick={handleVerify} disabled={verifying}>
{verifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Test Credentials
</Button>
</TabsContent>
<TabsContent value="features" className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2 border p-3 rounded-md">
<Switch
id="feat-nb"
checked={config.features.netbanking}
onCheckedChange={(c) => setConfig({ ...config, features: { ...config.features, netbanking: c } })}
/>
<Label htmlFor="feat-nb">Netbanking</Label>
</div>
<div className="flex items-center space-x-2 border p-3 rounded-md">
<Switch
id="feat-upi"
checked={config.features.upi}
onCheckedChange={(c) => setConfig({ ...config, features: { ...config.features, upi: c } })}
/>
<Label htmlFor="feat-upi">UPI</Label>
</div>
<div className="flex items-center space-x-2 border p-3 rounded-md">
<Switch
id="feat-cards"
checked={config.features.cards}
onCheckedChange={(c) => setConfig({ ...config, features: { ...config.features, cards: c } })}
/>
<Label htmlFor="feat-cards">Cards</Label>
</div>
<div className="flex items-center space-x-2 border p-3 rounded-md">
<Switch
id="feat-emi"
checked={config.features.emi}
onCheckedChange={(c) => setConfig({ ...config, features: { ...config.features, emi: c } })}
/>
<Label htmlFor="feat-emi">EMI</Label>
</div>
</div>
</TabsContent>
<TabsContent value="webhooks" className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Webhook URL</Label>
<div className="flex items-center gap-2">
<Input readOnly value={`https://api.eventify.com/webhooks/${provider}`} />
<Button variant="outline" size="icon">
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Add this URL to your provider dashboard.</p>
</div>
<div className="space-y-2">
<Label>Webhook Secret</Label>
<Input
value={config.webhookSecret || ''}
onChange={(e) => setConfig({ ...config, webhookSecret: e.target.value })}
placeholder="whsec_..."
/>
</div>
</TabsContent>
</Tabs>
</div>
<SheetFooter>
<Button onClick={handleSave} disabled={loading} className="w-full sm:w-auto">
{loading ? 'Saving...' : 'Save Configuration'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import { useEffect, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card } from '@/components/ui/card';
import { getSystemSettings } from '@/lib/actions/settings';
import { GlobalSettings, DEFAULT_SETTINGS } from '@/lib/types/settings';
import { OrganizationSettings } from './tabs/OrganizationSettings';
import { PublicAppConfigTab } from './tabs/PublicAppConfig';
import { PartnerGovernanceTab } from './tabs/PartnerGovernance';
import { SystemHealthTab } from './tabs/SystemHealth';
import { PaymentConfigTab } from './tabs/PaymentConfig';
import { TeamTreeView } from './tabs/TeamTreeView';
import { StaffDirectory } from './tabs/StaffDirectory';
import { Loader2, Settings, Smartphone, Building2, Handshake, Server, CreditCard, Network, UserCog } from 'lucide-react';
export function SettingsLayout() {
const [settings, setSettings] = useState<GlobalSettings | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadSettings() {
try {
const res = await getSystemSettings();
if (res.success) {
setSettings(res.data);
} else {
setSettings(DEFAULT_SETTINGS); // Fallback
}
} catch (err) {
console.error(err);
setSettings(DEFAULT_SETTINGS);
} finally {
setLoading(false);
}
}
loadSettings();
}, []);
const handleUpdate = (section: keyof GlobalSettings, data: any) => {
if (!settings) return;
setSettings({
...settings,
[section]: { ...settings[section], ...data }
});
};
if (loading || !settings) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin mb-4" />
<p>Loading system configuration...</p>
</div>
);
}
return (
<div className="flex flex-col h-full space-y-6 container mx-auto p-6 max-w-7xl">
<div className="flex items-center justify-between pb-2">
<div>
<h1 className="text-3xl font-bold tracking-tight">System Settings</h1>
<p className="text-muted-foreground">
Global configuration for User App, Partner Dashboard, and Backoffice.
</p>
</div>
</div>
<Tabs defaultValue="organization" orientation="vertical" className="flex-1 w-full flex flex-col md:flex-row gap-8">
<Card className="md:w-64 flex-shrink-0 h-fit bg-muted/30 p-2 border shadow-sm">
<TabsList className="bg-transparent flex flex-col h-auto items-start w-full space-y-1 p-0">
<TabsTrigger
value="organization"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<Building2 className="h-4 w-4 mr-2" />
Organization
</TabsTrigger>
<TabsTrigger
value="payment"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<CreditCard className="h-4 w-4 mr-2" />
Payment Gateways
</TabsTrigger>
<TabsTrigger
value="public-app"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<Smartphone className="h-4 w-4 mr-2" />
Public App
</TabsTrigger>
<TabsTrigger
value="partner-governance"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<Handshake className="h-4 w-4 mr-2" />
Partner Governance
</TabsTrigger>
<TabsTrigger
value="system-health"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<Server className="h-4 w-4 mr-2" />
System Health
</TabsTrigger>
<div className="w-full h-px bg-border my-2" />
<p className="px-3 text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Access Management</p>
<TabsTrigger
value="teams"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<Network className="h-4 w-4 mr-2" />
Teams
</TabsTrigger>
<TabsTrigger
value="staff"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<UserCog className="h-4 w-4 mr-2" />
Staff Directory
</TabsTrigger>
</TabsList>
</Card>
<div className="flex-1 min-w-0">
<TabsContent value="organization" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<OrganizationSettings
orgConfig={settings.organization}
securityConfig={settings.security}
onUpdate={handleUpdate}
/>
</TabsContent>
<TabsContent value="payment" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<PaymentConfigTab
config={settings.payment}
onUpdate={(data) => handleUpdate('payment', data)}
/>
</TabsContent>
<TabsContent value="public-app" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<PublicAppConfigTab
config={settings.publicApp}
onUpdate={(data) => handleUpdate('publicApp', data)}
/>
</TabsContent>
<TabsContent value="partner-governance" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<PartnerGovernanceTab
config={settings.partner}
onUpdate={(data) => handleUpdate('partner', data)}
/>
</TabsContent>
<TabsContent value="system-health" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<SystemHealthTab
config={settings.system}
onUpdate={(data) => handleUpdate('system', data)}
/>
</TabsContent>
<TabsContent value="teams" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<TeamTreeView />
</TabsContent>
<TabsContent value="staff" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<StaffDirectory />
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { getScopesByCategory } from '@/lib/types/staff';
import { createDepartment } from '@/lib/actions/staff-management';
import { toast } from 'sonner';
import { Loader2, Plus } from 'lucide-react';
const DEPT_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'];
interface CreateDepartmentDialogProps {
onCreated: () => void;
}
export function CreateDepartmentDialog({ onCreated }: CreateDepartmentDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
const [color, setColor] = useState(DEPT_COLORS[0]);
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const scopesByCategory = getScopesByCategory();
const toggleScope = (scope: string) => {
setSelectedScopes(prev => prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]);
};
const handleSubmit = async () => {
if (!name.trim()) { toast.error('Department name is required'); return; }
setLoading(true);
try {
const res = await createDepartment({ name, slug, baseScopes: selectedScopes, color, description });
if (res.success) {
toast.success(`Department "${name}" created`);
setOpen(false);
setName(''); setDescription(''); setSelectedScopes([]);
onCreated();
}
} catch { toast.error('Failed to create department'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" /> New Department
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Department</DialogTitle>
<DialogDescription>Define a new organizational unit with base permissions.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Department Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Customer Support" />
{slug && <p className="text-xs text-muted-foreground">Slug: <code>{slug}</code></p>}
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="What does this department handle?" />
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex gap-2">
{DEPT_COLORS.map(c => (
<button key={c} onClick={() => setColor(c)}
className={`w-8 h-8 rounded-full border-2 transition-transform ${color === c ? 'border-foreground scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
<div className="space-y-3">
<Label>Base Permissions</Label>
<p className="text-xs text-muted-foreground">All members in this department will inherit these permissions.</p>
{Object.entries(scopesByCategory).map(([category, scopes]) => (
<div key={category} className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{category}</p>
<div className="grid grid-cols-2 gap-2">
{scopes.map(({ scope, label }) => (
<label key={scope} className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50">
<Checkbox checked={selectedScopes.includes(scope)} onCheckedChange={() => toggleScope(scope)} />
{label}
</label>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Department
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
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 { Department, StaffMember, getScopesByCategory } from '@/lib/types/staff';
import { createSquad } from '@/lib/actions/staff-management';
import { toast } from 'sonner';
import { Loader2, Plus } from 'lucide-react';
interface CreateSquadDialogProps {
departments: Department[];
staff: StaffMember[];
onCreated: () => void;
}
export function CreateSquadDialog({ departments, staff, onCreated }: CreateSquadDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [name, setName] = useState('');
const [departmentId, setDepartmentId] = useState('');
const [managerId, setManagerId] = useState('');
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
const scopesByCategory = getScopesByCategory();
// Filter staff by selected department for manager selection
const availableManagers = staff.filter(s =>
(s.departmentId === departmentId || !s.departmentId) && s.status === 'active'
);
const toggleScope = (scope: string) => {
setSelectedScopes(prev => prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]);
};
const handleSubmit = async () => {
if (!name.trim()) { toast.error('Squad name is required'); return; }
if (!departmentId) { toast.error('Select a parent department'); return; }
setLoading(true);
try {
const res = await createSquad({ name, departmentId, managerId: managerId || undefined, extraScopes: selectedScopes });
if (res.success) {
toast.success(`Squad "${name}" created`);
setOpen(false);
setName(''); setDepartmentId(''); setManagerId(''); setSelectedScopes([]);
onCreated();
}
} catch { toast.error('Failed to create squad'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" /> New Squad
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Squad</DialogTitle>
<DialogDescription>Create a sub-team within a department with additive permissions.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Squad Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. L1 - First Response" />
</div>
<div className="space-y-2">
<Label>Parent Department</Label>
<Select value={departmentId} onValueChange={setDepartmentId}>
<SelectTrigger><SelectValue placeholder="Select department" /></SelectTrigger>
<SelectContent>
{departments.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Squad Manager (Optional)</Label>
<Select value={managerId} onValueChange={setManagerId}>
<SelectTrigger><SelectValue placeholder="Assign a lead" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">No Manager</SelectItem>
{availableManagers.map(s => <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Extra Permissions</Label>
<p className="text-xs text-muted-foreground">These are <strong>additive</strong> on top of the department's base scopes.</p>
{Object.entries(scopesByCategory).map(([category, scopes]) => (
<div key={category} className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{category}</p>
<div className="grid grid-cols-2 gap-2">
{scopes.map(({ scope, label }) => (
<label key={scope} className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50">
<Checkbox checked={selectedScopes.includes(scope)} onCheckedChange={() => toggleScope(scope)} />
{label}
</label>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Squad
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Department, Squad, StaffRole } from '@/lib/types/staff';
import { inviteStaff } from '@/lib/actions/staff-management';
import { toast } from 'sonner';
import { Loader2, UserPlus } from 'lucide-react';
interface InviteStaffDialogProps {
departments: Department[];
squads: Squad[];
onInvited: () => void;
}
export function InviteStaffDialog({ departments, squads, onInvited }: InviteStaffDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [departmentId, setDepartmentId] = useState('');
const [squadId, setSquadId] = useState('');
const [role, setRole] = useState<StaffRole>('MEMBER');
const filteredSquads = squads.filter(s => s.departmentId === departmentId);
const handleSubmit = async () => {
if (!name.trim() || !email.trim()) { toast.error('Name and email are required'); return; }
if (!departmentId || !squadId) { toast.error('Select a department and squad'); return; }
setLoading(true);
try {
const res = await inviteStaff({ name, email, departmentId, squadId, role });
if (res.success) {
toast.success('Invite Sent', { description: res.message });
setOpen(false);
setName(''); setEmail(''); setDepartmentId(''); setSquadId(''); setRole('MEMBER');
onInvited();
} else {
toast.error(res.message);
}
} catch { toast.error('Failed to send invite'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="h-4 w-4 mr-1" /> Invite Staff
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>Send a magic link invitation to join the team.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Full Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Neha Sharma" />
</div>
<div className="space-y-2">
<Label>Email Address</Label>
<Input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="neha@eventify.com" />
</div>
<div className="space-y-2">
<Label>Department</Label>
<Select value={departmentId} onValueChange={(v) => { setDepartmentId(v); setSquadId(''); }}>
<SelectTrigger><SelectValue placeholder="Select department" /></SelectTrigger>
<SelectContent>
{departments.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Squad</Label>
<Select value={squadId} onValueChange={setSquadId} disabled={!departmentId}>
<SelectTrigger><SelectValue placeholder={departmentId ? "Select squad" : "Select department first"} /></SelectTrigger>
<SelectContent>
{filteredSquads.map(s => <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as StaffRole)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="MANAGER">Manager</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Send Invitation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { OrganizationConfig, SecurityConfig } from '@/lib/types/settings';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { updateSystemSetting } from '@/lib/actions/settings';
import { toast } from 'sonner';
import { Building2, ShieldCheck, Mail, Globe } from 'lucide-react';
interface OrganizationSettingsProps {
orgConfig: OrganizationConfig;
securityConfig: SecurityConfig;
onUpdate: (section: 'organization' | 'security', data: any) => void;
}
export function OrganizationSettings({ orgConfig, securityConfig, onUpdate }: OrganizationSettingsProps) {
const [loading, setLoading] = useState(false);
// Internal state for form usage
const [orgState, setOrgState] = useState(orgConfig);
const [secState, setSecState] = useState(securityConfig);
const handleSaveOrg = async () => {
setLoading(true);
try {
const res = await updateSystemSetting('organization', orgState);
if (res.success) {
toast.success('Organization profile updated');
onUpdate('organization', res.updatedSettings.organization);
}
} catch (error) {
toast.error('Failed to update organization settings');
} finally {
setLoading(false);
}
};
const handleSaveSecurity = async () => {
setLoading(true);
try {
const res = await updateSystemSetting('security', secState);
if (res.success) {
toast.success('Security policy updated');
onUpdate('security', res.updatedSettings.security);
}
} catch (error) {
toast.error('Failed to update security settings');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
Organization Profile
</CardTitle>
<CardDescription>
General branding and contact details for the control center and emails.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Brand Name</Label>
<Input
value={orgState.brandName}
onChange={(e) => setOrgState({ ...orgState, brandName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Support Email</Label>
<div className="relative">
<Mail className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
value={orgState.supportEmail}
onChange={(e) => setOrgState({ ...orgState, supportEmail: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label>Legal Address</Label>
<Input
value={orgState.legalAddress}
onChange={(e) => setOrgState({ ...orgState, legalAddress: e.target.value })}
/>
</div>
<div className="flex justify-end mt-4">
<Button onClick={handleSaveOrg} disabled={loading}>
{loading ? 'Saving...' : 'Save Profile'}
</Button>
</div>
</CardContent>
</Card>
<Card className="border-orange-200 bg-orange-50/10">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-orange-600" />
Security Policy
</CardTitle>
<CardDescription>
Enforce security rules for all Control Center staff members.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between p-4 border rounded-lg bg-card">
<div className="space-y-0.5">
<Label className="text-base">Enforce 2FA</Label>
<p className="text-sm text-muted-foreground">
Require Two-Factor Authentication for all admin accounts.
</p>
</div>
<Switch
checked={secState.enforce2FA}
onCheckedChange={(checked) => setSecState({ ...secState, enforce2FA: checked })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Session Timeout (Minutes)</Label>
<Input
type="number"
value={secState.sessionTimeoutMinutes}
onChange={(e) => setSecState({ ...secState, sessionTimeoutMinutes: parseInt(e.target.value) })}
/>
</div>
<div className="space-y-2">
<Label>Password Expiration (Days)</Label>
<Input
type="number"
value={secState.passwordExpirationDays}
onChange={(e) => setSecState({ ...secState, passwordExpirationDays: parseInt(e.target.value) })}
/>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={handleSaveSecurity} disabled={loading} className="border-orange-200 text-orange-700 hover:bg-orange-50 hover:text-orange-800">
{loading ? 'Saving...' : 'Update Security Policy'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import { PartnerConfig } from '@/lib/types/settings';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { updateSystemSetting } from '@/lib/actions/settings';
import { toast } from 'sonner';
import { Handshake, Banknote, FileCheck } from 'lucide-react';
interface PartnerGovernanceProps {
config: PartnerConfig;
onUpdate: (data: PartnerConfig) => void;
}
export function PartnerGovernanceTab({ config, onUpdate }: PartnerGovernanceProps) {
const [loading, setLoading] = useState(false);
const [state, setState] = useState(config);
const handleSave = async () => {
setLoading(true);
try {
const res = await updateSystemSetting('partner', state);
if (res.success && res.updatedSettings.partner) {
toast.success('Partner governance rules updated');
onUpdate(res.updatedSettings.partner);
}
} catch (error) {
toast.error('Failed to update partner settings');
} finally {
setLoading(false);
}
};
const toggleKycDoc = (doc: 'pan' | 'gst' | 'aadhaar' | 'cheque') => {
const current = state.allowedKycDocs;
const updated = current.includes(doc)
? current.filter(d => d !== doc)
: [...current, doc];
setState({ ...state, allowedKycDocs: updated });
};
return (
<div className="space-y-6">
<div className="grid md:grid-cols-2 gap-6">
{/* Onboarding Rules */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Handshake className="h-5 w-5 text-indigo-500" />
Onboarding & Approval
</CardTitle>
<CardDescription>
Set rules for new organizer accounts and events.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between py-2 border-b">
<div className="space-y-0.5">
<Label>Require KYC Approval</Label>
<p className="text-xs text-muted-foreground">Block payouts until verified.</p>
</div>
<Switch
checked={state.requireKyc}
onCheckedChange={(c) => setState({ ...state, requireKyc: c })}
/>
</div>
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label>Manual Event Approval</Label>
<p className="text-xs text-muted-foreground">Admins must approve live events.</p>
</div>
<Switch
checked={state.manualEventApproval}
onCheckedChange={(c) => setState({ ...state, manualEventApproval: c })}
/>
</div>
</CardContent>
</Card>
{/* Commission Model */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Banknote className="h-5 w-5 text-green-600" />
Commission & Payouts
</CardTitle>
<CardDescription>
Define the default revenue share model.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Default Commission (%)</Label>
<Input
type="number"
value={state.defaultCommissionPercent}
onChange={(e) => setState({ ...state, defaultCommissionPercent: Number(e.target.value) })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Payout Schedule</Label>
<Select
value={state.payoutSchedule}
onValueChange={(v: any) => setState({ ...state, payoutSchedule: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily (T+1)</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="manual">Manual Request</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Min. Payout Amount ()</Label>
<Input
type="number"
value={state.minPayoutAmount}
onChange={(e) => setState({ ...state, minPayoutAmount: Number(e.target.value) })}
/>
</div>
</div>
</CardContent>
</Card>
</div>
{/* KYC Requirements */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" />
KYC Requirements
</CardTitle>
<CardDescription>
Select documents required for organizer verification.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{['pan', 'gst', 'aadhaar', 'cheque'].map((doc) => (
<div key={doc} className="flex items-center space-x-2 border p-3 rounded-md">
<Checkbox
id={`doc-${doc}`}
checked={state.allowedKycDocs.includes(doc as any)}
onCheckedChange={() => toggleKycDoc(doc as any)}
/>
<Label htmlFor={`doc-${doc}`} className="uppercase text-xs font-semibold">
{doc}
</Label>
</div>
))}
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={loading}>
{loading ? 'Saving...' : 'Update Governance Rules'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState } from 'react';
import { PaymentConfig, GatewayProvider, GatewayCredentials } from '@/lib/types/settings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { GatewayConfigSheet } from '@/features/settings/components/GatewayConfigSheet';
import { updateRoutingRules } from '@/lib/actions/payment-settings';
import { toast } from 'sonner';
import { CreditCard, Globe, Settings2, ShieldCheck } from 'lucide-react';
interface PaymentConfigTabProps {
config: PaymentConfig;
onUpdate: (data: PaymentConfig) => void;
}
const PROVIDERS: { id: GatewayProvider; name: string }[] = [
{ id: 'razorpay', name: 'Razorpay' },
{ id: 'stripe', name: 'Stripe' },
{ id: 'payu', name: 'PayU' },
{ id: 'easebuzz', name: 'Easebuzz' },
{ id: 'worldline', name: 'Worldline' },
];
export function PaymentConfigTab({ config, onUpdate }: PaymentConfigTabProps) {
const [selectedProvider, setSelectedProvider] = useState<GatewayProvider | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [routingState, setRoutingState] = useState(config);
const handleOpenConfig = (provider: GatewayProvider) => {
setSelectedProvider(provider);
setSheetOpen(true);
};
const handleRoutingUpdate = async (key: keyof PaymentConfig, value: string) => {
const newState = { ...routingState, [key]: value };
setRoutingState(newState);
try {
const res = await updateRoutingRules({
defaultGateway: newState.defaultGateway,
fallbackGateway: newState.fallbackGateway,
internationalGateway: newState.internationalGateway
});
if (res.success) {
toast.success('Routing rules updated');
// Optimistic update handled by local state, but we should propagate up
onUpdate(newState);
}
} catch (error) {
toast.error('Failed to update routing');
}
};
return (
<div className="space-y-8">
{/* Gateway Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{PROVIDERS.map((provider) => {
const gwConfig = config.gateways[provider.id];
const isActive = gwConfig?.enabled;
const isLive = gwConfig?.mode === 'live';
return (
<Card key={provider.id} className={`group hover:shadow-md transition-shadow ${isActive ? 'border-primary/50 bg-primary/5' : 'opacity-75'}`}>
<CardHeader className="flex flex-row items-start justify-between pb-2">
<CardTitle className="text-lg font-bold">{provider.name}</CardTitle>
{isActive ? (
<Badge variant={isLive ? 'default' : 'secondary'} className={isLive ? 'bg-emerald-600' : ''}>
{isLive ? 'LIVE' : 'TEST'}
</Badge>
) : (
<Badge variant="outline">Disabled</Badge>
)}
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground space-y-1">
{isActive ? (
<>
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-emerald-500" />
Verified
</div>
<div className="flex gap-2 mt-2">
{gwConfig.features.upi && <Badge variant="outline" className="text-[10px]">UPI</Badge>}
{gwConfig.features.cards && <Badge variant="outline" className="text-[10px]">Cards</Badge>}
{gwConfig.features.emi && <Badge variant="outline" className="text-[10px]">EMI</Badge>}
</div>
</>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
Not configured
</div>
)}
</div>
</CardContent>
<CardFooter>
<Button
variant={isActive ? "outline" : "secondary"}
className="w-full"
onClick={() => handleOpenConfig(provider.id)}
>
<Settings2 className="h-4 w-4 mr-2" />
Configure
</Button>
</CardFooter>
</Card>
);
})}
</div>
{/* Routing Logic */}
<Card className="border-indigo-200 bg-indigo-50/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-indigo-700">
<Globe className="h-5 w-5" />
Smart Routing Logic
</CardTitle>
<CardDescription>
Define how transactions are routed across active gateways.
</CardDescription>
</CardHeader>
<CardContent className="grid md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label>Default Gateway</Label>
<Select
value={routingState.defaultGateway}
onValueChange={(v) => handleRoutingUpdate('defaultGateway', v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Used for 90% of traffic.</p>
</div>
<div className="space-y-2">
<Label>Fallback Gateway</Label>
<Select
value={routingState.fallbackGateway}
onValueChange={(v) => handleRoutingUpdate('fallbackGateway', v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Used if default fails.</p>
</div>
<div className="space-y-2">
<Label>International (Non-INR)</Label>
<Select
value={routingState.internationalGateway}
onValueChange={(v) => handleRoutingUpdate('internationalGateway', v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Cards issued outside India.</p>
</div>
</CardContent>
</Card>
{/* Config Sheet Instance */}
{selectedProvider && config.gateways[selectedProvider] && (
<GatewayConfigSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
provider={selectedProvider}
initialConfig={config.gateways[selectedProvider]}
onSave={() => {
// Refresh parent data? Handled via Actions and upper state update usually,
// but for now we rely on the parent's polling or manual refresh,
// effectively we might want to trigger a callback here.
// Ideally onUpdate would trigger a re-fetch.
window.location.reload(); // Quick dirty refresh to sync state in this demo
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import { PublicAppConfig } from '@/lib/types/settings';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { updateSystemSetting } from '@/lib/actions/settings';
import { toast } from 'sonner';
import { Smartphone, Zap, Percent, Link as LinkIcon } from 'lucide-react';
interface PublicAppConfigProps {
config: PublicAppConfig;
onUpdate: (data: PublicAppConfig) => void;
}
export function PublicAppConfigTab({ config, onUpdate }: PublicAppConfigProps) {
const [loading, setLoading] = useState(false);
const [state, setState] = useState(config);
const handleSave = async () => {
setLoading(true);
try {
const res = await updateSystemSetting('publicApp', state);
if (res.success && res.updatedSettings.publicApp) {
toast.success('Public App configuration updated');
onUpdate(res.updatedSettings.publicApp);
}
} catch (error) {
toast.error('Failed to update app config');
} finally {
setLoading(false);
}
};
// Helper to update deeply nested feature flags
const toggleFeature = (key: keyof PublicAppConfig['betaFeatures']) => {
setState(prev => ({
...prev,
betaFeatures: {
...prev.betaFeatures,
[key]: !prev.betaFeatures[key]
}
}));
};
return (
<div className="space-y-6">
<div className="grid md:grid-cols-2 gap-6">
{/* Feature Flags */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" />
Feature Flags
</CardTitle>
<CardDescription>
Toggle beta features for end-users instantly.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between py-2 border-b">
<div className="space-y-0.5">
<Label>Crypto Payments</Label>
<p className="text-xs text-muted-foreground">Accept ETH/SOL via Solana Pay</p>
</div>
<Switch
checked={state.betaFeatures.cryptoPayments}
onCheckedChange={() => toggleFeature('cryptoPayments')}
/>
</div>
<div className="flex items-center justify-between py-2 border-b">
<div className="space-y-0.5">
<Label>AI Recommendations</Label>
<p className="text-xs text-muted-foreground">Show "Events you might like"</p>
</div>
<Switch
checked={state.betaFeatures.aiRecommendations}
onCheckedChange={() => toggleFeature('aiRecommendations')}
/>
</div>
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label>Social Login</Label>
<p className="text-xs text-muted-foreground">Enable Google/Apple Auth</p>
</div>
<Switch
checked={state.betaFeatures.socialLogin}
onCheckedChange={() => toggleFeature('socialLogin')}
/>
</div>
</CardContent>
</Card>
{/* Commercials */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Percent className="h-5 w-5 text-emerald-600" />
Booking Fees & Tax
</CardTitle>
<CardDescription>
Set the global platform fee charged to users.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Platform Fee (Flat )</Label>
<Input
type="number"
value={state.fees.platformFeeFlat}
onChange={(e) => setState({
...state,
fees: { ...state.fees, platformFeeFlat: Number(e.target.value) }
})}
/>
<p className="text-xs text-muted-foreground">
Added to every ticket purchase.
</p>
</div>
<div className="space-y-2">
<Label>GST / Tax Rate (%)</Label>
<Input
type="number"
value={state.fees.taxRatePercent}
onChange={(e) => setState({
...state,
fees: { ...state.fees, taxRatePercent: Number(e.target.value) }
})}
/>
</div>
</CardContent>
</Card>
</div>
{/* Links */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LinkIcon className="h-5 w-5 text-blue-500" />
Support Links
</CardTitle>
<CardDescription>
Update external URLs for help, terms, and privacy policies.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Help Center URL</Label>
<Input
value={state.links.helpCenterUrl}
onChange={(e) => setState({
...state,
links: { ...state.links, helpCenterUrl: e.target.value }
})}
/>
</div>
<div className="space-y-2">
<Label>Terms & Conditions URL</Label>
<Input
value={state.links.termsUrl}
onChange={(e) => setState({
...state,
links: { ...state.links, termsUrl: e.target.value }
})}
/>
</div>
<div className="space-y-2">
<Label>Privacy Policy URL</Label>
<Input
value={state.links.privacyUrl}
onChange={(e) => setState({
...state,
links: { ...state.links, privacyUrl: e.target.value }
})}
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end sticky bottom-6 z-10">
<Button size="lg" onClick={handleSave} disabled={loading} className="shadow-lg">
{loading ? 'Publishing Updates...' : 'Publish Public App Config'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect } from 'react';
import { Department, Squad, StaffMember, SCOPE_DEFINITIONS } from '@/lib/types/staff';
import { getStaff, getDepartments, getSquads, updateStaffRole, moveStaff, deactivateStaff } from '@/lib/actions/staff-management';
import { getEffectiveScopes } from '@/lib/auth/permissions';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { InviteStaffDialog } from '../dialogs/InviteStaffDialog';
import { toast } from 'sonner';
import { Loader2, Search, MoreHorizontal, UserCog, ArrowRightLeft, UserX, Crown, Shield, ShieldCheck, Eye } from 'lucide-react';
export function StaffDirectory() {
const [staff, setStaff] = useState<StaffMember[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [squads, setSquads] = useState<Squad[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterDept, setFilterDept] = useState('all');
const [filterRole, setFilterRole] = useState('all');
const loadData = async () => {
setLoading(true);
try {
const [staffRes, deptRes, sqRes] = await Promise.all([getStaff(), getDepartments(), getSquads()]);
if (staffRes.success) setStaff(staffRes.data);
if (deptRes.success) setDepartments(deptRes.data);
if (sqRes.success) setSquads(sqRes.data);
} finally { setLoading(false); }
};
useEffect(() => { loadData(); }, []);
const getDeptName = (id: string | null) => departments.find(d => d.id === id)?.name || '—';
const getDeptColor = (id: string | null) => departments.find(d => d.id === id)?.color || '#666';
const getSquadName = (id: string | null) => squads.find(s => s.id === id)?.name || '—';
const handleRoleChange = async (staffId: string, newRole: 'MANAGER' | 'MEMBER') => {
const res = await updateStaffRole(staffId, newRole);
if (res.success) { toast.success(res.message); loadData(); }
else toast.error(res.message);
};
const handleMove = async (staffId: string, newSquadId: string) => {
const res = await moveStaff(staffId, newSquadId);
if (res.success) { toast.success(res.message); loadData(); }
else toast.error(res.message);
};
const handleDeactivate = async (staffId: string) => {
const res = await deactivateStaff(staffId);
if (res.success) { toast.success('Staff deactivated'); loadData(); }
else toast.error(res.message);
};
const filtered = staff.filter(s => {
if (search && !s.name.toLowerCase().includes(search.toLowerCase()) && !s.email.toLowerCase().includes(search.toLowerCase())) return false;
if (filterDept !== 'all' && s.departmentId !== filterDept) return false;
if (filterRole !== 'all' && s.role !== filterRole) return false;
return true;
});
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" /> Loading staff directory...
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Staff Directory</h2>
<p className="text-sm text-muted-foreground">{staff.filter(s => s.status === 'active').length} active, {staff.filter(s => s.status === 'invited').length} invited</p>
</div>
<InviteStaffDialog departments={departments} squads={squads} onInvited={loadData} />
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search by name or email..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
</div>
<Select value={filterDept} onValueChange={setFilterDept}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder="Department" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterRole} onValueChange={setFilterRole}>
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Role" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
<SelectItem value="MANAGER">Manager</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Department</th>
<th className="px-4 py-3">Squad</th>
<th className="px-4 py-3">Role</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{filtered.map(member => (
<tr key={member.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary/30 to-primary/5 flex items-center justify-center text-xs font-bold text-primary">
{member.name.split(' ').map(n => n[0]).join('')}
</div>
<span className="font-medium text-sm">{member.name}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{member.email}</td>
<td className="px-4 py-3">
{member.departmentId ? (
<Badge variant="outline" className="text-[11px] gap-1" style={{ borderColor: getDeptColor(member.departmentId) + '60', color: getDeptColor(member.departmentId) }}>
{getDeptName(member.departmentId)}
</Badge>
) : <span className="text-sm text-muted-foreground"></span>}
</td>
<td className="px-4 py-3 text-sm">{getSquadName(member.squadId)}</td>
<td className="px-4 py-3">
{member.role === 'SUPER_ADMIN' && <Badge className="bg-red-500/10 text-red-600 border-red-200 text-[10px]"><ShieldCheck className="h-3 w-3 mr-1" />Super Admin</Badge>}
{member.role === 'MANAGER' && <Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-[10px]"><Crown className="h-3 w-3 mr-1" />Manager</Badge>}
{member.role === 'MEMBER' && <Badge variant="outline" className="text-[10px]">Member</Badge>}
</td>
<td className="px-4 py-3">
{member.status === 'active' && <Badge variant="outline" className="text-[10px] border-emerald-300 text-emerald-600">Active</Badge>}
{member.status === 'invited' && <Badge variant="outline" className="text-[10px] border-sky-300 text-sky-600">Invited</Badge>}
{member.status === 'deactivated' && <Badge variant="outline" className="text-[10px] border-red-300 text-red-500">Deactivated</Badge>}
</td>
<td className="px-4 py-3 text-right">
{member.role !== 'SUPER_ADMIN' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Manage</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleRoleChange(member.id, member.role === 'MANAGER' ? 'MEMBER' : 'MANAGER')}>
<UserCog className="h-4 w-4 mr-2" />
{member.role === 'MANAGER' ? 'Demote to Member' : 'Promote to Manager'}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowRightLeft className="h-4 w-4 mr-2" /> Move to Squad
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{squads.filter(s => s.id !== member.squadId).map(sq => (
<DropdownMenuItem key={sq.id} onClick={() => handleMove(member.id, sq.id)}>
{sq.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{member.status !== 'deactivated' && (
<DropdownMenuItem className="text-destructive" onClick={() => handleDeactivate(member.id)}>
<UserX className="h-4 w-4 mr-2" /> Deactivate
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={7} className="text-center py-8 text-muted-foreground">No staff found.</td></tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useState } from 'react';
import { SystemConfig } from '@/lib/types/settings';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { updateSystemSetting, purgeSystemCache } from '@/lib/actions/settings';
import { toast } from 'sonner';
import { Server, CreditCard, Radio, Trash2, AlertTriangle, Activity } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
interface SystemHealthProps {
config: SystemConfig;
onUpdate: (data: SystemConfig) => void;
}
export function SystemHealthTab({ config, onUpdate }: SystemHealthProps) {
const [loading, setLoading] = useState(false);
const [state, setState] = useState(config);
const [purgeLoading, setPurgeLoading] = useState(false);
const handleSave = async () => {
setLoading(true);
try {
const res = await updateSystemSetting('system', state);
if (res.success && res.updatedSettings.system) {
toast.success('System configuration updated');
onUpdate(res.updatedSettings.system);
}
} catch (error) {
toast.error('Failed to update system config');
} finally {
setLoading(false);
}
};
const handlePurgeCache = async () => {
setPurgeLoading(true);
try {
const res = await purgeSystemCache();
if (res.success) {
toast.success('Cache purged successfully', {
description: 'Changes are propagating to edge locations.'
});
}
} catch (error) {
toast.error('Failed to purge cache');
} finally {
setPurgeLoading(false);
}
};
return (
<div className="space-y-6">
{/* System Status Banner */}
<div className="grid md:grid-cols-3 gap-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">API Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-2xl font-bold">Operational</span>
</div>
<p className="text-xs text-muted-foreground mt-1">Uptime: 99.99%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Cache Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold">Healthy</span>
<Badge variant="outline">TTL: {state.cache.ttl}s</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
Last Purged: {state.cache.lastPurgedAt ? new Date(state.cache.lastPurgedAt).toLocaleTimeString() : 'Never'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Database</CardTitle>
</CardHeader>
<CardContent>
<span className="text-2xl font-bold">Connected</span>
<p className="text-xs text-muted-foreground mt-1">Latency: 24ms</p>
</CardContent>
</Card>
</div>
{/* Payment Gateways */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-primary" />
Payment Gateways
</CardTitle>
<CardDescription>
Manage gateway keys and active modes (Test vs Live).
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Stripe */}
<div className="p-4 border rounded-lg bg-card/50">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h4 className="font-semibold">Stripe</h4>
{state.gateways.stripe.mode === 'live' ?
<Badge className="bg-emerald-500">Live</Badge> :
<Badge variant="secondary">Test Mode</Badge>
}
</div>
<Switch
checked={state.gateways.stripe.enabled}
onCheckedChange={(c) => setState({
...state,
gateways: { ...state.gateways, stripe: { ...state.gateways.stripe, enabled: c } }
})}
/>
</div>
<div className="grid gap-2">
<Label>Public Key</Label>
<Input
type="password"
value={state.gateways.stripe.publicKey}
readOnly // For demo safety
className="font-mono text-xs"
/>
</div>
<div className="flex items-center gap-2 mt-4">
<Label className="text-xs">Mode:</Label>
<div className="flex items-center space-x-2">
<Switch
checked={state.gateways.stripe.mode === 'live'}
onCheckedChange={(c) => setState({
...state,
gateways: { ...state.gateways, stripe: { ...state.gateways.stripe, mode: c ? 'live' : 'test' } }
})}
/>
<span className={state.gateways.stripe.mode === 'live' ? 'font-bold text-red-600' : 'text-muted-foreground'}>
{state.gateways.stripe.mode === 'live' ? 'LIVE TRAFFIC' : 'Test Mode'}
</span>
</div>
</div>
</div>
{/* Razorpay */}
<div className="p-4 border rounded-lg bg-card/50">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h4 className="font-semibold">Razorpay</h4>
{state.gateways.razorpay.mode === 'live' ?
<Badge className="bg-emerald-500">Live</Badge> :
<Badge variant="secondary">Test Mode</Badge>
}
</div>
<Switch
checked={state.gateways.razorpay.enabled}
onCheckedChange={(c) => setState({
...state,
gateways: { ...state.gateways, razorpay: { ...state.gateways.razorpay, enabled: c } }
})}
/>
</div>
<div className="grid gap-2">
<Label>Key ID</Label>
<Input
type="password"
value={state.gateways.razorpay.keyId}
readOnly
className="font-mono text-xs"
/>
</div>
</div>
</CardContent>
<CardFooter className="justify-end border-t pt-4">
<Button onClick={handleSave} disabled={loading}>Save Gateway Config</Button>
</CardFooter>
</Card>
{/* Danger Zone */}
<h3 className="text-lg font-semibold text-red-600 flex items-center gap-2 mt-8">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</h3>
<div className="grid md:grid-cols-2 gap-6">
<Card className="border-red-200 bg-red-50/10">
<CardHeader>
<CardTitle className="text-base text-red-700">Maintenance Mode</CardTitle>
<CardDescription>
Disable the public facing app temporarily. Only admins can access.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-between items-center">
<div className="space-y-1">
<Label>Status</Label>
<p className="text-sm font-medium">
{false ? <span className="text-red-600">Active - App Offline</span> : <span className="text-emerald-600">Inactive - App Live</span>}
</p>
</div>
<Button variant="destructive">Enable Maintenance</Button>
</CardContent>
</Card>
<Card className="border-orange-200 bg-orange-50/10">
<CardHeader>
<CardTitle className="text-base text-orange-700">Cache Control</CardTitle>
<CardDescription>
Force purge the CDN cache for all static pages.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-between items-center">
<div className="space-y-1">
<Label>Metrics</Label>
<p className="text-xs text-muted-foreground">Items cached: ~14.2k</p>
</div>
<Button
variant="outline"
className="border-orange-300 text-orange-700 hover:bg-orange-100"
onClick={handlePurgeCache}
disabled={purgeLoading}
>
{purgeLoading ? (
<Activity className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Purge Cache
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect } from 'react';
import { Department, Squad, StaffMember, SCOPE_DEFINITIONS } from '@/lib/types/staff';
import { getOrgTree, getStaff } from '@/lib/actions/staff-management';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { CreateDepartmentDialog } from '../dialogs/CreateDepartmentDialog';
import { CreateSquadDialog } from '../dialogs/CreateSquadDialog';
import { InviteStaffDialog } from '../dialogs/InviteStaffDialog';
import { Loader2, ChevronRight, ChevronDown, Building2, Users, UserCircle, Crown, Shield, Plus } from 'lucide-react';
type OrgNode = Department & { squads: (Squad & { members: StaffMember[] })[] };
export function TeamTreeView() {
const [tree, setTree] = useState<OrgNode[]>([]);
const [allStaff, setAllStaff] = useState<StaffMember[]>([]);
const [allSquads, setAllSquads] = useState<Squad[]>([]);
const [loading, setLoading] = useState(true);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [expandedSquads, setExpandedSquads] = useState<Set<string>>(new Set());
const loadData = async () => {
setLoading(true);
try {
const [treeRes, staffRes] = await Promise.all([getOrgTree(), getStaff()]);
if (treeRes.success) {
setTree(treeRes.data);
// Collect all squads from tree for dialogs
const sq: Squad[] = [];
treeRes.data.forEach(d => d.squads.forEach(s => sq.push(s)));
setAllSquads(sq);
// Auto-expand first department
if (treeRes.data.length > 0) {
setExpandedDepts(new Set([treeRes.data[0].id]));
}
}
if (staffRes.success) setAllStaff(staffRes.data);
} finally { setLoading(false); }
};
useEffect(() => { loadData(); }, []);
const toggleDept = (id: string) => {
setExpandedDepts(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleSquad = (id: string) => {
setExpandedSquads(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" /> Loading organization tree...
</div>
);
}
return (
<div className="space-y-6">
{/* Actions */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Organization Chart</h2>
<p className="text-sm text-muted-foreground">Manage departments, squads, and team assignments.</p>
</div>
<div className="flex gap-2">
<CreateDepartmentDialog onCreated={loadData} />
<CreateSquadDialog departments={tree} staff={allStaff} onCreated={loadData} />
<InviteStaffDialog departments={tree} squads={allSquads} onInvited={loadData} />
</div>
</div>
{/* Tree */}
<div className="space-y-3">
{tree.map(dept => (
<Card key={dept.id} className="overflow-hidden">
{/* Department Level */}
<button
className="w-full flex items-center gap-3 p-4 hover:bg-muted/30 transition-colors text-left"
onClick={() => toggleDept(dept.id)}
>
{expandedDepts.has(dept.id) ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: dept.color + '20' }}>
<Building2 className="h-4 w-4" style={{ color: dept.color }} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{dept.name}</span>
<Badge variant="outline" className="text-[10px]">{dept.slug}</Badge>
</div>
{dept.description && <p className="text-xs text-muted-foreground">{dept.description}</p>}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
{dept.squads.reduce((acc, s) => acc + s.members.length, 0)} members
</div>
</button>
{/* Scope badges */}
{expandedDepts.has(dept.id) && (
<div className="px-4 pb-2 flex flex-wrap gap-1">
{dept.baseScopes.map(scope => (
<Badge key={scope} variant="secondary" className="text-[10px] font-normal">
{SCOPE_DEFINITIONS[scope]?.label || scope}
</Badge>
))}
</div>
)}
{/* Squad Level */}
{expandedDepts.has(dept.id) && (
<div className="border-t">
{dept.squads.length === 0 && (
<div className="p-4 text-sm text-muted-foreground text-center">No squads yet. Create one to get started.</div>
)}
{dept.squads.map(squad => {
const manager = squad.members.find(m => m.id === squad.managerId);
return (
<div key={squad.id} className="border-b last:border-b-0">
<button
className="w-full flex items-center gap-3 pl-12 pr-4 py-3 hover:bg-muted/20 transition-colors text-left"
onClick={() => toggleSquad(squad.id)}
>
{expandedSquads.has(squad.id) ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm flex-1">{squad.name}</span>
{manager && (
<Badge variant="outline" className="text-[10px] gap-1">
<Crown className="h-3 w-3 text-amber-500" />{manager.name}
</Badge>
)}
<span className="text-xs text-muted-foreground">{squad.members.length} members</span>
</button>
{/* Extra scopes */}
{expandedSquads.has(squad.id) && squad.extraScopes.length > 0 && (
<div className="pl-20 pr-4 pb-1 flex flex-wrap gap-1">
{squad.extraScopes.map(scope => (
<Badge key={scope} className="text-[10px] bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
+ {SCOPE_DEFINITIONS[scope]?.label || scope}
</Badge>
))}
</div>
)}
{/* Members */}
{expandedSquads.has(squad.id) && (
<div className="pl-20 pr-4 pb-3 space-y-1">
{squad.members.map(member => (
<div key={member.id} className="flex items-center gap-3 py-1.5 px-3 rounded-md hover:bg-muted/30">
<div className="h-7 w-7 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xs font-bold text-primary">
{member.name.split(' ').map(n => n[0]).join('')}
</div>
<div className="flex-1">
<span className="text-sm font-medium">{member.name}</span>
<span className="text-xs text-muted-foreground ml-2">{member.email}</span>
</div>
{member.role === 'MANAGER' && (
<Badge className="text-[10px] bg-amber-500/10 text-amber-600 border-amber-200">Manager</Badge>
)}
{member.status === 'invited' && (
<Badge variant="outline" className="text-[10px] border-sky-300 text-sky-600">Invited</Badge>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip, TooltipContent, TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Shield, Tag, Mail, Download, Trash2, X,
CheckCircle2, Ban, Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { bulkExportUsers, bulkBanUsers, bulkDeleteUsers, bulkVerifyUsers } from '@/lib/actions/bulk-users';
import type { User } from '@/lib/types/user';
interface BulkActionBarProps {
selectedUsers: User[];
onClearSelection: () => void;
onOpenSuspendDialog: () => void;
onOpenTagDialog: () => void;
onOpenEmailComposer: () => void;
onComplete: () => void;
}
export function BulkActionBar({
selectedUsers,
onClearSelection,
onOpenSuspendDialog,
onOpenTagDialog,
onOpenEmailComposer,
onComplete,
}: BulkActionBarProps) {
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const count = selectedUsers.length;
if (count === 0) return null;
const userIds = selectedUsers.map(u => u.id);
const runAction = async (key: string, action: () => Promise<{ success: boolean; message: string }>) => {
setLoadingAction(key);
try {
const res = await action();
if (res.success) {
toast.success(res.message);
onClearSelection();
onComplete();
} else {
toast.error(res.message);
}
} catch {
toast.error('Action failed');
} finally {
setLoadingAction(null);
}
};
const handleExport = () => {
runAction('export', async () => {
const res = await bulkExportUsers(userIds);
if (res.success && res.csvData) {
// Trigger download
const blob = new Blob([res.csvData], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
return res;
});
};
const handleBan = () => {
if (!confirm(`Permanently ban ${count} user(s)? This is a severe action.`)) return;
runAction('ban', () => bulkBanUsers(userIds, { reason: 'Bulk ban from action bar' }));
};
const handleDelete = () => {
if (!confirm(`Permanently delete ${count} user(s)? This CANNOT be undone.`)) return;
runAction('delete', () => bulkDeleteUsers(userIds));
};
const handleVerify = () => {
runAction('verify', () => bulkVerifyUsers(userIds));
};
const actions = [
{ key: 'suspend', icon: Shield, label: 'Suspend', color: 'text-orange-500 hover:bg-orange-500/10', onClick: onOpenSuspendDialog },
{ key: 'ban', icon: Ban, label: 'Ban', color: 'text-red-500 hover:bg-red-500/10', onClick: handleBan },
{ key: 'tag', icon: Tag, label: 'Tag', color: 'text-blue-500 hover:bg-blue-500/10', onClick: onOpenTagDialog },
{ key: 'email', icon: Mail, label: 'Email', color: 'text-violet-500 hover:bg-violet-500/10', onClick: onOpenEmailComposer },
{ key: 'verify', icon: CheckCircle2, label: 'Verify', color: 'text-emerald-500 hover:bg-emerald-500/10', onClick: handleVerify },
{ key: 'export', icon: Download, label: 'Export CSV', color: 'text-sky-500 hover:bg-sky-500/10', onClick: handleExport },
{ key: 'delete', icon: Trash2, label: 'Delete', color: 'text-red-600 hover:bg-red-600/10', onClick: handleDelete, destructive: true },
];
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in-0 duration-300">
<div className="flex items-center gap-1 bg-background/95 backdrop-blur-xl border border-border/60 shadow-2xl rounded-2xl px-2 py-2">
{/* Selection Count */}
<div className="flex items-center gap-2 px-3 pr-4 border-r border-border/40">
<Badge variant="default" className="h-7 min-w-7 p-0 flex items-center justify-center rounded-full text-xs font-bold">
{count}
</Badge>
<span className="text-sm font-medium text-foreground whitespace-nowrap">
User{count > 1 ? 's' : ''} Selected
</span>
<Button variant="ghost" size="icon" className="h-6 w-6 rounded-full hover:bg-destructive/10" onClick={onClearSelection}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-0.5 px-1">
{actions.map((action, i) => (
<span key={action.key}>
{action.destructive && <div className="w-px h-6 bg-border/40 mx-1" />}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-9 w-9 rounded-xl transition-all', action.color)}
onClick={action.onClick}
disabled={loadingAction !== null}
>
{loadingAction === action.key ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<action.icon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{action.label}
</TooltipContent>
</Tooltip>
</span>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { bulkSuspendUsers } from '@/lib/actions/bulk-users';
import { Loader2, Shield, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import type { User } from '@/lib/types/user';
const REASONS = [
{ value: 'spam', label: 'Spam / Abuse' },
{ value: 'fraud', label: 'Suspected Fraud' },
{ value: 'tos', label: 'Terms of Service Violation' },
{ value: 'chargeback', label: 'Chargeback Abuse' },
{ value: 'other', label: 'Other (specify below)' },
];
const DURATIONS = [
{ value: '24h', label: '24 Hours' },
{ value: '7d', label: '7 Days' },
{ value: '30d', label: '30 Days' },
{ value: 'permanent', label: 'Permanent' },
];
interface BulkSuspendDialogProps {
users: User[];
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
export function BulkSuspendDialog({ users, open, onOpenChange, onComplete }: BulkSuspendDialogProps) {
const [reason, setReason] = useState('');
const [duration, setDuration] = useState('7d');
const [customNote, setCustomNote] = useState('');
const [notifyUsers, setNotifyUsers] = useState(true);
const [loading, setLoading] = useState(false);
// Filter out already suspended/banned users
const actionable = users.filter(u => u.status !== 'Suspended' && u.status !== 'Banned');
const skipped = users.length - actionable.length;
const handleSubmit = async () => {
if (!reason) { toast.error('Please select a reason'); return; }
setLoading(true);
try {
const finalReason = reason === 'other' ? customNote || 'Unspecified' : REASONS.find(r => r.value === reason)?.label || reason;
const res = await bulkSuspendUsers(
actionable.map(u => u.id),
{ reason: finalReason, duration, notifyUsers }
);
if (res.success) {
toast.success('Bulk Suspension Applied', {
description: res.message + (skipped > 0 ? ` (${skipped} already suspended/banned, skipped)` : ''),
});
onOpenChange(false);
onComplete();
} else {
toast.error(res.message);
}
} catch { toast.error('An unexpected error occurred'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-orange-500" />
Bulk Suspend Users
</DialogTitle>
<DialogDescription>
Suspend {actionable.length} user{actionable.length !== 1 ? 's' : ''} from the platform.
</DialogDescription>
</DialogHeader>
{skipped > 0 && (
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
{skipped} user{skipped > 1 ? 's are' : ' is'} already suspended/banned and will be skipped.
</div>
)}
{/* Affected Users Preview */}
<div className="max-h-28 overflow-y-auto p-3 rounded-lg bg-muted/30 border">
<div className="flex flex-wrap gap-1.5">
{users.slice(0, 20).map(u => (
<Badge key={u.id} variant="outline" className="text-xs gap-1">
{u.name}
{(u.status === 'Suspended' || u.status === 'Banned') && (
<span className="text-[9px] text-muted-foreground">(skip)</span>
)}
</Badge>
))}
{users.length > 20 && <Badge variant="secondary" className="text-xs">+{users.length - 20} more</Badge>}
</div>
</div>
<div className="space-y-4 py-2">
{/* Reason */}
<div className="space-y-2">
<Label>Reason for Suspension</Label>
<Select value={reason} onValueChange={setReason}>
<SelectTrigger><SelectValue placeholder="Select reason..." /></SelectTrigger>
<SelectContent>
{REASONS.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{reason === 'other' && (
<div className="space-y-2">
<Label>Custom Reason</Label>
<Textarea value={customNote} onChange={e => setCustomNote(e.target.value)} placeholder="Describe the reason..." rows={2} />
</div>
)}
{/* Duration */}
<div className="space-y-2">
<Label>Duration</Label>
<div className="grid grid-cols-4 gap-2">
{DURATIONS.map(d => (
<Button
key={d.value}
type="button"
variant={duration === d.value ? 'default' : 'outline'}
size="sm"
onClick={() => setDuration(d.value)}
className={duration === d.value && d.value === 'permanent' ? 'bg-red-600 hover:bg-red-700' : ''}
>
{d.label}
</Button>
))}
</div>
</div>
{/* Notify */}
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={notifyUsers} onCheckedChange={(v) => setNotifyUsers(!!v)} />
<span className="text-sm">Send suspension notification email to users</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button
onClick={handleSubmit}
disabled={loading || actionable.length === 0}
className="bg-orange-600 hover:bg-orange-700"
>
{loading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Suspending...</> : `Suspend ${actionable.length} User${actionable.length !== 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { bulkTagUsers } from '@/lib/actions/bulk-users';
import { mockTags } from '../../data/mockUserCrmData';
import { Loader2, Tag, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { User } from '@/lib/types/user';
interface BulkTagDialogProps {
users: User[];
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
export function BulkTagDialog({ users, open, onOpenChange, onComplete }: BulkTagDialogProps) {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [mode, setMode] = useState<'add' | 'remove'>('add');
const [loading, setLoading] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [customTags, setCustomTags] = useState<{ id: string; name: string; color: string }[]>([]);
const allTags = [...mockTags, ...customTags];
const toggleTag = (tagId: string) => {
setSelectedTags(prev => prev.includes(tagId) ? prev.filter(t => t !== tagId) : [...prev, tagId]);
};
const handleCreateTag = () => {
if (!newTagName.trim()) return;
const id = 'tag-custom-' + Date.now();
setCustomTags(prev => [...prev, { id, name: newTagName.trim(), color: 'bg-indigo-500/20 text-indigo-600 border-indigo-300' }]);
setSelectedTags(prev => [...prev, id]);
setNewTagName('');
};
const handleSubmit = async () => {
if (selectedTags.length === 0) { toast.error('Select at least one tag'); return; }
setLoading(true);
try {
const res = await bulkTagUsers(users.map(u => u.id), { tagIds: selectedTags, mode });
if (res.success) {
toast.success('Tags Updated', { description: res.message });
onOpenChange(false);
setSelectedTags([]);
onComplete();
} else {
toast.error(res.message);
}
} catch { toast.error('An unexpected error occurred'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5 text-primary" />
Bulk Tag Users
</DialogTitle>
<DialogDescription>
{mode === 'add' ? 'Add' : 'Remove'} tags {mode === 'add' ? 'to' : 'from'} {users.length} selected user{users.length !== 1 ? 's' : ''}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Mode Toggle */}
<div className="flex rounded-lg border overflow-hidden">
<Button
type="button"
variant={mode === 'add' ? 'default' : 'ghost'}
size="sm"
className="flex-1 rounded-none"
onClick={() => setMode('add')}
>
Add to All
</Button>
<Button
type="button"
variant={mode === 'remove' ? 'destructive' : 'ghost'}
size="sm"
className="flex-1 rounded-none"
onClick={() => setMode('remove')}
>
Remove from All
</Button>
</div>
{/* Tag Selection */}
<div className="space-y-2">
<Label>Select Tags</Label>
<div className="flex flex-wrap gap-2 p-3 rounded-lg border bg-muted/20 min-h-[80px]">
{allTags.map(tag => (
<Badge
key={tag.id}
variant="outline"
className={cn(
'cursor-pointer transition-all text-xs py-1 px-2.5',
tag.color,
selectedTags.includes(tag.id) && 'ring-2 ring-primary ring-offset-1 scale-105',
)}
onClick={() => toggleTag(tag.id)}
>
{selectedTags.includes(tag.id) && '✓ '}
{tag.name}
</Badge>
))}
</div>
</div>
{/* Create New Tag */}
<div className="space-y-2">
<Label>Or Create New Tag</Label>
<div className="flex gap-2">
<Input
value={newTagName}
onChange={e => setNewTagName(e.target.value)}
placeholder="e.g. Season Pass Holder"
className="flex-1"
onKeyDown={e => e.key === 'Enter' && handleCreateTag()}
/>
<Button type="button" variant="outline" size="icon" onClick={handleCreateTag} disabled={!newTagName.trim()}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Affected Users */}
<div className="max-h-24 overflow-y-auto p-2.5 rounded-lg bg-muted/20 border">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider font-medium mb-1.5">Affected Users</p>
<div className="flex flex-wrap gap-1">
{users.slice(0, 15).map(u => (
<Badge key={u.id} variant="secondary" className="text-[10px]">{u.name}</Badge>
))}
{users.length > 15 && <Badge variant="secondary" className="text-[10px]">+{users.length - 15} more</Badge>}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button
onClick={handleSubmit}
disabled={loading || selectedTags.length === 0}
variant={mode === 'remove' ? 'destructive' : 'default'}
>
{loading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</> : `${mode === 'add' ? 'Add' : 'Remove'} ${selectedTags.length} Tag${selectedTags.length !== 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Martian+Mono:wght@400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -9,26 +11,38 @@
@layer base {
:root {
/* Neumorphic Blue Theme - Primary Palette */
--neu-base: 216 33% 94%; /* #E8EFF8 - Main background */
--neu-surface: 216 30% 92%; /* #DFE9F5 - Card surfaces */
--neu-raised: 216 33% 96%; /* Lighter for raised elements */
--neu-inset: 216 30% 88%; /* Darker for inset/pressed */
--neu-base: 216 33% 94%;
/* #E8EFF8 - Main background */
--neu-surface: 216 30% 92%;
/* #DFE9F5 - Card surfaces */
--neu-raised: 216 33% 96%;
/* Lighter for raised elements */
--neu-inset: 216 30% 88%;
/* Darker for inset/pressed */
/* Brand Colors */
--deep-blue: 220 60% 15%; /* #0F1E3D - Primary text */
--royal-blue: 222 75% 33%; /* #1E3A8A - Active states */
--ocean-blue: 217 91% 60%; /* #3B82F6 - Interactive */
--sky-blue: 199 89% 48%; /* #0EA5E9 - Highlights */
--ice-blue: 199 95% 74%; /* #7DD3FC - Subtle accents */
--deep-blue: 220 60% 15%;
/* #0F1E3D - Primary text */
--royal-blue: 222 75% 33%;
/* #1E3A8A - Active states */
--ocean-blue: 217 91% 60%;
/* #3B82F6 - Interactive */
--sky-blue: 199 89% 48%;
/* #0EA5E9 - Highlights */
--ice-blue: 199 95% 74%;
/* #7DD3FC - Subtle accents */
/* Semantic Colors */
--success: 142 76% 36%; /* Green for positive */
--success: 142 76% 36%;
/* Green for positive */
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%; /* Amber for warnings */
--warning: 38 92% 50%;
/* Amber for warnings */
--warning-foreground: 0 0% 100%;
--error: 0 84% 60%; /* Red for errors */
--error: 0 84% 60%;
/* Red for errors */
--error-foreground: 0 0% 100%;
/* Base shadcn tokens mapped to neumorphic theme */
--background: 216 33% 94%;
--foreground: 220 60% 15%;
@@ -114,50 +128,51 @@
}
@layer components {
/* Neumorphic utility classes */
.neu-card {
@apply bg-card rounded-2xl transition-all duration-200;
box-shadow:
box-shadow:
6px 6px 12px hsl(var(--neu-inset)),
-6px -6px 12px hsl(var(--neu-raised));
}
.neu-card-hover:hover {
box-shadow:
box-shadow:
8px 8px 16px hsl(var(--neu-inset)),
-8px -8px 16px hsl(var(--neu-raised));
}
.neu-inset {
@apply bg-secondary rounded-xl;
box-shadow:
box-shadow:
inset 3px 3px 6px hsl(var(--neu-inset)),
inset -3px -3px 6px hsl(var(--neu-raised));
}
.neu-button {
@apply bg-card rounded-xl transition-all duration-200 cursor-pointer;
box-shadow:
box-shadow:
4px 4px 8px hsl(var(--neu-inset)),
-4px -4px 8px hsl(var(--neu-raised));
}
.neu-button:hover {
box-shadow:
box-shadow:
6px 6px 12px hsl(var(--neu-inset)),
-6px -6px 12px hsl(var(--neu-raised));
}
.neu-button:active {
box-shadow:
box-shadow:
inset 2px 2px 4px hsl(var(--neu-inset)),
inset -2px -2px 4px hsl(var(--neu-raised));
}
.neu-button-active {
@apply bg-primary text-primary-foreground;
box-shadow:
box-shadow:
inset 2px 2px 4px hsl(222 75% 28%),
inset -2px -2px 4px hsl(222 75% 38%);
}
}
}

View File

@@ -0,0 +1,280 @@
'use server';
// Ad Control — Server Actions (localStorage-persisted mock)
import type {
Surface, PlacementItem, PlacementConfigData, PlacementWithEvent, PlacementStatus,
} from '@/lib/types/ad-control';
import { MOCK_SURFACES, MOCK_PLACEMENTS, MOCK_PICKER_EVENTS } from '@/features/ad-control/data/mockAdData';
import { logPlacementAction, getPlacementAuditLog } from '@/lib/audit/placement-audit';
const PLACEMENTS_KEY = 'ad_control_placements';
// --- Persistence Helpers ---
function getPlacementsStore(): PlacementItem[] {
if (typeof window === 'undefined') return MOCK_PLACEMENTS;
try {
const raw = localStorage.getItem(PLACEMENTS_KEY);
return raw ? JSON.parse(raw) : MOCK_PLACEMENTS;
} catch { return MOCK_PLACEMENTS; }
}
function savePlacementsStore(items: PlacementItem[]) {
if (typeof window === 'undefined') return;
localStorage.setItem(PLACEMENTS_KEY, JSON.stringify(items));
}
// --- Auto-expire check ---
function autoExpire(items: PlacementItem[]): PlacementItem[] {
const now = new Date().toISOString();
let changed = false;
const updated = items.map(p => {
if ((p.status === 'ACTIVE' || p.status === 'SCHEDULED') && p.endAt && p.endAt < now) {
changed = true;
return { ...p, status: 'EXPIRED' as PlacementStatus, updatedAt: now };
}
return p;
});
if (changed) savePlacementsStore(updated);
return updated;
}
// --- Resolve events ---
function resolveEvent(p: PlacementItem): PlacementWithEvent {
const event = p.eventId ? MOCK_PICKER_EVENTS.find(e => e.id === p.eventId) : undefined;
return { ...p, event };
}
// ===== QUERIES =====
export async function getSurfaces(): Promise<{ success: boolean; data: Surface[] }> {
await new Promise(r => setTimeout(r, 200));
return { success: true, data: MOCK_SURFACES };
}
export async function getPlacements(
surfaceId?: string,
status?: PlacementStatus | 'ALL',
): Promise<{ success: boolean; data: PlacementWithEvent[] }> {
await new Promise(r => setTimeout(r, 300));
let items = autoExpire(getPlacementsStore());
if (surfaceId) items = items.filter(p => p.surfaceId === surfaceId);
if (status && status !== 'ALL') items = items.filter(p => p.status === status);
items.sort((a, b) => a.rank - b.rank);
return { success: true, data: items.map(resolveEvent) };
}
export async function getPickerEvents(): Promise<{ success: boolean; data: typeof MOCK_PICKER_EVENTS }> {
await new Promise(r => setTimeout(r, 200));
return { success: true, data: MOCK_PICKER_EVENTS };
}
export async function getPlacementAudit(placementId: string) {
await new Promise(r => setTimeout(r, 200));
return { success: true, data: getPlacementAuditLog(placementId) };
}
// ===== MUTATIONS =====
export async function createPlacement(
surfaceId: string,
eventId: string,
config: PlacementConfigData,
): Promise<{ success: boolean; message: string; data?: PlacementItem }> {
await new Promise(r => setTimeout(r, 500));
const store = getPlacementsStore();
const surface = MOCK_SURFACES.find(s => s.id === surfaceId);
if (!surface) return { success: false, message: 'Surface not found' };
// Check max slots (only active/scheduled count)
const activeCount = store.filter(p => p.surfaceId === surfaceId && (p.status === 'ACTIVE' || p.status === 'SCHEDULED')).length;
if (activeCount >= surface.maxSlots) {
return { success: false, message: `Surface "${surface.name}" is full (${surface.maxSlots} max slots)` };
}
// Check duplicate
if (store.some(p => p.surfaceId === surfaceId && p.eventId === eventId && p.status !== 'EXPIRED' && p.status !== 'DISABLED')) {
return { success: false, message: 'This event is already placed on this surface' };
}
const maxRank = store.filter(p => p.surfaceId === surfaceId).reduce((max, p) => Math.max(max, p.rank), 0);
const now = new Date().toISOString();
const newItem: PlacementItem = {
id: `plc-${Date.now()}`,
surfaceId,
itemType: 'EVENT',
eventId,
status: 'DRAFT',
priority: config.priority,
rank: maxRank + 1,
startAt: config.startAt,
endAt: config.endAt,
targeting: config.targeting,
boostLabel: config.boostLabel,
notes: config.notes,
createdBy: 'admin-1',
updatedBy: 'admin-1',
createdAt: now,
updatedAt: now,
};
store.push(newItem);
savePlacementsStore(store);
logPlacementAction(newItem.id, 'admin-1', 'CREATED', null, newItem);
return { success: true, message: 'Placement created as draft', data: newItem };
}
export async function updatePlacement(
id: string,
patch: Partial<PlacementConfigData>,
): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const idx = store.findIndex(p => p.id === id);
if (idx === -1) return { success: false, message: 'Placement not found' };
const before = { ...store[idx] };
const now = new Date().toISOString();
if (patch.startAt !== undefined) store[idx].startAt = patch.startAt;
if (patch.endAt !== undefined) store[idx].endAt = patch.endAt;
if (patch.targeting) store[idx].targeting = patch.targeting;
if (patch.boostLabel !== undefined) store[idx].boostLabel = patch.boostLabel;
if (patch.priority) store[idx].priority = patch.priority;
if (patch.notes !== undefined) store[idx].notes = patch.notes;
store[idx].updatedBy = 'admin-1';
store[idx].updatedAt = now;
savePlacementsStore(store);
logPlacementAction(id, 'admin-1', 'UPDATED', before, store[idx]);
return { success: true, message: 'Placement updated' };
}
export async function publishPlacement(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const idx = store.findIndex(p => p.id === id);
if (idx === -1) return { success: false, message: 'Placement not found' };
const before = { ...store[idx] };
const now = new Date().toISOString();
// If has future startAt, mark as SCHEDULED instead
if (store[idx].startAt && new Date(store[idx].startAt!) > new Date()) {
store[idx].status = 'SCHEDULED';
} else {
store[idx].status = 'ACTIVE';
}
store[idx].updatedBy = 'admin-1';
store[idx].updatedAt = now;
savePlacementsStore(store);
logPlacementAction(id, 'admin-1', 'PUBLISHED', before, store[idx]);
return { success: true, message: `Placement ${store[idx].status === 'SCHEDULED' ? 'scheduled' : 'published'}` };
}
export async function unpublishPlacement(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const idx = store.findIndex(p => p.id === id);
if (idx === -1) return { success: false, message: 'Placement not found' };
const before = { ...store[idx] };
store[idx].status = 'DISABLED';
store[idx].updatedBy = 'admin-1';
store[idx].updatedAt = new Date().toISOString();
savePlacementsStore(store);
logPlacementAction(id, 'admin-1', 'UNPUBLISHED', before, store[idx]);
return { success: true, message: 'Placement unpublished' };
}
export async function reorderPlacements(
surfaceId: string,
orderedIds: string[],
): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 600));
const store = getPlacementsStore();
// Update ranks transactionally
orderedIds.forEach((id, index) => {
const item = store.find(p => p.id === id && p.surfaceId === surfaceId);
if (item) {
item.rank = index + 1;
item.updatedAt = new Date().toISOString();
}
});
savePlacementsStore(store);
logPlacementAction(surfaceId, 'admin-1', 'REORDERED', null, { orderedIds });
return { success: true, message: `Reordered ${orderedIds.length} placements` };
}
export async function deletePlacement(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const item = store.find(p => p.id === id);
if (!item) return { success: false, message: 'Placement not found' };
logPlacementAction(id, 'admin-1', 'DELETED', item, null);
const updated = store.filter(p => p.id !== id);
savePlacementsStore(updated);
return { success: true, message: 'Placement deleted' };
}
// ===== PUBLIC API (mock) =====
export async function getPublicPlacements(
surfaceKey: string,
city?: string,
category?: string,
): Promise<{ success: boolean; data: PlacementWithEvent[] }> {
await new Promise(r => setTimeout(r, 200));
const surface = MOCK_SURFACES.find(s => s.key === surfaceKey);
if (!surface) return { success: true, data: [] };
const now = new Date().toISOString();
let items = autoExpire(getPlacementsStore())
.filter(p => p.surfaceId === surface.id && p.status === 'ACTIVE')
.filter(p => !p.startAt || p.startAt <= now)
.filter(p => !p.endAt || p.endAt > now);
// Apply targeting filters
if (city) {
items = items.filter(p => p.targeting.cityIds.length === 0 || p.targeting.cityIds.includes(city));
}
if (category) {
items = items.filter(p => p.targeting.categoryIds.length === 0 || p.targeting.categoryIds.includes(category));
}
items.sort((a, b) => {
const priorityOrder = { SPONSORED: 0, MANUAL: 1, ALGO: 2 };
if (priorityOrder[a.priority] !== priorityOrder[b.priority]) {
return priorityOrder[a.priority] - priorityOrder[b.priority];
}
return a.rank - b.rank;
});
return { success: true, data: items.slice(0, surface.maxSlots).map(resolveEvent) };
}

388
src/lib/actions/ads.ts Normal file
View File

@@ -0,0 +1,388 @@
'use server';
// Sponsored Ads — Server Actions (localStorage-persisted mock)
import type {
Campaign, CampaignWithEvents, CampaignFormData,
CampaignReport, CampaignStatus, PlacementDailyStats,
} from '@/lib/types/ads';
import type { SurfaceKey } from '@/lib/types/ad-control';
import { MOCK_CAMPAIGNS, MOCK_DAILY_STATS, MOCK_TRACKING_EVENTS } from '@/features/ad-control/data/mockAdsData';
import { MOCK_PICKER_EVENTS, MOCK_SURFACES } from '@/features/ad-control/data/mockAdData';
import { computeDailyStats, getTrackingEvents, recordImpression, recordClick } from '@/lib/ads/tracking';
const CAMPAIGNS_KEY = 'ad_campaigns';
const CAMPAIGN_AUDIT_KEY = 'ad_campaign_audit';
// ===== Persistence =====
function getCampaignStore(): Campaign[] {
if (typeof window === 'undefined') return MOCK_CAMPAIGNS;
try {
const raw = localStorage.getItem(CAMPAIGNS_KEY);
return raw ? JSON.parse(raw) : MOCK_CAMPAIGNS;
} catch { return MOCK_CAMPAIGNS; }
}
function saveCampaignStore(campaigns: Campaign[]) {
if (typeof window !== 'undefined') {
localStorage.setItem(CAMPAIGNS_KEY, JSON.stringify(campaigns));
}
}
function logCampaignAudit(campaignId: string, action: string, details?: Record<string, any>) {
if (typeof window === 'undefined') return;
try {
const entries = JSON.parse(localStorage.getItem(CAMPAIGN_AUDIT_KEY) || '[]');
entries.push({
id: `ca-${Date.now()}`,
campaignId,
actorId: 'admin-1',
action,
details: details || null,
createdAt: new Date().toISOString(),
});
localStorage.setItem(CAMPAIGN_AUDIT_KEY, JSON.stringify(entries.slice(-500)));
} catch { }
}
// ===== Resolve Events =====
function resolveEvents(campaign: Campaign): CampaignWithEvents {
const events = MOCK_PICKER_EVENTS.filter(e => campaign.eventIds.includes(e.id));
return { ...campaign, events };
}
// ===== Auto-end expired campaigns =====
function autoEndCampaigns(campaigns: Campaign[]): Campaign[] {
const now = new Date().toISOString();
return campaigns.map(c => {
if (c.status === 'ACTIVE' && (c.endAt < now || c.spent >= c.totalBudget)) {
return { ...c, status: 'ENDED' as CampaignStatus, updatedAt: now };
}
return c;
});
}
// ===== QUERIES =====
export async function getCampaigns(
status?: CampaignStatus | 'ALL',
): Promise<{ success: boolean; data: CampaignWithEvents[] }> {
let campaigns = autoEndCampaigns(getCampaignStore());
saveCampaignStore(campaigns);
if (status && status !== 'ALL') {
campaigns = campaigns.filter(c => c.status === status);
}
// Sort: IN_REVIEW first, then ACTIVE, then by updatedAt desc
const statusOrder: Record<string, number> = { IN_REVIEW: 0, ACTIVE: 1, PAUSED: 2, DRAFT: 3, ENDED: 4, REJECTED: 5 };
campaigns.sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9) || b.updatedAt.localeCompare(a.updatedAt));
return { success: true, data: campaigns.map(resolveEvents) };
}
export async function getCampaign(id: string): Promise<{ success: boolean; data?: CampaignWithEvents; message: string }> {
const campaigns = getCampaignStore();
const campaign = campaigns.find(c => c.id === id);
if (!campaign) return { success: false, message: 'Campaign not found' };
return { success: true, data: resolveEvents(campaign), message: 'OK' };
}
// ===== MUTATIONS =====
export async function createCampaign(
data: CampaignFormData,
): Promise<{ success: boolean; message: string; data?: Campaign }> {
const campaigns = getCampaignStore();
const now = new Date().toISOString();
const id = `camp-${Date.now().toString(36)}`;
const newCampaign: Campaign = {
id,
partnerId: `partner-${data.partnerName.toLowerCase().replace(/\s+/g, '-').slice(0, 10)}`,
partnerName: data.partnerName,
name: data.name,
objective: data.objective,
status: 'DRAFT',
startAt: data.startAt,
endAt: data.endAt,
billingModel: data.billingModel,
totalBudget: data.totalBudget,
dailyCap: data.dailyCap,
spent: 0,
targeting: data.targeting,
surfaceKeys: data.surfaceKeys,
eventIds: data.eventIds,
frequencyCap: data.frequencyCap,
approvedBy: null,
rejectedReason: null,
createdBy: 'admin-1',
createdAt: now,
updatedAt: now,
};
campaigns.push(newCampaign);
saveCampaignStore(campaigns);
logCampaignAudit(id, 'CREATED', { name: data.name });
return { success: true, message: 'Campaign created as draft', data: newCampaign };
}
export async function updateCampaign(
id: string,
patch: Partial<CampaignFormData>,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
const campaign = campaigns[idx];
if (campaign.status !== 'DRAFT' && campaign.status !== 'PAUSED') {
return { success: false, message: 'Can only edit draft or paused campaigns' };
}
const updated: Campaign = {
...campaign,
...patch,
updatedAt: new Date().toISOString(),
} as Campaign;
campaigns[idx] = updated;
saveCampaignStore(campaigns);
logCampaignAudit(id, 'UPDATED', patch);
return { success: true, message: 'Campaign updated' };
}
export async function submitCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'DRAFT') return { success: false, message: 'Only draft campaigns can be submitted' };
// Validation checks
const c = campaigns[idx];
if (c.eventIds.length === 0) return { success: false, message: 'At least one event is required' };
if (c.surfaceKeys.length === 0) return { success: false, message: 'At least one surface is required' };
if (c.totalBudget <= 0) return { success: false, message: 'Budget must be positive' };
if (!c.startAt || !c.endAt) return { success: false, message: 'Schedule dates are required' };
campaigns[idx] = { ...c, status: 'IN_REVIEW', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'SUBMITTED');
return { success: true, message: 'Campaign submitted for review' };
}
export async function approveCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
// Eligibility checks
const c = campaigns[idx];
for (const eventId of c.eventIds) {
const event = MOCK_PICKER_EVENTS.find(e => e.id === eventId);
if (!event) return { success: false, message: `Event ${eventId} not found` };
if (event.approvalStatus !== 'APPROVED') return { success: false, message: `Event "${event.title}" is not approved` };
if (new Date(event.endDate) < new Date()) return { success: false, message: `Event "${event.title}" has ended` };
}
campaigns[idx] = { ...c, status: 'ACTIVE', approvedBy: 'admin-1', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'APPROVED');
return { success: true, message: 'Campaign approved and activated' };
}
export async function rejectCampaign(
id: string,
reason: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
campaigns[idx] = { ...campaigns[idx], status: 'REJECTED', rejectedReason: reason, updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'REJECTED', { reason });
return { success: true, message: 'Campaign rejected' };
}
export async function pauseCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'ACTIVE') return { success: false, message: 'Only active campaigns can be paused' };
campaigns[idx] = { ...campaigns[idx], status: 'PAUSED', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'PAUSED');
return { success: true, message: 'Campaign paused' };
}
export async function resumeCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'PAUSED') return { success: false, message: 'Only paused campaigns can be resumed' };
campaigns[idx] = { ...campaigns[idx], status: 'ACTIVE', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'RESUMED');
return { success: true, message: 'Campaign resumed' };
}
// ===== REPORTING =====
export async function getCampaignReport(
id: string,
): Promise<{ success: boolean; data?: CampaignReport; message: string }> {
const campaigns = getCampaignStore();
const campaign = campaigns.find(c => c.id === id);
if (!campaign) return { success: false, message: 'Campaign not found' };
// Get or compute daily stats
let dailyStats: PlacementDailyStats[];
if (campaign.id === 'camp-001') {
// Use pre-computed mock data for demo campaign
dailyStats = MOCK_DAILY_STATS;
} else {
dailyStats = computeDailyStats(
campaign.id,
campaign.billingModel,
campaign.totalBudget / (campaign.billingModel === 'CPM' ? 1000 : campaign.billingModel === 'CPC' ? 500 : 1),
);
}
// Totals
const totalImpressions = dailyStats.reduce((s, d) => s + d.impressions, 0);
const totalClicks = dailyStats.reduce((s, d) => s + d.clicks, 0);
const totalSpend = dailyStats.reduce((s, d) => s + d.spend, 0);
// By surface
const surfaceMap = new Map<string, { impressions: number; clicks: number; spend: number }>();
for (const stat of dailyStats) {
const existing = surfaceMap.get(stat.surfaceKey) || { impressions: 0, clicks: 0, spend: 0 };
existing.impressions += stat.impressions;
existing.clicks += stat.clicks;
existing.spend += stat.spend;
surfaceMap.set(stat.surfaceKey, existing);
}
const bySurface = Array.from(surfaceMap.entries()).map(([surfaceKey, data]) => {
const surface = MOCK_SURFACES.find(s => s.key === surfaceKey);
return {
surfaceKey: surfaceKey as SurfaceKey,
surfaceName: surface?.name || surfaceKey,
impressions: data.impressions,
clicks: data.clicks,
ctr: data.impressions > 0 ? Number((data.clicks / data.impressions).toFixed(4)) : 0,
spend: Number(data.spend.toFixed(2)),
};
});
const report: CampaignReport = {
campaign: resolveEvents(campaign),
totals: {
impressions: totalImpressions,
clicks: totalClicks,
ctr: totalImpressions > 0 ? Number((totalClicks / totalImpressions).toFixed(4)) : 0,
spend: Number(totalSpend.toFixed(2)),
remaining: Number((campaign.totalBudget - totalSpend).toFixed(2)),
},
dailyStats,
bySurface,
};
return { success: true, data: report, message: 'OK' };
}
// ===== CSV EXPORT =====
export async function exportCampaignCSV(id: string): Promise<{ success: boolean; csv?: string; message: string }> {
const res = await getCampaignReport(id);
if (!res.success || !res.data) return { success: false, message: res.message };
const { campaign, totals, dailyStats, bySurface } = res.data;
const lines: string[] = [
`Campaign Report: ${campaign.name}`,
`Partner: ${campaign.partnerName}`,
`Status: ${campaign.status}`,
`Period: ${campaign.startAt.slice(0, 10)} to ${campaign.endAt.slice(0, 10)}`,
`Billing: ${campaign.billingModel}`,
`Budget: ₹${campaign.totalBudget.toLocaleString()}`,
`Spent: ₹${totals.spend.toLocaleString()}`,
``,
`Date,Surface,Impressions,Clicks,CTR,Spend`,
...dailyStats.map(d =>
`${d.date},${d.surfaceKey},${d.impressions},${d.clicks},${(d.ctr * 100).toFixed(2)}%,₹${d.spend.toFixed(2)}`
),
``,
`Surface Summary`,
`Surface,Impressions,Clicks,CTR,Spend`,
...bySurface.map(s =>
`${s.surfaceName},${s.impressions},${s.clicks},${(s.ctr * 100).toFixed(2)}%,₹${s.spend.toFixed(2)}`
),
``,
`Totals,${totals.impressions},${totals.clicks},${(totals.ctr * 100).toFixed(2)}%,₹${totals.spend.toFixed(2)}`,
];
return { success: true, csv: lines.join('\n'), message: 'OK' };
}
// ===== DASHBOARD STATS =====
export async function getSponsoredStats(): Promise<{
success: boolean;
data: {
activeCampaigns: number;
todaySpend: number;
impressions24h: number;
clicks24h: number;
ctr24h: number;
};
}> {
const campaigns = autoEndCampaigns(getCampaignStore());
const activeCampaigns = campaigns.filter(c => c.status === 'ACTIVE').length;
// Last 24h stats from tracking events
const cutoff24h = new Date(Date.now() - 86400000).toISOString();
// For pre-seeded data, use mock stats; for real-time, use tracking
let impressions24h = 0;
let clicks24h = 0;
let todaySpend = 0;
// Check real tracking events first
const allEvents = getTrackingEvents();
const recent = allEvents.filter(e => e.timestamp > cutoff24h);
if (recent.length > 0) {
impressions24h = recent.filter(e => e.type === 'IMPRESSION').length;
clicks24h = recent.filter(e => e.type === 'CLICK').length;
todaySpend = Number(((impressions24h / 1000) * 250).toFixed(2)); // ₹250 CPM assumed
} else {
// Fallback to mock daily stats (last day)
const lastDayStats = MOCK_DAILY_STATS.filter(d => d.date === '2026-02-09');
impressions24h = lastDayStats.reduce((s, d) => s + d.impressions, 0);
clicks24h = lastDayStats.reduce((s, d) => s + d.clicks, 0);
todaySpend = lastDayStats.reduce((s, d) => s + d.spend, 0);
}
const ctr24h = impressions24h > 0 ? Number((clicks24h / impressions24h).toFixed(4)) : 0;
return {
success: true,
data: { activeCampaigns, todaySpend, impressions24h, clicks24h, ctr24h },
};
}

View File

@@ -0,0 +1,240 @@
'use server';
import { z } from 'zod';
import { logAdminAction } from '@/lib/audit-logger';
// --- Validation ---
const bulkActionSchema = z.object({
userIds: z.array(z.string()).min(1, 'At least one user must be selected'),
selectAll: z.boolean().optional(), // For "Select All across pages" future support
});
// --- Authorization ---
async function verifyAdmin() {
return { id: 'admin-1', role: 'admin' };
}
// ===== BULK SUSPEND =====
export async function bulkSuspendUsers(
userIds: string[],
payload: { reason: string; duration: string; notifyUsers: boolean }
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
// Simulate batched DB update: UPDATE users SET status='Suspended' WHERE id IN (...)
await new Promise(r => setTimeout(r, 1200));
// In production: Prisma updateMany in a transaction
// const result = await prisma.user.updateMany({
// where: { id: { in: userIds }, status: { not: 'Suspended' } },
// data: { status: 'Suspended', suspendedAt: new Date(), suspendReason: payload.reason, suspendDuration: payload.duration }
// });
await logAdminAction({
actorId: admin.id,
action: 'BULK_SUSPEND',
targetId: 'multiple',
details: {
count: userIds.length,
reason: payload.reason,
duration: payload.duration,
notifyUsers: payload.notifyUsers,
userIds,
},
});
// If notifyUsers, queue notification emails (don't await)
if (payload.notifyUsers) {
// queueBulkEmail(userIds, 'suspension_notice', { reason: payload.reason, duration: payload.duration });
console.log(`[QUEUE] Suspension notice queued for ${userIds.length} users`);
}
return {
success: true,
message: `${userIds.length} user(s) suspended (${payload.duration}). Reason: ${payload.reason}`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk suspend failed.', affectedCount: 0 };
}
}
// ===== BULK BAN =====
export async function bulkBanUsers(
userIds: string[],
payload: { reason: string }
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 1500));
await logAdminAction({
actorId: admin.id,
action: 'BULK_BAN',
targetId: 'multiple',
details: { count: userIds.length, reason: payload.reason, userIds },
});
return {
success: true,
message: `${userIds.length} user(s) permanently banned.`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk ban failed.', affectedCount: 0 };
}
}
// ===== BULK TAG =====
export async function bulkTagUsers(
userIds: string[],
payload: { tagIds: string[]; mode: 'add' | 'remove' }
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 800));
await logAdminAction({
actorId: admin.id,
action: payload.mode === 'add' ? 'BULK_TAG_ADD' : 'BULK_TAG_REMOVE',
targetId: 'multiple',
details: { count: userIds.length, tagIds: payload.tagIds, mode: payload.mode, userIds },
});
return {
success: true,
message: `${payload.mode === 'add' ? 'Added' : 'Removed'} ${payload.tagIds.length} tag(s) ${payload.mode === 'add' ? 'to' : 'from'} ${userIds.length} user(s).`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk tag failed.', affectedCount: 0 };
}
}
// ===== BULK EMAIL =====
export async function bulkSendEmail(
userIds: string[],
payload: { subject: string; body: string; template?: string }
): Promise<{ success: boolean; message: string; queuedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
// Don't await delivery — push to queue
await new Promise(r => setTimeout(r, 600));
await logAdminAction({
actorId: admin.id,
action: 'BULK_EMAIL',
targetId: 'multiple',
details: { count: userIds.length, subject: payload.subject, userIds },
});
return {
success: true,
message: `${userIds.length} email(s) queued for delivery.`,
queuedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk email failed.', queuedCount: 0 };
}
}
// ===== BULK EXPORT =====
export async function bulkExportUsers(
userIds: string[],
selectAll: boolean = false
): Promise<{ success: boolean; message: string; csvData: string }> {
try {
const admin = await verifyAdmin();
await new Promise(r => setTimeout(r, 1000));
// In production: query DB with userIds or all if selectAll
const header = 'id,name,email,phone,status,role,totalSpent,bookingsCount,joinedAt\n';
const rows = userIds.map(id => `${id},Mock User,user@email.com,+91XXXXXXXX,Active,User,0,0,2025-01-01`).join('\n');
await logAdminAction({
actorId: admin.id,
action: 'BULK_EXPORT',
targetId: 'multiple',
details: { count: selectAll ? 'ALL' : userIds.length, selectAll },
});
return {
success: true,
message: `Exported ${selectAll ? 'all' : userIds.length} user(s) to CSV.`,
csvData: header + rows,
};
} catch (error: any) {
return { success: false, message: error.message || 'Export failed.', csvData: '' };
}
}
// ===== BULK DELETE =====
export async function bulkDeleteUsers(
userIds: string[]
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 2000));
await logAdminAction({
actorId: admin.id,
action: 'BULK_DELETE',
targetId: 'multiple',
details: { count: userIds.length, userIds },
});
return {
success: true,
message: `${userIds.length} user(s) permanently deleted.`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk delete failed.', affectedCount: 0 };
}
}
// ===== BULK VERIFY =====
export async function bulkVerifyUsers(
userIds: string[]
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 800));
await logAdminAction({
actorId: admin.id,
action: 'BULK_VERIFY',
targetId: 'multiple',
details: { count: userIds.length, userIds },
});
return {
success: true,
message: `${userIds.length} user(s) marked as verified.`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk verify failed.', affectedCount: 0 };
}
}

View File

@@ -0,0 +1,287 @@
/**
* Partner Governance — Server Actions
*
* KYC verification, event approval, account management, and lifecycle actions.
* Currently backed by in-memory mock data. Replace with API calls when backend is ready.
*/
import { mockPartners, mockKYCDocuments, mockPartnerEvents } from '@/data/mockPartnerData';
import type { KYCDocument, KYCDocStatus, PartnerEvent, Partner } from '@/types/partner';
// ═══════════════════════════════════════════════════════════════════
// KYC WORKFLOW
// ═══════════════════════════════════════════════════════════════════
/**
* Get KYC status for a partner, including docs and completion %.
*/
export async function getPartnerKYCStatus(partnerId: string) {
const docs = mockKYCDocuments.filter(d => d.partnerId === partnerId);
const mandatoryDocs = docs.filter(d => d.mandatory);
const approvedMandatory = mandatoryDocs.filter(d => d.status === 'APPROVED');
const completionPercent = mandatoryDocs.length > 0
? Math.round((approvedMandatory.length / mandatoryDocs.length) * 100)
: 0;
const isFullyVerified = mandatoryDocs.length > 0 && approvedMandatory.length === mandatoryDocs.length;
return {
success: true,
partnerId,
documents: docs,
completionPercent,
isFullyVerified,
totalDocs: docs.length,
mandatoryCount: mandatoryDocs.length,
approvedCount: approvedMandatory.length,
};
}
/**
* Verify (approve/reject) a single KYC document.
* If all mandatory docs become APPROVED, auto-upgrade partner to Verified.
*/
export async function verifyPartnerDocument(
docId: string,
status: 'APPROVED' | 'REJECTED',
rejectionReason?: string
): Promise<{ success: boolean; message: string; autoVerified?: boolean }> {
const doc = mockKYCDocuments.find(d => d.id === docId);
if (!doc) {
return { success: false, message: 'Document not found.' };
}
// Update document status
(doc as any).status = status;
(doc as any).reviewedBy = 'Current Admin';
(doc as any).reviewedAt = new Date().toISOString();
if (status === 'REJECTED' && rejectionReason) {
(doc as any).adminNote = rejectionReason;
}
console.log(`[AUDIT] Admin verified document ${docId}: ${status}`, rejectionReason || '');
// Check auto-verification
if (status === 'APPROVED') {
const partnerDocs = mockKYCDocuments.filter(d => d.partnerId === doc.partnerId);
const mandatoryDocs = partnerDocs.filter(d => d.mandatory);
const allApproved = mandatoryDocs.every(d => d.status === 'APPROVED');
if (allApproved) {
const partner = mockPartners.find(p => p.id === doc.partnerId);
if (partner) {
(partner as any).verificationStatus = 'Verified';
console.log(`[AUDIT] Partner ${partner.name} auto-verified — all mandatory KYC docs approved.`);
return {
success: true,
message: `Document approved. All mandatory documents verified — partner auto-upgraded to Verified.`,
autoVerified: true,
};
}
}
}
return {
success: true,
message: status === 'APPROVED'
? 'Document approved successfully.'
: `Document rejected. Partner notified to re-upload.`,
};
}
// ═══════════════════════════════════════════════════════════════════
// EVENT GOVERNANCE
// ═══════════════════════════════════════════════════════════════════
/**
* Get events pending review, optionally filtered by partner.
*/
export async function getPendingEvents(partnerId?: string): Promise<PartnerEvent[]> {
let events = mockPartnerEvents.filter(e => e.status === 'PENDING_REVIEW');
if (partnerId) {
events = events.filter(e => e.partnerId === partnerId);
}
return events;
}
/**
* Get all events for a partner.
*/
export async function getPartnerEvents(partnerId: string): Promise<PartnerEvent[]> {
return mockPartnerEvents.filter(e => e.partnerId === partnerId);
}
/**
* Approve a partner event — PENDING_REVIEW → LIVE.
*/
export async function approvePartnerEvent(
eventId: string
): Promise<{ success: boolean; message: string }> {
const event = mockPartnerEvents.find(e => e.id === eventId);
if (!event) return { success: false, message: 'Event not found.' };
if (event.status !== 'PENDING_REVIEW') {
return { success: false, message: `Event is ${event.status}, not PENDING_REVIEW.` };
}
(event as any).status = 'LIVE';
(event as any).reviewedBy = 'Current Admin';
(event as any).reviewedAt = new Date().toISOString();
console.log(`[AUDIT] Admin approved event "${event.title}" (${eventId}) → LIVE`);
return { success: true, message: `"${event.title}" is now LIVE.` };
}
/**
* Reject a partner event — PENDING_REVIEW → DRAFT with reason.
*/
export async function rejectPartnerEvent(
eventId: string,
reason: string
): Promise<{ success: boolean; message: string }> {
const event = mockPartnerEvents.find(e => e.id === eventId);
if (!event) return { success: false, message: 'Event not found.' };
if (event.status !== 'PENDING_REVIEW') {
return { success: false, message: `Event is ${event.status}, not PENDING_REVIEW.` };
}
(event as any).status = 'REJECTED';
(event as any).rejectionReason = reason;
(event as any).reviewedBy = 'Current Admin';
(event as any).reviewedAt = new Date().toISOString();
console.log(`[AUDIT] Admin rejected event "${event.title}" (${eventId}): ${reason}`);
return { success: true, message: `"${event.title}" rejected. Partner notified with fix request.` };
}
// ═══════════════════════════════════════════════════════════════════
// ACCOUNT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════
/**
* Generate impersonation token for "Login as Partner".
* Returns a redirect URL to the partner dashboard.
*/
export async function generateImpersonationToken(
partnerId: string
): Promise<{ success: boolean; token?: string; redirectUrl?: string; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
// Simulate token generation
const token = `imp_${partnerId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const redirectUrl = `https://partner.prototype.eventifyplus.com/impersonate?token=${token}`;
console.log(`[AUDIT] Admin impersonated Partner "${partner.name}" (${partnerId}). Token: ${token}`);
return {
success: true,
token,
redirectUrl,
message: `Impersonation session created for ${partner.name}. This action has been logged.`,
};
}
/**
* Reset partner's 2FA enrollment.
*/
export async function resetPartner2FA(
partnerId: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
console.log(`[AUDIT] Admin reset 2FA for Partner "${partner.name}" (${partnerId})`);
return {
success: true,
message: `2FA has been reset for ${partner.name}. They will be prompted to re-enroll on next login.`,
};
}
/**
* Trigger password reset for a partner.
*/
export async function resetPartnerPassword(
partnerId: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
console.log(`[AUDIT] Admin triggered password reset for Partner "${partner.name}" (${partnerId})`);
return {
success: true,
message: `Password reset email sent to ${partner.primaryContact.email}.`,
};
}
// ═══════════════════════════════════════════════════════════════════
// LIFECYCLE ACTIONS
// ═══════════════════════════════════════════════════════════════════
/**
* Suspend a partner account.
*/
export async function suspendPartner(
partnerId: string,
reason: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
if (partner.status === 'Suspended') {
return { success: false, message: 'Partner is already suspended.' };
}
(partner as any).status = 'Suspended';
(partner as any).notes = reason;
console.log(`[AUDIT] Admin suspended Partner "${partner.name}" (${partnerId}): ${reason}`);
return { success: true, message: `${partner.name} has been suspended.` };
}
/**
* Unsuspend a partner account.
*/
export async function unsuspendPartner(
partnerId: string
): Promise<{ success: boolean; message: string }> {
const partner = mockPartners.find(p => p.id === partnerId);
if (!partner) return { success: false, message: 'Partner not found.' };
if (partner.status !== 'Suspended') {
return { success: false, message: 'Partner is not suspended.' };
}
(partner as any).status = 'Active';
console.log(`[AUDIT] Admin unsuspended Partner "${partner.name}" (${partnerId})`);
return { success: true, message: `${partner.name} has been reactivated.` };
}
// ═══════════════════════════════════════════════════════════════════
// DASHBOARD HELPERS
// ═══════════════════════════════════════════════════════════════════
/**
* Get dashboard stats for partner governance.
*/
export async function getPartnerDashboardStats() {
const pendingKYC = mockPartners.filter(p => p.verificationStatus === 'Pending').length;
const highRisk = mockPartners.filter(p => p.riskScore > 60).length;
const pendingEvents = mockPartnerEvents.filter(e => e.status === 'PENDING_REVIEW').length;
const activePartners = mockPartners.filter(p => p.status === 'Active').length;
const totalRevenue = mockPartners.reduce((sum, p) => sum + p.metrics.totalRevenue, 0);
return {
pendingKYC,
highRisk,
pendingEvents,
activePartners,
totalPartners: mockPartners.length,
totalRevenue,
};
}

View File

@@ -0,0 +1,79 @@
import { GatewayProvider, GatewayCredentials, PaymentConfig, GlobalSettings } from '../types/settings';
import { updateSystemSetting, getSystemSettings } from './settings';
import { encrypt, decrypt } from '../payment-encryption';
export async function verifyGatewayCredentials(
provider: GatewayProvider,
credentials: Partial<GatewayCredentials>
): Promise<{ success: boolean; message: string }> {
// Simulate API call to provider
await new Promise(resolve => setTimeout(resolve, 1500));
if (provider === 'payu' && (!credentials.merchantId || !credentials.salt)) {
return { success: false, message: 'Invalid PayU credentials: Merchant ID and Salt are required.' };
}
if (provider === 'razorpay' && !credentials.keyId) {
return { success: false, message: 'Invalid Razorpay Key ID.' };
}
// Success simulation
return { success: true, message: `Successfully connected to ${provider.toUpperCase()} [${credentials.mode} mode]` };
}
export async function saveGatewayConfig(
provider: GatewayProvider,
config: GatewayCredentials
): Promise<{ success: boolean; message: string }> {
// Fetch current settings first
const currentRes = await getSystemSettings();
if (!currentRes.success) return { success: false, message: 'Failed to retrieve current settings' };
const settings = currentRes.data;
// Encrypt sensitive fields
const encryptedConfig = { ...config };
if (config.salt) encryptedConfig.salt = encrypt(config.salt);
// Note: We don't encrypt public keys usually, but secrets yes.
// For demo simplicity, we encrypt 'salt' and assume others are public/safe or handled similarly.
// Update specific gateway in payment config
const newPaymentConfig: PaymentConfig = {
...settings.payment,
gateways: {
...settings.payment.gateways,
[provider]: encryptedConfig
}
};
// Use our main update action
const updateRes = await updateSystemSetting('payment', newPaymentConfig);
return {
success: updateRes.success,
message: `Saved configuration for ${provider}`
};
}
export async function updateRoutingRules(
rules: Pick<PaymentConfig, 'defaultGateway' | 'fallbackGateway' | 'internationalGateway'>
): Promise<{ success: boolean; message: string }> {
// Fetch current settings first
const currentRes = await getSystemSettings();
if (!currentRes.success) return { success: false, message: 'Failed to retrieve settings' };
const settings = currentRes.data;
const newPaymentConfig: PaymentConfig = {
...settings.payment,
...rules
};
const updateRes = await updateSystemSetting('payment', newPaymentConfig);
return {
success: updateRes.success,
message: 'Routing logic updated successfully'
};
}

View File

@@ -0,0 +1,95 @@
import { GlobalSettings, DEFAULT_SETTINGS } from '../types/settings';
// Simulate a database/store with a singleton object
// In a real app, this would be a DB table 'system_config'
let MOCK_DB_SETTINGS: GlobalSettings = { ...DEFAULT_SETTINGS };
// Load from localStorage if available (client-side only trick for persistence in this demo)
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('mock_system_settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
MOCK_DB_SETTINGS = { ...DEFAULT_SETTINGS, ...parsed };
} catch (e) {
console.error("Failed to parse saved settings", e);
}
}
}
export async function getSystemSettings(): Promise<{ success: boolean; data: GlobalSettings }> {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
// In a real app, we would fetch from DB here
// return db.systemConfig.findFirst();
// For now, return our in-memory/local storage mock
// We try to read from localStorage again to sync tabs
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('mock_system_settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
// Deep merge or at least top-level merge to ensure new sections (like 'payment') are added
MOCK_DB_SETTINGS = {
...DEFAULT_SETTINGS,
...parsed,
};
} catch (e) { /* ignore */ }
}
}
return { success: true, data: MOCK_DB_SETTINGS };
}
export async function updateSystemSetting<K extends keyof GlobalSettings>(
section: K,
data: Partial<GlobalSettings[K]>
): Promise<{ success: boolean; message: string; updatedSettings: GlobalSettings }> {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 800));
// Update the specific section
MOCK_DB_SETTINGS = {
...MOCK_DB_SETTINGS,
[section]: {
...MOCK_DB_SETTINGS[section],
...data
}
};
// Persist to localStorage for demo
if (typeof window !== 'undefined') {
localStorage.setItem('mock_system_settings', JSON.stringify(MOCK_DB_SETTINGS));
}
console.log(`[AUDIT] System Setting Updated: ${section}`, data);
// In Next.js we would maintain revalidatePath here
// revalidatePath('/settings');
return {
success: true,
message: 'Settings updated successfully.',
updatedSettings: MOCK_DB_SETTINGS
};
}
export async function purgeSystemCache(): Promise<{ success: boolean; message: string }> {
await new Promise(resolve => setTimeout(resolve, 1500));
console.log(`[AUDIT] System Cache Purged by Admin`);
// Update last purged time
MOCK_DB_SETTINGS.system.cache.lastPurgedAt = new Date().toISOString();
if (typeof window !== 'undefined') {
localStorage.setItem('mock_system_settings', JSON.stringify(MOCK_DB_SETTINGS));
}
return {
success: true,
message: 'System cache purged. Changes are now live on Public App.'
};
}

View File

@@ -0,0 +1,178 @@
import { Department, Squad, StaffMember, StaffRole, MOCK_DEPARTMENTS, MOCK_SQUADS, MOCK_STAFF } from '../types/staff';
// ===== In-Memory Mock DB =====
let departments: Department[] = [...MOCK_DEPARTMENTS];
let squads: Squad[] = [...MOCK_SQUADS];
let staff: StaffMember[] = [...MOCK_STAFF];
// Hydrate from localStorage
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('mock_staff_data');
if (saved) {
const parsed = JSON.parse(saved);
departments = parsed.departments || MOCK_DEPARTMENTS;
squads = parsed.squads || MOCK_SQUADS;
staff = parsed.staff || MOCK_STAFF;
}
} catch (e) { /* ignore */ }
}
function persist() {
if (typeof window !== 'undefined') {
localStorage.setItem('mock_staff_data', JSON.stringify({ departments, squads, staff }));
}
}
function uid() { return 'id_' + Math.random().toString(36).substring(2, 11); }
// ===== DEPARTMENT ACTIONS =====
export async function getDepartments(): Promise<{ success: boolean; data: Department[] }> {
await new Promise(r => setTimeout(r, 300));
return { success: true, data: departments };
}
export async function createDepartment(data: { name: string; slug: string; baseScopes: string[]; color: string; description?: string }): Promise<{ success: boolean; department: Department }> {
await new Promise(r => setTimeout(r, 500));
const dept: Department = {
id: uid(),
name: data.name,
slug: data.slug,
description: data.description || '',
baseScopes: data.baseScopes,
color: data.color,
createdAt: new Date().toISOString(),
};
departments.push(dept);
persist();
return { success: true, department: dept };
}
export async function deleteDepartment(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const deptSquads = squads.filter(s => s.departmentId === id);
if (deptSquads.length > 0) {
return { success: false, message: 'Cannot delete department with active squads. Remove squads first.' };
}
departments = departments.filter(d => d.id !== id);
persist();
return { success: true, message: 'Department deleted.' };
}
// ===== SQUAD ACTIONS =====
export async function getSquads(departmentId?: string): Promise<{ success: boolean; data: Squad[] }> {
await new Promise(r => setTimeout(r, 300));
const filtered = departmentId ? squads.filter(s => s.departmentId === departmentId) : squads;
return { success: true, data: filtered };
}
export async function createSquad(data: { name: string; departmentId: string; managerId?: string; extraScopes: string[] }): Promise<{ success: boolean; squad: Squad }> {
await new Promise(r => setTimeout(r, 500));
const sq: Squad = {
id: uid(),
name: data.name,
departmentId: data.departmentId,
managerId: data.managerId || null,
extraScopes: data.extraScopes,
createdAt: new Date().toISOString(),
};
squads.push(sq);
// If manager is specified, update their role
if (data.managerId) {
staff = staff.map(s => s.id === data.managerId ? { ...s, role: 'MANAGER' as StaffRole, squadId: sq.id, departmentId: data.departmentId } : s);
}
persist();
return { success: true, squad: sq };
}
export async function deleteSquad(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const members = staff.filter(s => s.squadId === id);
if (members.length > 0) {
return { success: false, message: 'Cannot delete squad with active members. Move or remove members first.' };
}
squads = squads.filter(s => s.id !== id);
persist();
return { success: true, message: 'Squad dissolved.' };
}
// ===== STAFF ACTIONS =====
export async function getStaff(): Promise<{ success: boolean; data: StaffMember[] }> {
await new Promise(r => setTimeout(r, 300));
return { success: true, data: staff };
}
export async function inviteStaff(data: { name: string; email: string; departmentId: string; squadId: string; role: StaffRole }): Promise<{ success: boolean; member: StaffMember; message: string }> {
await new Promise(r => setTimeout(r, 800));
// Check for duplicates
if (staff.find(s => s.email === data.email)) {
return { success: false, member: null as any, message: 'A staff member with this email already exists.' };
}
const member: StaffMember = {
id: uid(),
name: data.name,
email: data.email,
squadId: data.squadId,
departmentId: data.departmentId,
role: data.role,
status: 'invited',
joinedAt: new Date().toISOString(),
};
staff.push(member);
persist();
console.log(`[AUDIT] Staff Invited: ${data.email} -> ${data.departmentId}/${data.squadId} as ${data.role}`);
return { success: true, member, message: `Invitation sent to ${data.email}` };
}
export async function updateStaffRole(staffId: string, newRole: StaffRole): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 500));
staff = staff.map(s => s.id === staffId ? { ...s, role: newRole } : s);
persist();
return { success: true, message: 'Role updated.' };
}
export async function moveStaff(staffId: string, newSquadId: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 500));
const squad = squads.find(s => s.id === newSquadId);
if (!squad) return { success: false, message: 'Target squad not found.' };
staff = staff.map(s => s.id === staffId ? { ...s, squadId: newSquadId, departmentId: squad.departmentId } : s);
persist();
console.log(`[AUDIT] Staff ${staffId} moved to squad ${newSquadId}`);
return { success: true, message: `Moved to ${squad.name}` };
}
export async function deactivateStaff(staffId: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 500));
staff = staff.map(s => s.id === staffId ? { ...s, status: 'deactivated' as const } : s);
persist();
return { success: true, message: 'Staff member deactivated.' };
}
// ===== HELPER QUERIES =====
export async function getOrgTree(): Promise<{
success: boolean;
data: Array<Department & { squads: Array<Squad & { members: StaffMember[] }> }>;
}> {
await new Promise(r => setTimeout(r, 400));
const tree = departments.map(dept => ({
...dept,
squads: squads
.filter(sq => sq.departmentId === dept.id)
.map(sq => ({
...sq,
members: staff.filter(s => s.squadId === sq.id && s.status !== 'deactivated'),
})),
}));
return { success: true, data: tree };
}

115
src/lib/ads/serving.ts Normal file
View File

@@ -0,0 +1,115 @@
// Ad Serving Engine — Ranking, frequency caps, budget pacing
import type { Campaign, SponsoredPlacement } from '@/lib/types/ads';
import type { SurfaceKey } from '@/lib/types/ad-control';
import { getUserImpressionCount } from './tracking';
import { MOCK_PICKER_EVENTS } from '@/features/ad-control/data/mockAdData';
// --- Get eligible sponsored placements for a surface request ---
export function getSponsoredForSurface(
campaigns: Campaign[],
surfaceKey: SurfaceKey,
city?: string,
category?: string,
anonId?: string,
): SponsoredPlacement[] {
const now = new Date().toISOString();
const today = now.slice(0, 10);
const eligible: { campaign: Campaign; placement: SponsoredPlacement }[] = [];
for (const camp of campaigns) {
// 1. Must be ACTIVE
if (camp.status !== 'ACTIVE') continue;
// 2. Within schedule window
if (camp.startAt > now || camp.endAt < now) continue;
// 3. Must target this surface
if (!camp.surfaceKeys.includes(surfaceKey)) continue;
// 4. Targeting match (empty = all)
if (city && camp.targeting.cityIds.length > 0 && !camp.targeting.cityIds.includes(city)) continue;
if (category && camp.targeting.categoryIds.length > 0 && !camp.targeting.categoryIds.includes(category)) continue;
// 5. Budget check: total
if (camp.spent >= camp.totalBudget) continue;
// 6. Frequency cap check
if (anonId && camp.frequencyCap > 0) {
const todayCount = getUserImpressionCount(anonId, camp.id, today);
if (todayCount >= camp.frequencyCap) continue;
}
// Create sponsored placements for each event in campaign
for (const eventId of camp.eventIds) {
eligible.push({
campaign: camp,
placement: {
id: `splc-${camp.id}-${surfaceKey}-${eventId}`,
campaignId: camp.id,
eventId,
surfaceKey,
priority: 'SPONSORED',
bid: calculateBid(camp),
status: 'ACTIVE',
rank: 0,
},
});
}
}
// 7. Rank by eCPM (descending)
eligible.sort((a, b) => calculateECPM(b.campaign) - calculateECPM(a.campaign));
return eligible.map(e => e.placement);
}
// --- Bid calculation ---
function calculateBid(campaign: Campaign): number {
switch (campaign.billingModel) {
case 'CPM': return campaign.totalBudget / 1000; // simplified
case 'CPC': return campaign.totalBudget / 500; // simplified
case 'FIXED': return campaign.totalBudget;
default: return 0;
}
}
// --- eCPM calculation for ranking ---
function calculateECPM(campaign: Campaign): number {
switch (campaign.billingModel) {
case 'CPM': return calculateBid(campaign) * 1000;
case 'CPC': return calculateBid(campaign) * 100; // estimated CTR 10%
case 'FIXED': return campaign.totalBudget / 30; // estimate daily value
default: return 0;
}
}
// --- Spend calculation ---
export function calculateSpend(
billingModel: string,
bid: number,
impressions: number,
clicks: number,
): number {
switch (billingModel) {
case 'CPM': return Number(((impressions / 1000) * bid).toFixed(2));
case 'CPC': return Number((clicks * bid).toFixed(2));
case 'FIXED': return bid; // already allocated
default: return 0;
}
}
// --- Check if campaign should be auto-ended ---
export function shouldAutoEnd(campaign: Campaign): boolean {
const now = new Date().toISOString();
return (
campaign.status === 'ACTIVE' &&
(campaign.endAt < now || campaign.spent >= campaign.totalBudget)
);
}

194
src/lib/ads/tracking.ts Normal file
View File

@@ -0,0 +1,194 @@
// Ad Tracking Engine — Impression/Click recording, deduplication, rollups
import type { AdTrackingEvent, PlacementDailyStats } from '@/lib/types/ads';
import type { SurfaceKey } from '@/lib/types/ad-control';
const TRACKING_KEY = 'ad_tracking_events';
const DAILY_STATS_KEY = 'ad_daily_stats';
const DEDUPE_WINDOW_MS = 30_000; // 30 seconds
// --- Persistence ---
function getTrackingStore(): AdTrackingEvent[] {
if (typeof window === 'undefined') return [];
try { return JSON.parse(localStorage.getItem(TRACKING_KEY) || '[]'); }
catch { return []; }
}
function saveTrackingStore(events: AdTrackingEvent[]) {
if (typeof window === 'undefined') return;
// Cap at 5000 to avoid localStorage limits
localStorage.setItem(TRACKING_KEY, JSON.stringify(events.slice(-5000)));
}
function getDailyStatsStore(): PlacementDailyStats[] {
if (typeof window === 'undefined') return [];
try { return JSON.parse(localStorage.getItem(DAILY_STATS_KEY) || '[]'); }
catch { return []; }
}
function saveDailyStatsStore(stats: PlacementDailyStats[]) {
if (typeof window === 'undefined') return;
localStorage.setItem(DAILY_STATS_KEY, JSON.stringify(stats));
}
// --- Deduplication ---
function isDuplicate(events: AdTrackingEvent[], newEvent: Omit<AdTrackingEvent, 'id'>): boolean {
const cutoff = new Date(new Date(newEvent.timestamp).getTime() - DEDUPE_WINDOW_MS).toISOString();
return events.some(e =>
e.type === newEvent.type &&
e.placementId === newEvent.placementId &&
e.anonId === newEvent.anonId &&
e.timestamp > cutoff
);
}
// --- Record Events ---
export function recordImpression(data: {
placementId: string;
campaignId: string;
surfaceKey: SurfaceKey;
eventId: string;
userId?: string | null;
anonId: string;
sessionId: string;
device?: string;
cityId?: string;
}): { success: boolean; deduplicated?: boolean } {
const store = getTrackingStore();
const now = new Date().toISOString();
const event: Omit<AdTrackingEvent, 'id'> = {
type: 'IMPRESSION',
placementId: data.placementId,
campaignId: data.campaignId,
surfaceKey: data.surfaceKey,
eventId: data.eventId,
userId: data.userId || null,
anonId: data.anonId,
sessionId: data.sessionId,
timestamp: now,
device: data.device || 'unknown',
cityId: data.cityId || 'unknown',
};
if (isDuplicate(store, event)) {
return { success: true, deduplicated: true };
}
const fullEvent: AdTrackingEvent = {
...event,
id: `te-imp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
};
store.push(fullEvent);
saveTrackingStore(store);
return { success: true };
}
export function recordClick(data: {
placementId: string;
campaignId: string;
surfaceKey: SurfaceKey;
eventId: string;
userId?: string | null;
anonId: string;
sessionId: string;
device?: string;
cityId?: string;
}): { success: boolean } {
const store = getTrackingStore();
const now = new Date().toISOString();
const fullEvent: AdTrackingEvent = {
id: `te-clk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'CLICK',
placementId: data.placementId,
campaignId: data.campaignId,
surfaceKey: data.surfaceKey,
eventId: data.eventId,
userId: data.userId || null,
anonId: data.anonId,
sessionId: data.sessionId,
timestamp: now,
device: data.device || 'unknown',
cityId: data.cityId || 'unknown',
};
store.push(fullEvent);
saveTrackingStore(store);
return { success: true };
}
// --- Queries ---
export function getTrackingEvents(campaignId?: string, type?: 'IMPRESSION' | 'CLICK'): AdTrackingEvent[] {
let events = getTrackingStore();
if (campaignId) events = events.filter(e => e.campaignId === campaignId);
if (type) events = events.filter(e => e.type === type);
return events;
}
export function getUserImpressionCount(anonId: string, campaignId: string, date: string): number {
const events = getTrackingStore();
return events.filter(e =>
e.type === 'IMPRESSION' &&
e.anonId === anonId &&
e.campaignId === campaignId &&
e.timestamp.startsWith(date)
).length;
}
export function getTodaySpend(campaignId: string, cpmRate: number): number {
const today = new Date().toISOString().slice(0, 10);
const impressions = getTrackingStore().filter(e =>
e.type === 'IMPRESSION' &&
e.campaignId === campaignId &&
e.timestamp.startsWith(today)
).length;
return Number(((impressions / 1000) * cpmRate).toFixed(2));
}
// --- Daily Stats Rollup ---
export function computeDailyStats(campaignId: string, billingModel: string, bid: number): PlacementDailyStats[] {
const events = getTrackingStore().filter(e => e.campaignId === campaignId);
// Group by date + surfaceKey
const buckets: Record<string, { impressions: number; clicks: number; surfaceKey: SurfaceKey; placementId: string }> = {};
for (const e of events) {
const date = e.timestamp.slice(0, 10);
const key = `${date}|${e.surfaceKey}`;
if (!buckets[key]) {
buckets[key] = { impressions: 0, clicks: 0, surfaceKey: e.surfaceKey, placementId: e.placementId };
}
if (e.type === 'IMPRESSION') buckets[key].impressions++;
else buckets[key].clicks++;
}
return Object.entries(buckets).map(([key, data]) => {
const [date] = key.split('|');
let spend = 0;
if (billingModel === 'CPM') spend = (data.impressions / 1000) * bid;
else if (billingModel === 'CPC') spend = data.clicks * bid;
// FIXED: spread evenly (simplified)
else spend = bid / 30; // assume 30-day campaign
const ctr = data.impressions > 0 ? data.clicks / data.impressions : 0;
return {
id: `ds-${key}`,
campaignId,
placementId: data.placementId,
surfaceKey: data.surfaceKey,
date,
impressions: data.impressions,
clicks: data.clicks,
ctr: Number(ctr.toFixed(4)),
spend: Number(spend.toFixed(2)),
};
}).sort((a, b) => a.date.localeCompare(b.date));
}

175
src/lib/api/public-ads.ts Normal file
View File

@@ -0,0 +1,175 @@
// Public Ads API Client (Simulation)
// This module simulates the public API endpoints requested.
import type { PlacementWithEvent } from '@/lib/types/ad-control';
import type { SurfaceKey } from '@/lib/types/ad-control';
import { getCampaigns } from '@/lib/actions/ads';
import { getPublicPlacements } from '@/lib/actions/ad-control';
import { getSponsoredForSurface } from '@/lib/ads/serving';
import { recordImpression, recordClick } from '@/lib/ads/tracking';
// --- Types ---
export interface AdRequest {
surfaceKey: SurfaceKey;
cityId?: string;
categoryId?: string;
anonId?: string; // For frequency capping
limit?: number;
}
export interface AdResponse {
placements: PlacementWithEvent[];
meta: {
sponsoredCount: number;
manualCount: number;
totalCount: number;
};
}
// --- API Simulation ---
/**
* GET /api/public/placements
* Merges Sponsored (high priority) + Manual (medium) + Algo (low - future)
*/
export async function fetchPlacements(req: AdRequest): Promise<AdResponse> {
// Simulate network latency
await new Promise(r => setTimeout(r, 150));
// 1. Fetch Sponsored
const allCampaignsRes = await getCampaigns('ACTIVE');
const sponsoredItems = getSponsoredForSurface(
allCampaignsRes.data,
req.surfaceKey,
req.cityId,
req.categoryId,
req.anonId
);
// Resolving sponsored items to full PlacementWithEvent structure
// In a real backend, this would happen via JOINs. Here we mock it by finding the event in our mock data.
// We import MOCK_PICKER_EVENTS dynamically to avoid circular deps if possible, or just use the action.
// Actually, getCampaigns already resolves events, so we can use that.
// We need to map SponsoredPlacement -> PlacementWithEvent
// The `getSponsoredForSurface` returns `SponsoredPlacement` objects.
// We need to hydrate them with event details.
// optimizing: getCampaigns returns CampaignWithEvents, which has the event details.
const campaigns = allCampaignsRes.data;
const hydratedSponsored: PlacementWithEvent[] = sponsoredItems.map(sp => {
const campaign = campaigns.find(c => c.id === sp.campaignId);
const event = campaign?.events.find(e => e.id === sp.eventId);
return {
id: sp.id,
surfaceId: sp.surfaceKey, // mapping key to ID for compatibility
itemType: 'EVENT',
eventId: sp.eventId,
status: 'ACTIVE',
priority: 'SPONSORED',
rank: sp.rank, // 0 for sponsored usually, or ranked by eCPM
startAt: campaign?.startAt,
endAt: campaign?.endAt,
targeting: campaign?.targeting || { cityIds: [], categoryIds: [], countryCodes: [] },
event: event,
// Extra metadata for frontend to know it's sponsored
notes: `Sponsored by ${campaign?.partnerName}`,
boostLabel: 'Sponsored',
createdAt: campaign?.createdAt || new Date().toISOString(),
updatedAt: campaign?.updatedAt || new Date().toISOString(),
createdBy: 'system',
updatedBy: 'system',
};
}).filter(p => p.event); // Ensure event exists
// 2. Fetch Manual (Organic/Featured)
const manualRes = await getPublicPlacements(req.surfaceKey, req.cityId, req.categoryId);
const manualItems = manualRes.data;
// 3. Merge & Deduplicate
// If an event is both Sponsored and Manual, show Sponsored (higher priority)
const seenEventIds = new Set<string>();
const merged: PlacementWithEvent[] = [];
// Add Sponsored
for (const item of hydratedSponsored) {
if (!seenEventIds.has(item.eventId!)) {
merged.push(item);
seenEventIds.add(item.eventId!);
}
}
// Add Manual (if not already added)
for (const item of manualItems) {
if (!item.eventId || !seenEventIds.has(item.eventId)) {
merged.push(item);
if (item.eventId) seenEventIds.add(item.eventId);
}
}
// 4. Limit
const limit = req.limit || 10;
const final = merged.slice(0, limit);
return {
placements: final,
meta: {
sponsoredCount: hydratedSponsored.length,
manualCount: manualItems.length,
totalCount: final.length,
},
};
}
/**
* POST /api/public/track/impression
*/
export async function trackImpression(data: {
placementId: string;
campaignId?: string;
surfaceKey: string;
eventId: string;
anonId: string;
userId?: string;
}): Promise<{ success: boolean }> {
// Fire & Forget in a real app, but here we await slightly
await new Promise(r => setTimeout(r, 50));
if (data.campaignId) {
// Only track for sponsored campaigns for now, or track all for analytics
recordImpression({
...data,
surfaceKey: data.surfaceKey as SurfaceKey, // cast for safety
sessionId: 'sess-simulated', // simplified
});
}
return { success: true };
}
/**
* POST /api/public/track/click
*/
export async function trackClick(data: {
placementId: string;
campaignId?: string;
surfaceKey: string;
eventId: string;
anonId: string;
userId?: string;
}): Promise<{ success: boolean; url: string }> {
await new Promise(r => setTimeout(r, 50));
if (data.campaignId) {
recordClick({
...data,
surfaceKey: data.surfaceKey as SurfaceKey,
sessionId: 'sess-simulated',
});
}
// Return the destination URL (e.g. event details page)
return { success: true, url: `/events/${data.eventId}` };
}

View File

@@ -0,0 +1,50 @@
// Placement Audit Logger — localStorage-backed audit trail for ad placements
import type { PlacementAuditEntry } from '@/lib/types/ad-control';
const AUDIT_KEY = 'placement_audit_log';
function getAuditStore(): PlacementAuditEntry[] {
if (typeof window === 'undefined') return [];
try {
return JSON.parse(localStorage.getItem(AUDIT_KEY) || '[]');
} catch { return []; }
}
function saveAuditStore(entries: PlacementAuditEntry[]) {
if (typeof window === 'undefined') return;
localStorage.setItem(AUDIT_KEY, JSON.stringify(entries));
}
export function logPlacementAction(
placementId: string,
actorId: string,
action: string,
before?: Record<string, any> | null,
after?: Record<string, any> | null,
): PlacementAuditEntry {
const entry: PlacementAuditEntry = {
id: `paudit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
placementId,
actorId,
action,
before: before ?? null,
after: after ?? null,
createdAt: new Date().toISOString(),
};
const store = getAuditStore();
store.unshift(entry); // newest first
saveAuditStore(store.slice(0, 500)); // cap at 500 entries
console.log(`[PLACEMENT AUDIT] ${actorId}${action} on ${placementId}`);
return entry;
}
export function getPlacementAuditLog(placementId: string): PlacementAuditEntry[] {
return getAuditStore().filter(e => e.placementId === placementId);
}
export function getAllAuditLogs(): PlacementAuditEntry[] {
return getAuditStore();
}

View File

@@ -0,0 +1,83 @@
import { StaffMember, Department, Squad, MOCK_DEPARTMENTS, MOCK_SQUADS } from '../types/staff';
/**
* Resolves all effective permissions for a staff member.
* Logic: Department.baseScopes + Squad.extraScopes (Additive)
*/
export function getEffectiveScopes(
staff: StaffMember,
departments: Department[] = MOCK_DEPARTMENTS,
squads: Squad[] = MOCK_SQUADS
): string[] {
// SuperAdmin gets everything
if (staff.role === 'SUPER_ADMIN') return ['*'];
const scopeSet = new Set<string>();
// 1. Department base scopes
if (staff.departmentId) {
const dept = departments.find(d => d.id === staff.departmentId);
if (dept) {
dept.baseScopes.forEach(s => scopeSet.add(s));
}
}
// 2. Squad extra scopes (additive)
if (staff.squadId) {
const squad = squads.find(s => s.id === staff.squadId);
if (squad) {
squad.extraScopes.forEach(s => scopeSet.add(s));
}
}
// 3. Manager role bonus: can manage squad members
if (staff.role === 'MANAGER') {
scopeSet.add('settings.staff'); // Can manage their own squad's staff
}
return Array.from(scopeSet);
}
/**
* Main permission check.
*
* Usage: hasPermission(currentUser, 'finance.refunds.execute')
*
* Supports wildcard matching:
* - '*' = full access (SuperAdmin)
* - 'finance.*' = all finance scopes
* - 'finance.refunds.execute' = exact match
*/
export function hasPermission(
staff: StaffMember,
requiredScope: string,
departments?: Department[],
squads?: Squad[]
): boolean {
const effectiveScopes = getEffectiveScopes(staff, departments, squads);
// Wildcard = full access
if (effectiveScopes.includes('*')) return true;
// Exact match
if (effectiveScopes.includes(requiredScope)) return true;
// Category wildcard: 'finance.*' matches 'finance.refunds.execute'
for (const scope of effectiveScopes) {
if (scope.endsWith('.*')) {
const prefix = scope.replace('.*', '');
if (requiredScope.startsWith(prefix)) return true;
}
}
return false;
}
/**
* Check if a staff member is a manager of a specific squad.
*/
export function isSquadManager(staff: StaffMember, squadId: string, squads: Squad[] = MOCK_SQUADS): boolean {
if (staff.role === 'SUPER_ADMIN') return true;
const squad = squads.find(s => s.id === squadId);
return squad?.managerId === staff.id;
}

View File

@@ -0,0 +1,33 @@
// In a real Node.js environment, we would use 'crypto'
// import crypto from 'crypto';
// Since this is a Vite client-side demo, we'll mock the encryption
// to simulate the security layer.
export function encrypt(text: string): string {
if (!text) return '';
try {
// Simple base64 encoding simulation + prefix
return `enc_${btoa(text)}`;
} catch (e) {
console.error("Encryption failed", e);
return text;
}
}
export function decrypt(text: string): string {
if (!text) return '';
if (!text.startsWith('enc_')) return text; // Return as-is if not encrypted
try {
const payload = text.replace('enc_', '');
return atob(payload);
} catch (e) {
console.error("Decryption failed", e);
return text;
}
}
export function maskKey(key: string): string {
if (!key || key.length < 8) return '********';
return `******${key.slice(-4)}`;
}

110
src/lib/types/ad-control.ts Normal file
View File

@@ -0,0 +1,110 @@
// Ad Control Module — Types & Interfaces
// ===== Enums =====
export type SurfaceKey =
| 'HOME_FEATURED_CAROUSEL'
| 'HOME_TOP_EVENTS'
| 'CATEGORY_FEATURED'
| 'CITY_TRENDING'
| 'SEARCH_BOOSTED';
export type PlacementStatus = 'DRAFT' | 'ACTIVE' | 'SCHEDULED' | 'EXPIRED' | 'DISABLED';
export type PlacementPriority = 'SPONSORED' | 'MANUAL' | 'ALGO';
export type ItemType = 'EVENT' | 'BANNER' | 'COLLECTION';
export type LayoutType = 'carousel' | 'grid' | 'list';
export type SortBehavior = 'rank' | 'date' | 'popularity';
// ===== Surface =====
export interface Surface {
id: string;
key: SurfaceKey;
name: string;
description: string;
maxSlots: number;
layoutType: LayoutType;
sortBehavior: SortBehavior;
icon: string; // lucide icon name
createdAt: string;
}
// ===== Placement Targeting =====
export interface PlacementTargeting {
cityIds: string[];
categoryIds: string[];
countryCodes: string[];
}
// ===== Placement Item =====
export interface PlacementItem {
id: string;
surfaceId: string;
itemType: ItemType;
eventId: string | null;
bannerId?: string | null;
status: PlacementStatus;
priority: PlacementPriority;
rank: number;
startAt: string | null;
endAt: string | null;
targeting: PlacementTargeting;
boostLabel: string | null; // "Featured" | "Top" | "Sponsored" | null
notes: string | null;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
// ===== Placement Audit =====
export interface PlacementAuditEntry {
id: string;
placementId: string;
actorId: string;
action: string; // CREATED | PUBLISHED | UNPUBLISHED | REORDERED | UPDATED | DELETED
before: Record<string, any> | null;
after: Record<string, any> | null;
createdAt: string;
}
// ===== Mock Event (for Picker) =====
export type EventApprovalStatus = 'APPROVED' | 'PENDING' | 'REJECTED';
export interface PickerEvent {
id: string;
title: string;
city: string;
state: string;
country: string;
date: string; // event start date
endDate: string; // event end date
organizer: string;
organizerLogo: string;
category: string;
coverImage: string | null;
approvalStatus: EventApprovalStatus;
ticketsSold: number;
capacity: number;
}
// ===== Placement with resolved event =====
export interface PlacementWithEvent extends PlacementItem {
event?: PickerEvent;
}
// ===== Config form data =====
export interface PlacementConfigData {
startAt: string | null;
endAt: string | null;
targeting: PlacementTargeting;
boostLabel: string | null;
priority: PlacementPriority;
notes: string | null;
}

129
src/lib/types/ads.ts Normal file
View File

@@ -0,0 +1,129 @@
// Sponsored Ads Module — Types & Interfaces
import type { SurfaceKey, PlacementTargeting, PickerEvent } from './ad-control';
// ===== Enums =====
export type CampaignStatus = 'DRAFT' | 'IN_REVIEW' | 'ACTIVE' | 'PAUSED' | 'ENDED' | 'REJECTED';
export type BillingModel = 'FIXED' | 'CPM' | 'CPC';
export type CampaignObjective = 'AWARENESS' | 'SALES';
export type TrackingEventType = 'IMPRESSION' | 'CLICK';
// ===== Campaign =====
export interface Campaign {
id: string;
partnerId: string;
partnerName: string;
name: string;
objective: CampaignObjective;
status: CampaignStatus;
startAt: string;
endAt: string;
billingModel: BillingModel;
totalBudget: number; // in INR
dailyCap: number | null; // max spend per day
spent: number; // accumulated spend
targeting: PlacementTargeting;
surfaceKeys: SurfaceKey[];
eventIds: string[];
frequencyCap: number; // max impressions per user per day (0 = unlimited)
approvedBy: string | null;
rejectedReason: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
// ===== Campaign with resolved events =====
export interface CampaignWithEvents extends Campaign {
events: PickerEvent[];
}
// ===== Sponsored Placement =====
export interface SponsoredPlacement {
id: string;
campaignId: string;
eventId: string;
surfaceKey: SurfaceKey;
priority: 'SPONSORED';
bid: number; // per-unit cost (CPM rate / CPC rate / fixed allocation)
status: 'ACTIVE' | 'PAUSED';
rank: number;
}
// ===== Tracking Events =====
export interface AdTrackingEvent {
id: string;
type: TrackingEventType;
placementId: string;
campaignId: string;
surfaceKey: SurfaceKey;
eventId: string;
userId: string | null;
anonId: string;
sessionId: string;
timestamp: string;
device: string;
cityId: string;
}
// ===== Daily Stats (Rollup) =====
export interface PlacementDailyStats {
id: string;
campaignId: string;
placementId: string;
surfaceKey: SurfaceKey;
date: string; // YYYY-MM-DD
impressions: number;
clicks: number;
ctr: number; // clicks / impressions
spend: number; // INR
}
// ===== Campaign Report =====
export interface CampaignReport {
campaign: CampaignWithEvents;
totals: {
impressions: number;
clicks: number;
ctr: number;
spend: number;
remaining: number;
};
dailyStats: PlacementDailyStats[];
bySurface: {
surfaceKey: SurfaceKey;
surfaceName: string;
impressions: number;
clicks: number;
ctr: number;
spend: number;
}[];
}
// ===== Form Data =====
export interface CampaignFormData {
// Step 1: Basics
partnerName: string;
name: string;
objective: CampaignObjective;
startAt: string;
endAt: string;
// Step 2: Placement
surfaceKeys: SurfaceKey[];
eventIds: string[];
// Step 3: Targeting
targeting: PlacementTargeting;
// Step 4: Budget
billingModel: BillingModel;
totalBudget: number;
dailyCap: number | null;
frequencyCap: number;
}

173
src/lib/types/settings.ts Normal file
View File

@@ -0,0 +1,173 @@
export interface OrganizationConfig {
brandName: string;
supportEmail: string;
legalAddress: string;
logoUrl?: string; // For emails
socialLinks: {
twitter?: string;
linkedin?: string;
instagram?: string;
};
}
export interface SecurityConfig {
enforce2FA: boolean;
sessionTimeoutMinutes: number;
passwordExpirationDays: number;
}
export interface PublicAppConfig {
maintenanceMode: boolean;
betaFeatures: {
cryptoPayments: boolean;
aiRecommendations: boolean;
socialLogin: boolean;
};
fees: {
platformFeeFlat: number; // e.g. 20 (INR)
taxRatePercent: number; // e.g. 18 (GST)
};
banners: {
id: string;
imageUrl: string;
linkUrl: string;
active: boolean;
}[];
links: {
helpCenterUrl: string;
termsUrl: string;
privacyUrl: string;
};
}
export interface PartnerConfig {
requireKyc: boolean;
manualEventApproval: boolean;
defaultCommissionPercent: number;
payoutSchedule: 'daily' | 'weekly' | 'monthly' | 'manual';
minPayoutAmount: number;
allowedKycDocs: ('pan' | 'gst' | 'aadhaar' | 'cheque')[];
}
export type GatewayProvider = 'razorpay' | 'stripe' | 'payu' | 'easebuzz' | 'worldline';
export interface GatewayCredentials {
enabled: boolean;
mode: 'test' | 'live';
merchantId?: string;
publicKey?: string; // For Stripe
keyId?: string; // For Razorpay
salt?: string; // For PayU/Easebuzz
webhookSecret?: string;
features: {
netbanking: boolean;
upi: boolean;
cards: boolean;
emi: boolean;
wallet: boolean;
};
}
export interface PaymentConfig {
defaultGateway: GatewayProvider;
fallbackGateway: GatewayProvider;
internationalGateway: GatewayProvider;
gateways: Record<GatewayProvider, GatewayCredentials>;
}
export interface GlobalSettings {
organization: OrganizationConfig;
security: SecurityConfig;
publicApp: PublicAppConfig;
partner: PartnerConfig;
system: SystemConfig;
payment: PaymentConfig; // New section
}
export const DEFAULT_SETTINGS: GlobalSettings = {
organization: {
brandName: 'Eventify',
supportEmail: 'support@eventify.com',
legalAddress: '123 Tech Park, Bangalore, India',
socialLinks: {}
},
security: {
enforce2FA: false,
sessionTimeoutMinutes: 60,
passwordExpirationDays: 90
},
publicApp: {
maintenanceMode: false,
betaFeatures: {
cryptoPayments: false,
aiRecommendations: true,
socialLogin: true
},
fees: {
platformFeeFlat: 20,
taxRatePercent: 18
},
banners: [],
links: {
helpCenterUrl: 'https://help.eventify.com',
termsUrl: 'https://eventify.com/terms',
privacyUrl: 'https://eventify.com/privacy'
}
},
partner: {
requireKyc: true,
manualEventApproval: false,
defaultCommissionPercent: 5,
payoutSchedule: 'weekly',
minPayoutAmount: 1000,
allowedKycDocs: ['pan', 'gst', 'cheque']
},
system: {
gateways: { // Legacy structure, will migrate to 'payment'
stripe: { enabled: true, mode: 'test', publicKey: 'pk_test_...' },
razorpay: { enabled: true, mode: 'test', keyId: 'rzp_test_...' }
},
cache: {
ttl: 3600
}
},
payment: {
defaultGateway: 'razorpay',
fallbackGateway: 'stripe',
internationalGateway: 'stripe',
gateways: {
razorpay: {
enabled: true,
mode: 'test',
keyId: 'rzp_test_123456',
features: { netbanking: true, upi: true, cards: true, emi: false, wallet: true }
},
stripe: {
enabled: true,
mode: 'test',
publicKey: 'pk_test_123456',
features: { netbanking: false, upi: false, cards: true, emi: false, wallet: false }
},
payu: {
enabled: false,
mode: 'test',
merchantId: '',
salt: '',
features: { netbanking: true, upi: true, cards: true, emi: true, wallet: true }
},
easebuzz: {
enabled: false,
mode: 'test',
merchantId: '',
salt: '',
features: { netbanking: true, upi: true, cards: true, emi: true, wallet: true }
},
worldline: {
enabled: false,
mode: 'test',
merchantId: '',
features: { netbanking: true, upi: true, cards: true, emi: false, wallet: false }
}
}
}
};

203
src/lib/types/staff.ts Normal file
View File

@@ -0,0 +1,203 @@
// ===== HIERARCHICAL STAFF & RBAC TYPES =====
export type StaffRole = 'SUPER_ADMIN' | 'MANAGER' | 'MEMBER';
export type StaffStatus = 'active' | 'invited' | 'deactivated';
export type Permission = string; // Dotted notation: "finance.refunds.execute"
export interface Department {
id: string;
name: string;
slug: string;
description?: string;
baseScopes: Permission[];
color: string; // For UI badges
createdAt: string;
}
export interface Squad {
id: string;
name: string;
departmentId: string;
managerId: string | null;
extraScopes: Permission[];
createdAt: string;
}
export interface StaffMember {
id: string;
name: string;
email: string;
avatar?: string;
squadId: string | null;
departmentId: string | null;
role: StaffRole;
status: StaffStatus;
joinedAt: string;
}
// ===== SCOPE DEFINITIONS (Human-readable) =====
export const SCOPE_DEFINITIONS: Record<string, { label: string; category: string }> = {
// Users Module
'users.read': { label: 'View Users', category: 'Users' },
'users.write': { label: 'Edit Users', category: 'Users' },
'users.delete': { label: 'Delete Users', category: 'Users' },
'users.ban': { label: 'Ban/Suspend Users', category: 'Users' },
// Events Module
'events.read': { label: 'View Events', category: 'Events' },
'events.write': { label: 'Create/Edit Events', category: 'Events' },
'events.approve': { label: 'Approve Events', category: 'Events' },
'events.delete': { label: 'Delete Events', category: 'Events' },
// Finance Module
'finance.read': { label: 'View Finance Dashboard', category: 'Finance' },
'finance.refunds.read': { label: 'View Refund Requests', category: 'Finance' },
'finance.refunds.execute': { label: 'Process Refunds', category: 'Finance' },
'finance.payouts.read': { label: 'View Payouts', category: 'Finance' },
'finance.payouts.execute': { label: 'Execute Payouts', category: 'Finance' },
// Partners Module
'partners.read': { label: 'View Partners', category: 'Partners' },
'partners.write': { label: 'Edit Partners', category: 'Partners' },
'partners.kyc': { label: 'Verify Partner KYC', category: 'Partners' },
'partners.impersonate': { label: 'Login as Partner', category: 'Partners' },
'partners.events.review': { label: 'Review Partner Events', category: 'Partners' },
'partners.suspend': { label: 'Suspend/Unsuspend Partners', category: 'Partners' },
// Support Module
'tickets.read': { label: 'View Tickets', category: 'Support' },
'tickets.write': { label: 'Respond to Tickets', category: 'Support' },
'tickets.assign': { label: 'Assign Tickets', category: 'Support' },
'tickets.escalate': { label: 'Escalate Tickets', category: 'Support' },
// Settings Module
'settings.read': { label: 'View Settings', category: 'Settings' },
'settings.write': { label: 'Modify Settings', category: 'Settings' },
'settings.staff': { label: 'Manage Staff', category: 'Settings' },
// Ad Control Module
'ads.read': { label: 'View Ad Campaigns', category: 'Ad Control' },
'ads.write': { label: 'Create/Edit Campaigns', category: 'Ad Control' },
'ads.approve': { label: 'Approve Campaigns', category: 'Ad Control' },
'ads.report': { label: 'View Ad Reports', category: 'Ad Control' },
};
export const ALL_SCOPES = Object.keys(SCOPE_DEFINITIONS);
// Group scopes by category for UI
export function getScopesByCategory(): Record<string, { scope: string; label: string }[]> {
const grouped: Record<string, { scope: string; label: string }[]> = {};
for (const [scope, def] of Object.entries(SCOPE_DEFINITIONS)) {
if (!grouped[def.category]) grouped[def.category] = [];
grouped[def.category].push({ scope, label: def.label });
}
return grouped;
}
// ===== MOCK SEED DATA =====
export const MOCK_DEPARTMENTS: Department[] = [
{
id: 'dept_support',
name: 'Customer Support',
slug: 'support',
description: 'Handles user queries, tickets, and escalations.',
baseScopes: ['users.read', 'tickets.read', 'tickets.write'],
color: '#3B82F6',
createdAt: '2025-01-15T10:00:00Z',
},
{
id: 'dept_finance',
name: 'Finance & Accounting',
slug: 'finance',
description: 'Manages refunds, payouts, and financial reporting.',
baseScopes: ['finance.read', 'finance.refunds.read', 'finance.payouts.read'],
color: '#10B981',
createdAt: '2025-01-15T10:00:00Z',
},
{
id: 'dept_ops',
name: 'Operations',
slug: 'operations',
description: 'Event approvals, partner management, and platform ops.',
baseScopes: ['events.read', 'events.write', 'events.approve', 'partners.read', 'partners.write'],
color: '#F59E0B',
createdAt: '2025-02-01T10:00:00Z',
},
{
id: 'dept_tech',
name: 'Technology',
slug: 'tech',
description: 'Engineering, DevOps, and system administration.',
baseScopes: ['settings.read', 'settings.write', 'settings.staff'],
color: '#8B5CF6',
createdAt: '2025-02-10T10:00:00Z',
},
];
export const MOCK_SQUADS: Squad[] = [
// Support squads
{
id: 'squad_l1',
name: 'L1 - First Response',
departmentId: 'dept_support',
managerId: 'staff_sarah',
extraScopes: ['tickets.assign'],
createdAt: '2025-01-20T10:00:00Z',
},
{
id: 'squad_l2',
name: 'L2 - Escalations',
departmentId: 'dept_support',
managerId: 'staff_rahul',
extraScopes: ['tickets.escalate', 'users.write'],
createdAt: '2025-01-20T10:00:00Z',
},
// Finance squads
{
id: 'squad_refunds',
name: 'Refunds Team',
departmentId: 'dept_finance',
managerId: 'staff_priya',
extraScopes: ['finance.refunds.execute'],
createdAt: '2025-02-01T10:00:00Z',
},
{
id: 'squad_payouts',
name: 'Payouts & Settlements',
departmentId: 'dept_finance',
managerId: null,
extraScopes: ['finance.payouts.execute'],
createdAt: '2025-02-05T10:00:00Z',
},
// Ops squads
{
id: 'squad_events_review',
name: 'Event Review',
departmentId: 'dept_ops',
managerId: 'staff_amit',
extraScopes: ['events.delete'],
createdAt: '2025-02-10T10:00:00Z',
},
{
id: 'squad_kyc',
name: 'KYC Verification',
departmentId: 'dept_ops',
managerId: null,
extraScopes: ['partners.kyc'],
createdAt: '2025-02-10T10:00:00Z',
},
];
export const MOCK_STAFF: StaffMember[] = [
{ id: 'staff_admin', name: 'Arjun Mehta', email: 'arjun@eventify.com', avatar: '', squadId: null, departmentId: null, role: 'SUPER_ADMIN', status: 'active', joinedAt: '2024-06-01T10:00:00Z' },
// Support
{ id: 'staff_sarah', name: 'Sarah Khan', email: 'sarah@eventify.com', avatar: '', squadId: 'squad_l1', departmentId: 'dept_support', role: 'MANAGER', status: 'active', joinedAt: '2025-01-20T10:00:00Z' },
{ id: 'staff_rahul', name: 'Rahul Verma', email: 'rahul@eventify.com', avatar: '', squadId: 'squad_l2', departmentId: 'dept_support', role: 'MANAGER', status: 'active', joinedAt: '2025-01-22T10:00:00Z' },
{ id: 'staff_neha', name: 'Neha Sharma', email: 'neha@eventify.com', avatar: '', squadId: 'squad_l1', departmentId: 'dept_support', role: 'MEMBER', status: 'active', joinedAt: '2025-02-01T10:00:00Z' },
{ id: 'staff_vikram', name: 'Vikram Iyer', email: 'vikram@eventify.com', avatar: '', squadId: 'squad_l1', departmentId: 'dept_support', role: 'MEMBER', status: 'active', joinedAt: '2025-02-05T10:00:00Z' },
{ id: 'staff_aisha', name: 'Aisha Patel', email: 'aisha@eventify.com', avatar: '', squadId: 'squad_l2', departmentId: 'dept_support', role: 'MEMBER', status: 'invited', joinedAt: '2025-02-08T10:00:00Z' },
// Finance
{ id: 'staff_priya', name: 'Priya Nair', email: 'priya@eventify.com', avatar: '', squadId: 'squad_refunds', departmentId: 'dept_finance', role: 'MANAGER', status: 'active', joinedAt: '2025-02-01T10:00:00Z' },
{ id: 'staff_deepak', name: 'Deepak Joshi', email: 'deepak@eventify.com', avatar: '', squadId: 'squad_refunds', departmentId: 'dept_finance', role: 'MEMBER', status: 'active', joinedAt: '2025-02-03T10:00:00Z' },
{ id: 'staff_meera', name: 'Meera Gupta', email: 'meera@eventify.com', avatar: '', squadId: 'squad_payouts', departmentId: 'dept_finance', role: 'MEMBER', status: 'active', joinedAt: '2025-02-06T10:00:00Z' },
// Ops
{ id: 'staff_amit', name: 'Amit Desai', email: 'amit@eventify.com', avatar: '', squadId: 'squad_events_review', departmentId: 'dept_ops', role: 'MANAGER', status: 'active', joinedAt: '2025-02-10T10:00:00Z' },
{ id: 'staff_ritu', name: 'Ritu Singh', email: 'ritu@eventify.com', avatar: '', squadId: 'squad_kyc', departmentId: 'dept_ops', role: 'MEMBER', status: 'active', joinedAt: '2025-02-10T10:00:00Z' },
{ id: 'staff_karan', name: 'Karan Reddy', email: 'karan@eventify.com', avatar: '', squadId: 'squad_events_review', departmentId: 'dept_ops', role: 'MEMBER', status: 'invited', joinedAt: '2025-02-10T10:00:00Z' },
];

256
src/pages/AdControl.tsx Normal file
View File

@@ -0,0 +1,256 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Plus, Search, RefreshCw, Loader2, Megaphone, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { SurfaceTabs } from '@/features/ad-control/components/SurfaceTabs';
import { PlacementList } from '@/features/ad-control/components/PlacementList';
import { EventPickerModal } from '@/features/ad-control/components/EventPickerModal';
import { PlacementConfigDrawer } from '@/features/ad-control/components/PlacementConfigDrawer';
import { getSurfaces, getPlacements, getPickerEvents } from '@/lib/actions/ad-control';
import type { Surface, PlacementWithEvent, PlacementStatus, PickerEvent } from '@/lib/types/ad-control';
type StatusFilter = 'ALL' | PlacementStatus;
export default function AdControl() {
const navigate = useNavigate();
// Data
const [surfaces, setSurfaces] = useState<Surface[]>([]);
const [placements, setPlacements] = useState<PlacementWithEvent[]>([]);
const [pickerEvents, setPickerEvents] = useState<PickerEvent[]>([]);
const [allPlacements, setAllPlacements] = useState<PlacementWithEvent[]>([]);
// UI State
const [activeSurfaceId, setActiveSurfaceId] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
// Modals
const [pickerOpen, setPickerOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<PickerEvent | null>(null);
const [editingPlacement, setEditingPlacement] = useState<PlacementWithEvent | null>(null);
// --- Data Fetching ---
const loadSurfaces = useCallback(async () => {
const res = await getSurfaces();
if (res.success) {
setSurfaces(res.data);
if (!activeSurfaceId && res.data.length > 0) setActiveSurfaceId(res.data[0].id);
}
}, []);
const loadPlacements = useCallback(async () => {
// Load all placements for count badges
const allRes = await getPlacements();
if (allRes.success) setAllPlacements(allRes.data);
// Load filtered for active surface
if (!activeSurfaceId) return;
const res = await getPlacements(activeSurfaceId, statusFilter);
if (res.success) setPlacements(res.data);
}, [activeSurfaceId, statusFilter]);
const loadPickerEvents = useCallback(async () => {
const res = await getPickerEvents();
if (res.success) setPickerEvents(res.data);
}, []);
useEffect(() => {
const init = async () => {
setLoading(true);
await loadSurfaces();
await loadPickerEvents();
setLoading(false);
};
init();
}, []);
useEffect(() => {
if (activeSurfaceId) loadPlacements();
}, [activeSurfaceId, statusFilter]);
const handleRefresh = useCallback(async () => {
setLoading(true);
await loadPlacements();
setLoading(false);
}, [loadPlacements]);
// --- Computed ---
const activeSurface = surfaces.find(s => s.id === activeSurfaceId);
const placementCounts = useMemo(() => {
const counts: Record<string, number> = {};
allPlacements.forEach(p => {
if (p.status === 'ACTIVE' || p.status === 'SCHEDULED') {
counts[p.surfaceId] = (counts[p.surfaceId] || 0) + 1;
}
});
return counts;
}, [allPlacements]);
const filteredPlacements = useMemo(() => {
if (!searchQuery.trim()) return placements;
const q = searchQuery.toLowerCase();
return placements.filter(p =>
p.event?.title.toLowerCase().includes(q) ||
p.event?.city.toLowerCase().includes(q) ||
p.event?.organizer.toLowerCase().includes(q) ||
p.eventId?.toLowerCase().includes(q)
);
}, [placements, searchQuery]);
const alreadyPlacedEventIds = useMemo(() => {
return allPlacements
.filter(p => p.surfaceId === activeSurfaceId && p.status !== 'EXPIRED' && p.status !== 'DISABLED')
.map(p => p.eventId)
.filter(Boolean) as string[];
}, [allPlacements, activeSurfaceId]);
const activeCount = placementCounts[activeSurfaceId] || 0;
// --- Handlers ---
const handleEventSelected = (event: PickerEvent) => {
setSelectedEvent(event);
setEditingPlacement(null);
setDrawerOpen(true);
};
const handleEditPlacement = (placement: PlacementWithEvent) => {
setEditingPlacement(placement);
setSelectedEvent(null);
setDrawerOpen(true);
};
const handleDrawerComplete = () => {
setSelectedEvent(null);
setEditingPlacement(null);
handleRefresh();
};
// --- Render ---
if (loading && surfaces.length === 0) {
return (
<AppLayout title="Ad Control" description="Manage event placements and promotions">
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</AppLayout>
);
}
return (
<TooltipProvider>
<AppLayout title="Ad Control" description="Manage featured events, top picks, and sponsored placements">
{/* Sub-navigation */}
<div className="flex items-center gap-3 mb-6 pb-4 border-b">
<Button variant="secondary" className="gap-2 font-semibold" disabled>
<Megaphone className="h-4 w-4" /> Manual Placements
</Button>
<Button variant="outline" className="gap-2" onClick={() => navigate('/ad-control/sponsored')}>
<Zap className="h-4 w-4" /> Sponsored Campaigns
</Button>
</div>
<div className="flex gap-6">
{/* Left — Surface Tabs */}
<SurfaceTabs
surfaces={surfaces}
activeSurfaceId={activeSurfaceId}
onSelect={(id) => { setActiveSurfaceId(id); setStatusFilter('ALL'); setSearchQuery(''); }}
placementCounts={placementCounts}
/>
{/* Right — Placement Editor */}
<div className="flex-1 min-w-0">
{/* Surface Header */}
{activeSurface && (
<div className="mb-6">
<div className="flex items-center justify-between mb-1">
<div>
<h2 className="text-xl font-bold">{activeSurface.name}</h2>
<p className="text-sm text-muted-foreground">{activeSurface.description}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm py-1 px-3 font-mono">
{activeCount} / {activeSurface.maxSlots} slots used
</Badge>
<Button onClick={() => setPickerOpen(true)} className="gap-2" disabled={activeCount >= activeSurface.maxSlots}>
<Plus className="h-4 w-4" />
Add Event
</Button>
</div>
</div>
</div>
)}
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
{/* Search */}
<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={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search placements..."
className="pl-10"
/>
</div>
{/* Status Filter */}
<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="SCHEDULED" className="text-xs px-3">Scheduled</TabsTrigger>
<TabsTrigger value="DRAFT" className="text-xs px-3">Draft</TabsTrigger>
<TabsTrigger value="EXPIRED" className="text-xs px-3">Expired</TabsTrigger>
</TabsList>
</Tabs>
{/* Refresh */}
<Button variant="ghost" size="icon" onClick={handleRefresh} className="h-9 w-9">
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
{/* Placement List */}
<PlacementList
placements={filteredPlacements}
surfaceId={activeSurfaceId}
onEdit={handleEditPlacement}
onRefresh={handleRefresh}
/>
</div>
</div>
{/* Modals */}
<EventPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
events={pickerEvents}
onSelectEvent={handleEventSelected}
alreadyPlacedEventIds={alreadyPlacedEventIds}
/>
<PlacementConfigDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
event={selectedEvent}
surfaceId={activeSurfaceId}
editingPlacement={editingPlacement}
onComplete={handleDrawerComplete}
/>
</AppLayout>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,13 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { CampaignReportPage } from '@/features/ad-control/components/sponsored/CampaignReportPage';
export default function CampaignReport() {
return (
<TooltipProvider>
<AppLayout title="Campaign Report" description="Performance metrics and analytics">
<CampaignReportPage />
</AppLayout>
</TooltipProvider>
);
}

13
src/pages/NewCampaign.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { CampaignWizard } from '@/features/ad-control/components/sponsored/CampaignWizard';
export default function NewCampaign() {
return (
<TooltipProvider>
<AppLayout title="New Sponsored Campaign" description="Create a new paid placement campaign">
<CampaignWizard />
</AppLayout>
</TooltipProvider>
);
}

View File

@@ -1,129 +1,264 @@
import { Users, UserCheck, AlertTriangle, Search, Filter } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, UserCheck, AlertTriangle, Search, Plus, Clock, MoreHorizontal, Eye, Ban, ShieldCheck, Loader2 } from 'lucide-react';
import { AppLayout } from '@/components/layout/AppLayout';
import { cn } from '@/lib/utils';
import { mockPartners, formatCurrency } from '@/data/mockData';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AddPartnerSheet } from '@/features/partners/components/AddPartnerSheet';
import { fetchPartners as fetchPartnersApi, Partner } from '@/services/partnerApi';
function KycBadge({ status }: { status: string }) {
const normalized = status?.toLowerCase();
if (normalized === 'approved') return (
<Badge variant="outline" className="bg-success/10 text-success border-success/20 gap-1 text-xs">
<ShieldCheck className="h-3 w-3" /> Approved
</Badge>
);
if (normalized === '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>
);
}
function StatusBadge({ status }: { status: string }) {
const normalized = status?.toLowerCase();
if (normalized === 'active') return (
<Badge variant="outline" className="bg-success/10 text-success border-success/20 text-xs">Active</Badge>
);
if (normalized === 'suspended') return (
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20 text-xs">Suspended</Badge>
);
return (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 text-xs capitalize">{status}</Badge>
);
}
export default function Partners() {
return (
<AppLayout
title="Partner Management"
description="Manage partner accounts, KYC approvals, and Stripe connections."
>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="neu-card p-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-accent/10 flex items-center justify-center">
<Users className="h-6 w-6 text-accent" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">156</p>
<p className="text-sm text-muted-foreground">Total Partners</p>
</div>
</div>
</div>
<div className="neu-card p-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-warning/10 flex items-center justify-center">
<UserCheck className="h-6 w-6 text-warning" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">12</p>
<p className="text-sm text-muted-foreground">Pending KYC</p>
</div>
</div>
</div>
<div className="neu-card p-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-error/10 flex items-center justify-center">
<AlertTriangle className="h-6 w-6 text-error" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">2</p>
<p className="text-sm text-muted-foreground">Stripe Issues</p>
</div>
</div>
</div>
</div>
const navigate = useNavigate();
const [partners, setPartners] = useState<Partner[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState('all');
{/* Partners Table */}
<div className="neu-card p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold text-foreground">All Partners</h2>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search partners..."
className="h-10 w-64 pl-10 pr-4 rounded-xl text-sm bg-secondary shadow-neu-inset focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<button className="h-10 px-4 rounded-xl neu-button flex items-center gap-2">
<Filter className="h-4 w-4" />
<span className="text-sm font-medium">Filter</span>
</button>
</div>
</div>
const loadPartners = async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchPartnersApi();
setPartners(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch partners');
} finally {
setIsLoading(false);
}
};
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="text-left py-3 px-4 text-sm font-semibold text-muted-foreground">Partner</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-muted-foreground">KYC Status</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-muted-foreground">Stripe</th>
<th className="text-right py-3 px-4 text-sm font-semibold text-muted-foreground">Revenue</th>
<th className="text-right py-3 px-4 text-sm font-semibold text-muted-foreground">Events</th>
<th className="text-right py-3 px-4 text-sm font-semibold text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{mockPartners.map((partner) => (
<tr key={partner.id} className="border-b border-border/30 hover:bg-secondary/30 transition-colors">
<td className="py-4 px-4">
<div>
<p className="font-medium text-foreground">{partner.name}</p>
<p className="text-sm text-muted-foreground">{partner.email}</p>
useEffect(() => {
loadPartners();
}, []);
const filteredPartners = useMemo(() => {
let list = [...partners];
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter(p =>
p.name.toLowerCase().includes(q) ||
p.primary_contact_person_name.toLowerCase().includes(q) ||
p.primary_contact_person_email.toLowerCase().includes(q)
);
}
switch (activeTab) {
case 'pending_kyc':
list = list.filter(p => p.kyc_compliance_status?.toLowerCase() === 'pending');
break;
case 'active':
list = list.filter(p => p.status?.toLowerCase() === 'active');
break;
}
return list;
}, [partners, searchQuery, activeTab]);
const stats = useMemo(() => ({
total: partners.length,
active: partners.filter(p => p.status?.toLowerCase() === 'active').length,
pendingKyc: partners.filter(p => p.kyc_compliance_status?.toLowerCase() === 'pending').length,
kycApproved: partners.filter(p => p.is_kyc_compliant).length,
}), [partners]);
return (
<AppLayout
title="Partner Management"
description="Manage partner accounts, KYC approvals, and onboarding."
>
{/* 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>
</td>
<td className="py-4 px-4">
<span className={cn(
"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium",
partner.kycStatus === 'approved' && "bg-success/10 text-success",
partner.kycStatus === 'pending' && "bg-warning/10 text-warning",
partner.kycStatus === 'rejected' && "bg-error/10 text-error"
)}>
{partner.kycStatus.charAt(0).toUpperCase() + partner.kycStatus.slice(1)}
</span>
</td>
<td className="py-4 px-4">
<span className={cn(
"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium",
partner.stripeStatus === 'connected' && "bg-success/10 text-success",
partner.stripeStatus === 'pending' && "bg-warning/10 text-warning",
partner.stripeStatus === 'failed' && "bg-error/10 text-error"
)}>
{partner.stripeStatus.charAt(0).toUpperCase() + partner.stripeStatus.slice(1)}
</span>
</td>
<td className="py-4 px-4 text-right font-medium text-foreground">
{formatCurrency(partner.totalRevenue)}
</td>
<td className="py-4 px-4 text-right font-medium text-foreground">
{partner.eventsCount}
</td>
<td className="py-4 px-4 text-right">
<button className="text-sm font-medium text-accent hover:underline">
View
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</AppLayout>
);
<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">
<ShieldCheck className="h-3.5 w-3.5" /> KYC Approved
</div>
<p className="text-2xl font-bold text-success">{stats.kycApproved}</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>
<AddPartnerSheet>
<Button className="gap-2 shrink-0">
<Plus className="h-4 w-4" /> Add Partner
</Button>
</AddPartnerSheet>
</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">{stats.total}</Badge>
</TabsTrigger>
<TabsTrigger value="active" className="gap-1.5">
Active <Badge variant="secondary" className="text-[10px] h-5 px-1.5 ml-1 bg-success/10 text-success">{stats.active}</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>
</TabsList>
<div className="neu-card overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-16">
<AlertTriangle className="h-10 w-10 mx-auto mb-3 text-destructive/50" />
<p className="text-destructive text-sm font-medium">{error}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={loadPartners}>
Retry
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Partner</TableHead>
<TableHead>Type</TableHead>
<TableHead>KYC Status</TableHead>
<TableHead>Location</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 border border-border/50 shrink-0">
<span className="text-xs font-bold text-muted-foreground">
{partner.name.substring(0, 2).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium text-sm">{partner.name}</p>
<p className="text-xs text-muted-foreground">{partner.primary_contact_person_email}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs capitalize">
{partner.partner_type}
</Badge>
</TableCell>
<TableCell>
<KycBadge status={partner.kyc_compliance_status} />
</TableCell>
<TableCell>
<p className="text-sm">{partner.city}</p>
<p className="text-xs text-muted-foreground">{partner.state}</p>
</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>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} 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>
);
}

238
src/pages/Reviews.tsx Normal file
View File

@@ -0,0 +1,238 @@
import { useState } from 'react';
import { AppLayout } from '@/components/layout/AppLayout';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ReviewMetricsBar } from '@/features/reviews/components/ReviewMetricsBar';
import { PendingReviewsTable } from '@/features/reviews/components/PendingReviewsTable';
import { LiveReviewsTable } from '@/features/reviews/components/LiveReviewsTable';
import { ReviewEmptyState } from '@/features/reviews/components/ReviewEmptyState';
import { ReviewDrawer } from '@/features/reviews/components/ReviewDrawer';
import { RejectReviewDialog } from '@/features/reviews/components/RejectReviewDialog';
import { DeleteReviewDialog } from '@/features/reviews/components/DeleteReviewDialog';
import {
mockPendingReviews,
mockLiveReviews,
mockReviewMetrics,
} from '@/features/reviews/data/mockReviewData';
import type { Review, RejectReason, ReviewMetrics } from '@/types/review';
import { toast } from 'sonner';
import { Clock, Radio } from 'lucide-react';
export default function Reviews() {
// Data state
const [pendingReviews, setPendingReviews] = useState<Review[]>(mockPendingReviews);
const [liveReviews, setLiveReviews] = useState<Review[]>(mockLiveReviews);
const [metrics, setMetrics] = useState<ReviewMetrics>(mockReviewMetrics);
// UI state
const [activeTab, setActiveTab] = useState('pending');
const [selectedReview, setSelectedReview] = useState<Review | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerMode, setDrawerMode] = useState<'pending' | 'live'>('pending');
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// --- PENDING TAB ACTIONS ---
const handleApprove = (review: Review) => {
setPendingReviews((prev) => prev.filter((r) => r.id !== review.id));
setLiveReviews((prev) => [{ ...review, status: 'live' as const }, ...prev]);
setMetrics((prev) => ({
...prev,
totalPending: prev.totalPending - 1,
liveReviews: prev.liveReviews + 1,
}));
toast.success('Review approved and published!', {
description: `${review.reviewerName}'s review for "${review.eventName}" is now live.`,
});
};
const handleRejectClick = (review: Review) => {
setSelectedReview(review);
setRejectDialogOpen(true);
};
const handleRejectConfirm = (review: Review, _reason: RejectReason) => {
setPendingReviews((prev) => prev.filter((r) => r.id !== review.id));
setMetrics((prev) => ({
...prev,
totalPending: prev.totalPending - 1,
rejected: prev.rejected + 1,
}));
setRejectDialogOpen(false);
setSelectedReview(null);
toast.error('Review rejected', {
description: `${review.reviewerName}'s review has been rejected.`,
});
};
const handleEditPending = (review: Review) => {
setSelectedReview(review);
setDrawerMode('pending');
setDrawerOpen(true);
};
const handleSaveAndApprove = (review: Review, editedText: string) => {
const updatedReview = { ...review, reviewText: editedText, status: 'live' as const };
setPendingReviews((prev) => prev.filter((r) => r.id !== review.id));
setLiveReviews((prev) => [updatedReview, ...prev]);
setMetrics((prev) => ({
...prev,
totalPending: prev.totalPending - 1,
liveReviews: prev.liveReviews + 1,
}));
setDrawerOpen(false);
setSelectedReview(null);
toast.success('Review moderated and published!', {
description: 'Changes saved and the review is now live.',
});
};
// --- LIVE TAB ACTIONS ---
const handleViewLive = (review: Review) => {
setSelectedReview(review);
setDrawerMode('live');
setDrawerOpen(true);
};
const handleEditLive = (review: Review) => {
setSelectedReview(review);
setDrawerMode('live');
setDrawerOpen(true);
};
const handleDeleteClick = (review: Review) => {
setSelectedReview(review);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = (review: Review, _reason: RejectReason) => {
setLiveReviews((prev) => prev.filter((r) => r.id !== review.id));
setMetrics((prev) => ({
...prev,
liveReviews: prev.liveReviews - 1,
rejected: prev.rejected + 1,
}));
setDeleteDialogOpen(false);
setSelectedReview(null);
toast.error('Review deleted', {
description: `The review by ${review.reviewerName} has been removed.`,
});
};
const handleSaveLiveEdit = (review: Review, editedText: string) => {
setLiveReviews((prev) =>
prev.map((r) => (r.id === review.id ? { ...r, reviewText: editedText } : r))
);
setDrawerOpen(false);
setSelectedReview(null);
toast.success('Review updated!', {
description: 'Changes have been saved successfully.',
});
};
return (
<TooltipProvider>
<AppLayout
title="Review Management"
description="Moderate and manage user feedback across all events."
>
<div className="space-y-6">
{/* Metrics */}
<ReviewMetricsBar metrics={metrics} />
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="h-12 bg-white border-2 border-black/10 rounded-xl p-1 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.05)] mb-6">
<TabsTrigger
value="pending"
className="rounded-lg px-5 py-2.5 text-sm font-bold data-[state=active]:bg-black data-[state=active]:text-white data-[state=active]:shadow-[2px_2px_0px_0px_rgba(0,0,0,0.2)] transition-all gap-2"
>
<Clock className="h-4 w-4" />
Pending Approvals
{pendingReviews.length > 0 && (
<span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-amber-500 px-1.5 text-[10px] font-bold text-white font-mono">
{pendingReviews.length}
</span>
)}
</TabsTrigger>
<TabsTrigger
value="live"
className="rounded-lg px-5 py-2.5 text-sm font-bold data-[state=active]:bg-black data-[state=active]:text-white data-[state=active]:shadow-[2px_2px_0px_0px_rgba(0,0,0,0.2)] transition-all gap-2"
>
<Radio className="h-4 w-4" />
Live Reviews
<span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-emerald-500 px-1.5 text-[10px] font-bold text-white font-mono">
{liveReviews.length}
</span>
</TabsTrigger>
</TabsList>
{/* Pending Tab */}
<TabsContent value="pending" className="animate-in fade-in-50 duration-300">
{pendingReviews.length > 0 ? (
<>
<div className="mb-4">
<p className="text-sm text-muted-foreground">
Showing <span className="font-semibold text-foreground font-mono">{pendingReviews.length}</span> reviews
awaiting moderation
</p>
</div>
<PendingReviewsTable
reviews={pendingReviews}
onApprove={handleApprove}
onReject={handleRejectClick}
onEdit={handleEditPending}
/>
</>
) : (
<ReviewEmptyState />
)}
</TabsContent>
{/* Live Tab */}
<TabsContent value="live" className="animate-in fade-in-50 duration-300">
<div className="mb-4">
<p className="text-sm text-muted-foreground">
Showing <span className="font-semibold text-foreground font-mono">{liveReviews.length}</span> published
reviews
</p>
</div>
<LiveReviewsTable
reviews={liveReviews}
onView={handleViewLive}
onEdit={handleEditLive}
onDelete={handleDeleteClick}
/>
</TabsContent>
</Tabs>
</div>
{/* Modals & Drawers */}
<ReviewDrawer
review={selectedReview}
open={drawerOpen}
onOpenChange={setDrawerOpen}
onSaveAndApprove={handleSaveAndApprove}
onSave={handleSaveLiveEdit}
mode={drawerMode}
/>
<RejectReviewDialog
review={selectedReview}
open={rejectDialogOpen}
onOpenChange={setRejectDialogOpen}
onConfirm={handleRejectConfirm}
/>
<DeleteReviewDialog
review={selectedReview}
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteConfirm}
/>
</AppLayout>
</TooltipProvider>
);
}

View File

@@ -1,32 +1,10 @@
import { AppLayout } from '@/components/layout/AppLayout';
import { OrganizationProfileCard } from '@/features/settings/components/OrganizationProfileCard';
import { PayoutBankingCard } from '@/features/settings/components/PayoutBankingCard';
import { TeamSecurityCard } from '@/features/settings/components/TeamSecurityCard';
import { DeveloperSection } from '@/features/settings/components/DeveloperSection';
import { PaymentGatewayCard } from '@/features/settings/components/PaymentGatewayCard';
import { SettingsLayout } from '@/features/settings/components/SettingsLayout';
export default function Settings() {
return (
<AppLayout
title="Settings"
description="Manage platform configuration and critical operations."
>
<div className="space-y-6 max-w-[1200px]">
{/* Top Grid: Identity & Banking */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-auto lg:h-[400px]">
<OrganizationProfileCard />
<PayoutBankingCard />
</div>
{/* Middle Grid: Gateways & Security */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-auto">
<PaymentGatewayCard />
<TeamSecurityCard />
</div>
{/* Collapsible Developer Section */}
<DeveloperSection />
</div>
<AppLayout>
<SettingsLayout />
</AppLayout>
);
}

View File

@@ -0,0 +1,13 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { SponsoredDashboard } from '@/features/ad-control/components/sponsored/SponsoredDashboard';
export default function SponsoredAds() {
return (
<TooltipProvider>
<AppLayout title="Sponsored Campaigns" description="Create, manage, and monitor paid sponsored placements">
<SponsoredDashboard />
</AppLayout>
</TooltipProvider>
);
}

View File

@@ -26,7 +26,9 @@ import { SuspensionModal } from '@/features/users/components/SuspensionModal';
import { BanModal } from '@/features/users/components/BanModal';
import { DeleteConfirmDialog } from '@/features/users/components/DeleteConfirmDialog';
import { NotificationComposer } from '@/features/users/components/NotificationComposer';
import { BulkActionsDropdown } from '@/features/users/components/BulkActionsDropdown';
import { BulkActionBar } from '@/features/users/components/BulkActionBar';
import { BulkSuspendDialog } from '@/features/users/components/dialogs/BulkSuspendDialog';
import { BulkTagDialog } from '@/features/users/components/dialogs/BulkTagDialog';
// Data & Types
import { mockCrmUsers, mockUserMetrics } from '@/features/users/data/mockUserCrmData';
@@ -57,6 +59,8 @@ export default function Users() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [notificationComposerOpen, setNotificationComposerOpen] = useState(false);
const [notificationTargetUsers, setNotificationTargetUsers] = useState<User[]>([]);
const [bulkSuspendOpen, setBulkSuspendOpen] = useState(false);
const [bulkTagOpen, setBulkTagOpen] = useState(false);
// Sync URL params to filters
const mergedFilters: SegmentFilters = useMemo(() => ({
@@ -232,26 +236,6 @@ export default function Users() {
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Bulk Actions (only when selected) */}
<BulkActionsDropdown
selectedUsers={selectedUsers}
onClearSelection={() => setSelectedUserIds([])}
onSendNotification={handleBulkNotification}
onSuspendUsers={(users) => {
if (users.length === 1) {
handleSuspendUser(users[0]);
} else {
toast.info(`Suspend ${users.length} users`);
}
}}
onBanUsers={(users) => {
if (users.length === 1) {
handleBanUser(users[0]);
} else {
toast.info(`Ban ${users.length} users`);
}
}}
/>
{/* View Toggle */}
<div className="flex border rounded-lg overflow-hidden bg-white/50">
@@ -430,6 +414,30 @@ export default function Users() {
onOpenChange={setNotificationComposerOpen}
onSent={() => setNotificationTargetUsers([])}
/>
<BulkSuspendDialog
users={selectedUsers}
open={bulkSuspendOpen}
onOpenChange={setBulkSuspendOpen}
onComplete={() => { setSelectedUserIds([]); handleRefresh(); }}
/>
<BulkTagDialog
users={selectedUsers}
open={bulkTagOpen}
onOpenChange={setBulkTagOpen}
onComplete={() => { setSelectedUserIds([]); handleRefresh(); }}
/>
{/* Floating Bulk Action Bar */}
<BulkActionBar
selectedUsers={selectedUsers}
onClearSelection={() => setSelectedUserIds([])}
onOpenSuspendDialog={() => setBulkSuspendOpen(true)}
onOpenTagDialog={() => setBulkTagOpen(true)}
onOpenEmailComposer={() => handleBulkNotification(selectedUsers)}
onComplete={handleRefresh}
/>
</AppLayout>
</TooltipProvider>
);

45
src/services/api.ts Normal file
View File

@@ -0,0 +1,45 @@
import { AuthError, getStoredAuth } from './auth';
/**
* Centralized API client. Every backend call in the project goes through here.
*
* - Automatically injects `username` and `token` from localStorage
* - All requests are POST with JSON body (matches backend convention)
* - Handles error responses and token expiry uniformly
*/
export async function apiPost<T = any>(
url: string,
body: Record<string, any> = {},
{ skipAuth = false }: { skipAuth?: boolean } = {},
): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
let authPayload: Record<string, string> = {};
if (!skipAuth) {
const stored = getStoredAuth();
if (!stored) {
throw new AuthError('Not authenticated', true);
}
authPayload = { username: stored.username, token: stored.token };
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ ...authPayload, ...body }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data.message || data.error || data.errors || 'Request failed';
const isTokenError =
res.status === 401 ||
res.status === 403 ||
(typeof message === 'string' && message.toLowerCase().includes('invalid token'));
throw new AuthError(message, isTokenError);
}
return data as T;
}

View File

@@ -1,26 +1,52 @@
// Authentication service based on UAT admin panel pattern
import { apiPost } from './api';
const AUTH_API_URL = import.meta.env.VITE_AUTH_API_URL || 'https://uat.eventifyplus.com/api/';
const AUTH_BASE_URL = '/accounts/api/';
export interface AuthUser {
id: number;
username: string;
token: string;
first_name?: string;
last_name?: string;
email?: string;
profile_photo?: string;
first_name: string;
last_name: string;
email: string;
phone_number: string;
role: string;
is_staff: boolean;
is_customer: boolean;
is_user: boolean;
pincode: string | null;
district: string | null;
state: string | null;
country: string | null;
place: string | null;
latitude: number | null;
longitude: number | null;
profile_picture: string;
}
export interface LoginResponse {
username: string;
status: string;
message: string;
token: string;
message?: string;
user?: {
first_name?: string;
last_name?: string;
email?: string;
phone_number?: string;
profile_photo?: string;
user: {
id: number;
username: string;
first_name: string;
last_name: string;
email: string;
phone_number: string;
role: string;
is_staff: boolean;
is_customer: boolean;
is_user: boolean;
pincode: string | null;
district: string | null;
state: string | null;
country: string | null;
place: string | null;
latitude: number | null;
longitude: number | null;
profile_picture: string;
};
}
@@ -34,132 +60,51 @@ export class AuthError extends Error {
}
}
/**
* Login with username and password
*/
export const login = async (username: string, password: string): Promise<LoginResponse> => {
console.log('Bypassing auth for dev as requested');
const data = await apiPost<LoginResponse>(
`${AUTH_BASE_URL}login/`,
{ username, password },
{ skipAuth: true },
);
// Return mock successful response immediately
return {
username: username,
token: 'dev-bypass-token-' + Date.now(),
message: 'Login successful (Bypass)',
user: {
first_name: 'Admin',
last_name: 'User (Bypass)',
email: username,
profile_photo: '', // Placeholder or empty
},
};
};
/**
* Check user status with token
*/
export const checkUserStatus = async (username: string, token: string): Promise<any> => {
// Support bypass token
if (token && token.startsWith('dev-bypass-token')) {
return {
status: 'active',
user: {
first_name: 'Admin',
last_name: 'User (Bypass)',
email: username || 'admin@example.com',
}
};
}
const statusUrl = `${AUTH_API_URL}user/status`;
const res = await fetch(statusUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Status check failed';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') ||
errorMessage.toLowerCase().includes('token') && (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
if (data.status !== 'success') {
throw new AuthError(data.message || 'Login failed', false);
}
return data;
};
/**
* Logout user
*/
export const logout = async (username: string, token: string): Promise<any> => {
// Handle bypass token logout locally
if (token && token.startsWith('dev-bypass-token')) {
return { message: 'Logged out successfully' };
}
const logoutUrl = `${AUTH_API_URL}user/logout`;
const res = await fetch(logoutUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new AuthError(data.message || data.errors || data.error || 'Logout failed');
}
return data;
export const logout = async (): Promise<{ message: string }> => {
return apiPost(`${AUTH_BASE_URL}logout/`);
};
export const checkUserStatus = async (_username: string, token: string): Promise<{ valid: true }> => {
const stored = getStoredAuth();
if (stored && stored.token === token) {
return { valid: true };
}
throw new AuthError('No valid session found', true);
};
/**
* Get stored authentication data from localStorage
*/
export const getStoredAuth = (): AuthUser | null => {
try {
const username = localStorage.getItem('username');
const token = localStorage.getItem('token');
const userData = localStorage.getItem('userData');
if (!username || !token) {
return null;
}
const parsedUserData = userData ? JSON.parse(userData) : {};
return {
username,
token,
...parsedUserData,
};
} catch (error) {
console.error('Error reading stored auth:', error);
const raw = localStorage.getItem('authUser');
if (!raw) return null;
return JSON.parse(raw) as AuthUser;
} catch {
console.error('Error reading stored auth');
return null;
}
};
/**
* Store authentication data in localStorage
*/
export const storeAuth = (loginResponse: LoginResponse): void => {
localStorage.setItem('username', loginResponse.username);
localStorage.setItem('token', loginResponse.token);
if (loginResponse.user) {
localStorage.setItem('userData', JSON.stringify(loginResponse.user));
}
export const storeAuth = (response: LoginResponse): void => {
const authUser: AuthUser = {
...response.user,
token: response.token,
};
localStorage.setItem('authUser', JSON.stringify(authUser));
};
/**
* Clear authentication data from localStorage
*/
export const clearAuth = (): void => {
localStorage.removeItem('username');
localStorage.removeItem('token');
localStorage.removeItem('userData');
localStorage.removeItem('authUser');
};

View File

@@ -1,207 +1,44 @@
// API service for Partner Dashboard (partner.prototype.eventifyplus.com)
import { AuthError } from './auth';
const API_URL = import.meta.env.VITE_PARTNER_APP_API_URL || 'https://partner.prototype.eventifyplus.com/api/';
import { apiPost } from './api';
export interface Partner {
id: number;
name: string;
email: string;
phone?: string;
company_name?: string;
kyc_status: 'pending' | 'approved' | 'rejected';
stripe_status: 'pending' | 'connected' | 'failed';
stripe_account_id?: string;
total_revenue?: number;
events_count?: number;
created_at?: string;
kyc_documents?: {
id_proof?: string;
address_proof?: string;
business_registration?: string;
};
partner_type: string;
primary_contact_person_name: string;
primary_contact_person_email: string;
primary_contact_person_phone: string;
status: string;
address: string;
city: string;
state: string;
country: string;
website_url: string | null;
pincode: string;
latitude: string;
longitude: string;
is_kyc_compliant: boolean;
kyc_compliance_status: string;
kyc_compliance_reason: string | null;
kyc_compliance_document_type: string | null;
kyc_compliance_document_other_type: string | null;
kyc_compliance_document_number: string | null;
kyc_compliance_document_file: string | null;
}
export interface PartnerEvent {
id: number;
partner_id: number;
title: string;
description?: string;
date: string;
time?: string;
venue?: string;
ticket_price?: number;
total_tickets?: number;
tickets_sold?: number;
status: 'draft' | 'pending_approval' | 'approved' | 'live' | 'completed' | 'cancelled';
revenue?: number;
}
export interface Staff {
id: number;
partner_id: number;
export interface CreatePartnerPayload {
name: string;
email: string;
role: string;
permissions?: string[];
status: 'active' | 'inactive';
partner_type: string;
primary_contact_person_name: string;
primary_contact_person_email: string;
primary_contact_person_phone?: string;
website_url?: string;
}
/**
* Fetch all partners (admin only)
*/
export const fetchPartners = async (username: string, token: string): Promise<Partner[]> => {
const res = await fetch(`${API_URL}partners/all/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch partners';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
export const fetchPartners = async (): Promise<Partner[]> => {
const data = await apiPost<{ status: string; partners: Partner[] }>('/partner/list/');
return data.partners || [];
};
/**
* Update partner KYC status (admin only)
*/
export const updatePartnerKYC = async (
username: string,
token: string,
partnerId: number,
status: 'approved' | 'rejected',
notes?: string
): Promise<any> => {
const res = await fetch(`${API_URL}partners/kyc/update/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, partner_id: partnerId, status, notes }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to update KYC status';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
return data;
};
/**
* Fetch partner events (admin can view all)
*/
export const fetchPartnerEvents = async (
username: string,
token: string,
partnerId?: number
): Promise<PartnerEvent[]> => {
const res = await fetch(`${API_URL}events/partner/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, partner_id: partnerId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch partner events';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
return data.events || [];
};
/**
* Approve or reject partner event (admin only)
*/
export const moderatePartnerEvent = async (
username: string,
token: string,
eventId: number,
action: 'approve' | 'reject',
reason?: string
): Promise<any> => {
const res = await fetch(`${API_URL}events/moderate/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, event_id: eventId, action, reason }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to moderate event';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
return data;
};
/**
* Fetch partner staff members
*/
export const fetchPartnerStaff = async (
username: string,
token: string,
partnerId?: number
): Promise<Staff[]> => {
const res = await fetch(`${API_URL}staff/all/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, partner_id: partnerId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch staff';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
return data.staff || [];
};
/**
* Update partner Stripe connection status
*/
export const updatePartnerStripe = async (
username: string,
token: string,
partnerId: number,
stripeAccountId: string,
status: 'connected' | 'failed'
): Promise<any> => {
const res = await fetch(`${API_URL}partners/stripe/update/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
token,
partner_id: partnerId,
stripe_account_id: stripeAccountId,
status
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to update Stripe status';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
return data;
export const createPartner = async (payload: CreatePartnerPayload): Promise<any> => {
return apiPost('/partner/create/', payload);
};

View File

@@ -1,7 +1,4 @@
// API service for User App (mvnew.eventifyplus.com)
// Based on UAT admin panel API patterns
import { AuthError } from './auth';
import { apiPost } from './api';
const API_URL = import.meta.env.VITE_USER_APP_API_URL || 'https://uat.eventifyplus.com/api/';
@@ -54,167 +51,36 @@ export interface Booking {
status?: string;
}
/**
* Fetch all events
*/
export const fetchEvents = async (username: string, token: string): Promise<Event[]> => {
const res = await fetch(`${API_URL}events/all/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch events';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
export const fetchEvents = async (): Promise<Event[]> => {
const data = await apiPost<{ events: Event[] }>(`${API_URL}events/all/`);
return data.events || [];
};
/**
* Fetch calendar events for a specific month
*/
export const fetchCalendarEvents = async (
username: string,
token: string,
month: number,
year: number
): Promise<Event[]> => {
const res = await fetch(`${API_URL}calendar/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, month, year }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch calendar events';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
export const fetchCalendarEvents = async (month: number, year: number): Promise<Event[]> => {
const data = await apiPost<{ events: Event[] }>(`${API_URL}calendar/`, { month, year });
return data.events || [];
};
/**
* Fetch events by date
*/
export const fetchEventsByDate = async (
username: string,
token: string,
date_of_event: string
): Promise<Event[]> => {
const res = await fetch(`${API_URL}events/by-date/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, date_of_event }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch events by date';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
export const fetchEventsByDate = async (date_of_event: string): Promise<Event[]> => {
const data = await apiPost<{ events: Event[] }>(`${API_URL}events/by-date/`, { date_of_event });
return data.events || [];
};
/**
* Fetch all users (admin only)
*/
export const fetchUsers = async (username: string, token: string): Promise<User[]> => {
const res = await fetch(`${API_URL}users/all/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch users';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
export const fetchUsers = async (): Promise<User[]> => {
const data = await apiPost<{ users: User[] }>(`${API_URL}users/all/`);
return data.users || [];
};
/**
* Fetch event categories
*/
export const fetchCategories = async (username: string, token: string): Promise<Category[]> => {
const res = await fetch(`${API_URL}categories/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch categories';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
export const fetchCategories = async (): Promise<Category[]> => {
const data = await apiPost<{ categories: Category[] }>(`${API_URL}categories/`);
return data.categories || [];
};
/**
* Fetch user bookings
*/
export const fetchUserBookings = async (
username: string,
token: string,
userId: number
): Promise<Booking[]> => {
const res = await fetch(`${API_URL}bookings/user/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, user_id: userId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch bookings';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
export const fetchUserBookings = async (userId: number): Promise<Booking[]> => {
const data = await apiPost<{ bookings: Booking[] }>(`${API_URL}bookings/user/`, { user_id: userId });
return data.bookings || [];
};
/**
* Update event status (admin only)
*/
export const updateEventStatus = async (
username: string,
token: string,
eventId: number,
status: string
): Promise<any> => {
const res = await fetch(`${API_URL}events/update-status/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, token, event_id: eventId, status }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.message || data.errors || data.error || 'Failed to update event';
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
throw new AuthError(errorMessage, isTokenError);
}
return data;
export const updateEventStatus = async (eventId: number, status: string): Promise<any> => {
return apiPost(`${API_URL}events/update-status/`, { event_id: eventId, status });
};

View File

@@ -34,18 +34,76 @@ export const PartnerSchema = z.object({
notes: z.string().optional(),
joinedAt: z.string(),
verificationStatus: z.enum(['Pending', 'Verified', 'Rejected']).default('Verified'),
riskScore: z.number().min(0).max(100).default(0),
});
export type Partner = z.infer<typeof PartnerSchema>;
// ── KYC Document Types ──────────────────────────────────────────────
export type KYCDocType = 'PAN' | 'GST' | 'AADHAAR' | 'CANCELLED_CHEQUE' | 'BUSINESS_REG';
export type KYCDocStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
export const KYCDocumentSchema = z.object({
id: z.string(),
partnerId: z.string(),
type: z.enum(['PAN', 'GST', 'AADHAAR', 'CANCELLED_CHEQUE', 'BUSINESS_REG']),
name: z.string(),
url: z.string(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).default('PENDING'),
mandatory: z.boolean().default(true),
adminNote: z.string().optional(),
reviewedBy: z.string().optional(),
reviewedAt: z.string().optional(),
uploadedBy: z.string(),
uploadedAt: z.string(),
});
export type KYCDocument = z.infer<typeof KYCDocumentSchema>;
// ── Event Governance Types ──────────────────────────────────────────
export type EventGovernanceStatus = 'PENDING_REVIEW' | 'LIVE' | 'DRAFT' | 'COMPLETED' | 'CANCELLED' | 'REJECTED';
export const PartnerEventSchema = z.object({
id: z.string(),
partnerId: z.string(),
title: z.string(),
description: z.string().optional(),
date: z.string(),
time: z.string().optional(),
venue: z.string(),
category: z.string().optional(),
ticketPrice: z.number().default(0),
totalTickets: z.number().default(0),
ticketsSold: z.number().default(0),
revenue: z.number().default(0),
status: z.enum(['PENDING_REVIEW', 'LIVE', 'DRAFT', 'COMPLETED', 'CANCELLED', 'REJECTED']),
coverImage: z.string().optional(),
rejectionReason: z.string().optional(),
reviewedBy: z.string().optional(),
reviewedAt: z.string().optional(),
submittedAt: z.string(),
createdAt: z.string(),
});
export type PartnerEvent = z.infer<typeof PartnerEventSchema>;
// ── Risk Score Helpers ──────────────────────────────────────────────
export type RiskLevel = 'low' | 'medium' | 'high';
export function getRiskLevel(score: number): RiskLevel {
if (score <= 30) return 'low';
if (score <= 60) return 'medium';
return 'high';
}
// ── Deal Terms ──────────────────────────────────────────────────────
export const DealTermSchema = z.object({
id: z.string(),
partnerId: z.string(),
type: z.enum(['RevenueShare', 'CommissionPerTicket', 'FixedFee', 'Tiered', 'Hybrid']),
name: z.string(),
params: z.object({
percentage: z.number().optional(), // For RevenueShare
amount: z.number().optional(), // For FixedFee or Commission
percentage: z.number().optional(),
amount: z.number().optional(),
currency: z.string().default('INR'),
tiers: z.array(z.object({
threshold: z.number(),
@@ -69,7 +127,7 @@ export const LedgerEntrySchema = z.object({
description: z.string(),
amount: z.number(),
currency: z.string().default('INR'),
referenceId: z.string().optional(), // Invoice ID or Transaction ID
referenceId: z.string().optional(),
createdAt: z.string(),
status: z.enum(['Pending', 'Cleared', 'Failed']),
});

28
src/types/review.ts Normal file
View File

@@ -0,0 +1,28 @@
// Review Management Types
export type ReviewStatus = 'pending' | 'live' | 'rejected';
export type RejectReason = 'spam' | 'inappropriate' | 'fake';
export type ReviewerRank = 'Explorer' | 'Contributor' | 'Enthusiast' | 'Champion' | 'Legend';
export interface Review {
id: string;
reviewerName: string;
reviewerEmail: string;
reviewerAvatar: string;
eventName: string;
eventId: string;
eventDate: string;
rating: number; // 1-5
reviewText: string;
submissionDate: Date;
status: ReviewStatus;
reviewerRank: ReviewerRank;
reviewerTotalReviews: number;
rejectReason?: RejectReason;
}
export interface ReviewMetrics {
totalPending: number;
liveReviews: number;
rejected: number;
}

View File

@@ -12,12 +12,20 @@ export default defineConfig(({ mode }) => ({
overlay: false,
},
proxy: {
// Proxy API requests to bypass CORS during development
'/accounts/api': {
target: 'http://0.0.0.0:8000',
changeOrigin: true,
secure: false,
},
'/partner': {
target: 'http://0.0.0.0:8000',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'https://uat.eventifyplus.com',
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
},
},