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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
The **Eventify Command Center** is the central administration dashboard for the Eventify platform. It provides sophisticated tools for User Management, Event Analytics, Support CRM, and Platform Moderation. Designed with a premium **Neumorphic** aesthetic, it serves as the cockpit for platform administrators.
|
||||
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**.
|
||||
|
||||
217
prisma/schema.prisma
Normal file
217
prisma/schema.prisma
Normal file
@@ -0,0 +1,217 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ===== ENUMS =====
|
||||
|
||||
enum CampaignStatus {
|
||||
DRAFT
|
||||
IN_REVIEW
|
||||
ACTIVE
|
||||
PAUSED
|
||||
ENDED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum BillingModel {
|
||||
FIXED
|
||||
CPM
|
||||
CPC
|
||||
}
|
||||
|
||||
enum CampaignObjective {
|
||||
AWARENESS
|
||||
SALES
|
||||
}
|
||||
|
||||
enum SurfaceKey {
|
||||
HOME_FEATURED_CAROUSEL
|
||||
HOME_TOP_EVENTS
|
||||
CATEGORY_FEATURED
|
||||
CITY_TRENDING
|
||||
SEARCH_BOOSTED
|
||||
}
|
||||
|
||||
enum TrackingEventType {
|
||||
IMPRESSION
|
||||
CLICK
|
||||
}
|
||||
|
||||
// ===== MODELS =====
|
||||
|
||||
model Campaign {
|
||||
id String @id @default(uuid())
|
||||
partnerId String
|
||||
name String
|
||||
objective CampaignObjective
|
||||
status CampaignStatus @default(DRAFT)
|
||||
|
||||
startAt DateTime
|
||||
endAt DateTime
|
||||
|
||||
billingModel BillingModel
|
||||
totalBudget Decimal @db.Decimal(10, 2)
|
||||
dailyCap Decimal? @db.Decimal(10, 2)
|
||||
spent Decimal @default(0) @db.Decimal(10, 2)
|
||||
|
||||
// Targeting (stored as JSON)
|
||||
targeting Json // { cityIds: [], categoryIds: [], countryCodes: [] }
|
||||
frequencyCap Int @default(0) // 0 = unlimited
|
||||
|
||||
// Relations
|
||||
placements SponsoredPlacement[]
|
||||
events CampaignEvent[] // Many-to-many via join table or array of IDs
|
||||
|
||||
approvedBy String?
|
||||
rejectedReason String?
|
||||
createdBy String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
auditLogs CampaignAuditLog[]
|
||||
}
|
||||
|
||||
model SponsoredPlacement {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id])
|
||||
|
||||
eventId String
|
||||
surfaceKey SurfaceKey
|
||||
priority String @default("SPONSORED")
|
||||
|
||||
bid Decimal @db.Decimal(10, 2)
|
||||
status String @default("ACTIVE") // ACTIVE, PAUSED
|
||||
rank Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([campaignId, eventId, surfaceKey])
|
||||
}
|
||||
|
||||
model CampaignEvent {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id])
|
||||
eventId String // Reference to Event table (not shown here)
|
||||
|
||||
@@unique([campaignId, eventId])
|
||||
}
|
||||
|
||||
model AdTrackingEvent {
|
||||
id String @id @default(uuid())
|
||||
type TrackingEventType
|
||||
|
||||
placementId String
|
||||
campaignId String
|
||||
surfaceKey SurfaceKey
|
||||
eventId String
|
||||
|
||||
userId String?
|
||||
anonId String
|
||||
sessionId String
|
||||
|
||||
timestamp DateTime @default(now())
|
||||
device String?
|
||||
cityId String?
|
||||
|
||||
@@index([campaignId, type, timestamp])
|
||||
@@index([anonId, timestamp]) // For frequency capping queries
|
||||
}
|
||||
|
||||
model PlacementDailyStats {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
placementId String
|
||||
surfaceKey SurfaceKey
|
||||
|
||||
date DateTime @db.Date
|
||||
|
||||
impressions Int @default(0)
|
||||
clicks Int @default(0)
|
||||
spend Decimal @default(0) @db.Decimal(10, 2)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([campaignId, placementId, surfaceKey, date])
|
||||
}
|
||||
|
||||
model CampaignAuditLog {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id])
|
||||
|
||||
actorId String
|
||||
action String // CREATED, UPDATED, SUBMITTED, APPROVED, REJECTED, PAUSED, RESUMED
|
||||
details Json? // Changed fields, reason, etc.
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ===== PARTNER GOVERNANCE =====
|
||||
|
||||
enum KYCStatus {
|
||||
PENDING
|
||||
VERIFIED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum KYCDocStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum PartnerEventStatus {
|
||||
PENDING_REVIEW
|
||||
LIVE
|
||||
DRAFT
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model PartnerProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique // FK to User table
|
||||
verification KYCStatus @default(PENDING)
|
||||
riskScore Int @default(0)
|
||||
|
||||
documents PartnerDoc[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PartnerDoc {
|
||||
id String @id @default(cuid())
|
||||
partnerId String
|
||||
partner PartnerProfile @relation(fields: [partnerId], references: [id])
|
||||
|
||||
type String // "PAN", "GST", "AADHAAR", "CANCELLED_CHEQUE", "BUSINESS_REG"
|
||||
name String
|
||||
url String
|
||||
status KYCDocStatus @default(PENDING)
|
||||
mandatory Boolean @default(true)
|
||||
|
||||
adminNote String?
|
||||
reviewedBy String?
|
||||
reviewedAt DateTime?
|
||||
|
||||
uploadedBy String
|
||||
uploadedAt DateTime @default(now())
|
||||
|
||||
@@index([partnerId, status])
|
||||
}
|
||||
|
||||
49
src/App.tsx
49
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
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();
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
167
src/features/ad-control/components/EventPickerModal.tsx
Normal file
167
src/features/ad-control/components/EventPickerModal.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Search, MapPin, Calendar, AlertTriangle, CheckCircle2,
|
||||
ImageOff, Clock, Users,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PickerEvent } from '@/lib/types/ad-control';
|
||||
|
||||
interface EventPickerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
events: PickerEvent[];
|
||||
onSelectEvent: (event: PickerEvent) => void;
|
||||
alreadyPlacedEventIds: string[];
|
||||
}
|
||||
|
||||
export function EventPickerModal({ open, onOpenChange, events, onSelectEvent, alreadyPlacedEventIds }: EventPickerModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return events;
|
||||
const q = query.toLowerCase();
|
||||
return events.filter(e =>
|
||||
e.title.toLowerCase().includes(q) ||
|
||||
e.id.toLowerCase().includes(q) ||
|
||||
e.organizer.toLowerCase().includes(q) ||
|
||||
e.city.toLowerCase().includes(q) ||
|
||||
e.category.toLowerCase().includes(q)
|
||||
);
|
||||
}, [events, query]);
|
||||
|
||||
const getWarnings = (event: PickerEvent): { label: string; severity: 'warning' | 'error' }[] => {
|
||||
const warns: { label: string; severity: 'warning' | 'error' }[] = [];
|
||||
if (event.approvalStatus === 'PENDING') warns.push({ label: 'Pending Approval', severity: 'warning' });
|
||||
if (event.approvalStatus === 'REJECTED') warns.push({ label: 'Rejected', severity: 'error' });
|
||||
if (new Date(event.endDate) < now) warns.push({ label: 'Event Ended', severity: 'error' });
|
||||
if (!event.coverImage) warns.push({ label: 'No Cover Image', severity: 'warning' });
|
||||
return warns;
|
||||
};
|
||||
|
||||
const handleSelect = (event: PickerEvent) => {
|
||||
const warnings = getWarnings(event);
|
||||
const hasErrors = warnings.some(w => w.severity === 'error');
|
||||
if (hasErrors) {
|
||||
const msg = warnings.filter(w => w.severity === 'error').map(w => w.label).join(', ');
|
||||
if (!confirm(`This event has issues: ${msg}. Proceed anyway?`)) return;
|
||||
}
|
||||
onSelectEvent(event);
|
||||
onOpenChange(false);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Event</DialogTitle>
|
||||
<DialogDescription>Search for an event to place on this surface.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search by name, ID, organizer, city, or category..."
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event List */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 min-h-0">
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
No events found matching "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.map(event => {
|
||||
const warnings = getWarnings(event);
|
||||
const isPlaced = alreadyPlacedEventIds.includes(event.id);
|
||||
const fillPercent = Math.round((event.ticketsSold / event.capacity) * 100);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => !isPlaced && handleSelect(event)}
|
||||
disabled={isPlaced}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 rounded-xl border p-3 text-left transition-all',
|
||||
isPlaced
|
||||
? 'opacity-50 cursor-not-allowed bg-muted/20'
|
||||
: 'hover:shadow-md hover:border-primary/30 bg-card cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{/* Cover */}
|
||||
{event.coverImage ? (
|
||||
<img src={event.coverImage} alt="" className="h-16 w-24 rounded-lg object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="h-16 w-24 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<ImageOff className="h-5 w-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<h4 className="font-semibold text-sm truncate">{event.title}</h4>
|
||||
{event.approvalStatus === 'APPROVED' && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><MapPin className="h-3 w-3" />{event.city}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(event.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
<span>{event.organizer}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-[10px] h-4">{event.category}</Badge>
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Users className="h-3 w-3" /> {event.ticketsSold.toLocaleString()}/{event.capacity.toLocaleString()} ({fillPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
<div className="flex flex-col gap-1 flex-shrink-0 items-end">
|
||||
{isPlaced && (
|
||||
<Badge variant="secondary" className="text-[10px]">Already Placed</Badge>
|
||||
)}
|
||||
{warnings.map((w, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] gap-1',
|
||||
w.severity === 'error'
|
||||
? 'bg-red-50 text-red-600 border-red-200'
|
||||
: 'bg-amber-50 text-amber-600 border-amber-200'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{w.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
282
src/features/ad-control/components/PlacementConfigDrawer.tsx
Normal file
282
src/features/ad-control/components/PlacementConfigDrawer.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Loader2, ExternalLink, X, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { createPlacement, updatePlacement, publishPlacement } from '@/lib/actions/ad-control';
|
||||
import { MOCK_CITIES, MOCK_CATEGORIES } from '../data/mockAdData';
|
||||
import type { PickerEvent, PlacementWithEvent, PlacementPriority, PlacementConfigData } from '@/lib/types/ad-control';
|
||||
|
||||
interface PlacementConfigDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
event: PickerEvent | null;
|
||||
surfaceId: string;
|
||||
editingPlacement: PlacementWithEvent | null; // null = create mode
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function PlacementConfigDrawer({
|
||||
open, onOpenChange, event, surfaceId, editingPlacement, onComplete,
|
||||
}: PlacementConfigDrawerProps) {
|
||||
const isEdit = !!editingPlacement;
|
||||
const displayEvent = editingPlacement?.event || event;
|
||||
|
||||
const [startAt, setStartAt] = useState('');
|
||||
const [endAt, setEndAt] = useState('');
|
||||
const [selectedCities, setSelectedCities] = useState<string[]>([]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [boostLabel, setBoostLabel] = useState<string>('none');
|
||||
const [priority, setPriority] = useState<PlacementPriority>('MANUAL');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loading, setLoading] = useState<'draft' | 'publish' | null>(null);
|
||||
|
||||
// Populate from editing placement
|
||||
useEffect(() => {
|
||||
if (editingPlacement) {
|
||||
setStartAt(editingPlacement.startAt ? editingPlacement.startAt.slice(0, 16) : '');
|
||||
setEndAt(editingPlacement.endAt ? editingPlacement.endAt.slice(0, 16) : '');
|
||||
setSelectedCities(editingPlacement.targeting.cityIds);
|
||||
setSelectedCategories(editingPlacement.targeting.categoryIds);
|
||||
setBoostLabel(editingPlacement.boostLabel || 'none');
|
||||
setPriority(editingPlacement.priority);
|
||||
setNotes(editingPlacement.notes || '');
|
||||
} else {
|
||||
setStartAt('');
|
||||
setEndAt('');
|
||||
setSelectedCities([]);
|
||||
setSelectedCategories([]);
|
||||
setBoostLabel('none');
|
||||
setPriority('MANUAL');
|
||||
setNotes('');
|
||||
}
|
||||
}, [editingPlacement, open]);
|
||||
|
||||
const buildConfig = (): PlacementConfigData => ({
|
||||
startAt: startAt ? new Date(startAt).toISOString() : null,
|
||||
endAt: endAt ? new Date(endAt).toISOString() : null,
|
||||
targeting: {
|
||||
cityIds: selectedCities,
|
||||
categoryIds: selectedCategories,
|
||||
countryCodes: ['IN'],
|
||||
},
|
||||
boostLabel: boostLabel === 'none' ? null : boostLabel,
|
||||
priority,
|
||||
notes: notes.trim() || null,
|
||||
});
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
setLoading('draft');
|
||||
try {
|
||||
if (isEdit) {
|
||||
const res = await updatePlacement(editingPlacement!.id, buildConfig());
|
||||
res.success ? toast.success('Placement updated') : toast.error(res.message);
|
||||
} else {
|
||||
const res = await createPlacement(surfaceId, displayEvent!.id, buildConfig());
|
||||
res.success ? toast.success(res.message) : toast.error(res.message);
|
||||
}
|
||||
onOpenChange(false);
|
||||
onComplete();
|
||||
} catch { toast.error('Save failed'); }
|
||||
finally { setLoading(null); }
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!confirm('Publish this placement? It will become visible on the public app.')) return;
|
||||
setLoading('publish');
|
||||
try {
|
||||
if (isEdit) {
|
||||
// Update first, then publish
|
||||
await updatePlacement(editingPlacement!.id, buildConfig());
|
||||
const res = await publishPlacement(editingPlacement!.id);
|
||||
res.success ? toast.success(res.message) : toast.error(res.message);
|
||||
} else {
|
||||
const createRes = await createPlacement(surfaceId, displayEvent!.id, buildConfig());
|
||||
if (createRes.success && createRes.data) {
|
||||
const pubRes = await publishPlacement(createRes.data.id);
|
||||
pubRes.success ? toast.success(pubRes.message) : toast.error(pubRes.message);
|
||||
} else {
|
||||
toast.error(createRes.message);
|
||||
setLoading(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onOpenChange(false);
|
||||
onComplete();
|
||||
} catch { toast.error('Publish failed'); }
|
||||
finally { setLoading(null); }
|
||||
};
|
||||
|
||||
const toggleCity = (id: string) => {
|
||||
setSelectedCities(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
|
||||
};
|
||||
const toggleCategory = (id: string) => {
|
||||
setSelectedCategories(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader className="pb-4">
|
||||
<SheetTitle>{isEdit ? 'Edit Placement' : 'Configure Placement'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isEdit ? 'Update schedule, targeting, and settings.' : 'Set up schedule, targeting, and publish.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Event Preview */}
|
||||
{displayEvent && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-muted/30 border mb-6">
|
||||
{displayEvent.coverImage ? (
|
||||
<img src={displayEvent.coverImage} alt="" className="h-14 w-20 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="h-14 w-20 rounded-lg bg-muted" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-semibold text-sm truncate">{displayEvent.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">{displayEvent.city} · {displayEvent.organizer}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(displayEvent.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Schedule */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Schedule</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Start Date/Time</Label>
|
||||
<Input type="datetime-local" value={startAt} onChange={e => setStartAt(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">End Date/Time</Label>
|
||||
<Input type="datetime-local" value={endAt} onChange={e => setEndAt(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">Leave empty for no schedule constraints (always active when published).</p>
|
||||
</div>
|
||||
|
||||
{/* Targeting — Cities */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">City Targeting</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{MOCK_CITIES.map(city => (
|
||||
<Badge
|
||||
key={city.id}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'cursor-pointer text-xs py-1 px-2 transition-all',
|
||||
selectedCities.includes(city.id) && 'bg-primary text-primary-foreground border-primary'
|
||||
)}
|
||||
onClick={() => toggleCity(city.id)}
|
||||
>
|
||||
{selectedCities.includes(city.id) && '✓ '}
|
||||
{city.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">No selection = all cities (nationwide).</p>
|
||||
</div>
|
||||
|
||||
{/* Targeting — Categories */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Category Targeting</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{MOCK_CATEGORIES.map(cat => (
|
||||
<Badge
|
||||
key={cat.id}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'cursor-pointer text-xs py-1 px-2 transition-all',
|
||||
selectedCategories.includes(cat.id) && 'bg-primary text-primary-foreground border-primary'
|
||||
)}
|
||||
onClick={() => toggleCategory(cat.id)}
|
||||
>
|
||||
{selectedCategories.includes(cat.id) && '✓ '}
|
||||
{cat.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boost Label + Priority */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Boost Label</Label>
|
||||
<Select value={boostLabel} onValueChange={setBoostLabel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="Featured">Featured</SelectItem>
|
||||
<SelectItem value="Top">Top</SelectItem>
|
||||
<SelectItem value="Sponsored">Sponsored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as PlacementPriority)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSORED">Sponsored</SelectItem>
|
||||
<SelectItem value="MANUAL">Manual Curated</SelectItem>
|
||||
<SelectItem value="ALGO">Algorithmic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Internal Notes</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="e.g. Sponsor deal #1234, approved by marketing team..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview Link (mock) */}
|
||||
<Button variant="outline" className="w-full gap-2 text-sm" onClick={() => toast.info('Preview URL copied!', { description: `https://eventifyplus.com/preview?placement=${editingPlacement?.id || 'new'}&token=mock-preview-token` })}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Preview in App
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<SheetFooter className="pt-6 gap-2 flex-row">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={!!loading} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!!loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading === 'draft' ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</> : 'Save as Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePublish}
|
||||
disabled={!!loading}
|
||||
className="flex-1 bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
{loading === 'publish' ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</> : 'Publish'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
257
src/features/ad-control/components/PlacementList.tsx
Normal file
257
src/features/ad-control/components/PlacementList.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
GripVertical, MoreHorizontal, Eye, Pencil, Power, PowerOff,
|
||||
Trash2, Calendar, MapPin, Loader2, Save, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { publishPlacement, unpublishPlacement, deletePlacement, reorderPlacements } from '@/lib/actions/ad-control';
|
||||
import type { PlacementWithEvent, PlacementStatus, PlacementPriority } from '@/lib/types/ad-control';
|
||||
|
||||
const STATUS_CONFIG: Record<PlacementStatus, { label: string; color: string; dot: string }> = {
|
||||
ACTIVE: { label: 'Active', color: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
|
||||
SCHEDULED: { label: 'Scheduled', color: 'bg-blue-50 text-blue-700 border-blue-200', dot: 'bg-blue-500' },
|
||||
DRAFT: { label: 'Draft', color: 'bg-slate-50 text-slate-600 border-slate-200', dot: 'bg-slate-400' },
|
||||
EXPIRED: { label: 'Expired', color: 'bg-red-50 text-red-600 border-red-200', dot: 'bg-red-400' },
|
||||
DISABLED: { label: 'Disabled', color: 'bg-orange-50 text-orange-600 border-orange-200', dot: 'bg-orange-400' },
|
||||
};
|
||||
|
||||
const PRIORITY_CONFIG: Record<PlacementPriority, { label: string; color: string }> = {
|
||||
SPONSORED: { label: 'Sponsored', color: 'bg-purple-100 text-purple-700' },
|
||||
MANUAL: { label: 'Curated', color: 'bg-sky-100 text-sky-700' },
|
||||
ALGO: { label: 'Algorithm', color: 'bg-zinc-100 text-zinc-600' },
|
||||
};
|
||||
|
||||
interface PlacementListProps {
|
||||
placements: PlacementWithEvent[];
|
||||
surfaceId: string;
|
||||
onEdit: (placement: PlacementWithEvent) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function PlacementList({ placements, surfaceId, onEdit, onRefresh }: PlacementListProps) {
|
||||
const [items, setItems] = useState(placements);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
const [hasReordered, setHasReordered] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
|
||||
// Sync when placements prop changes
|
||||
if (placements !== items && !hasReordered) {
|
||||
setItems(placements);
|
||||
}
|
||||
|
||||
// --- Drag and Drop ---
|
||||
const handleDragStart = (index: number) => setDragIndex(index);
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
setOverIndex(index);
|
||||
};
|
||||
const handleDragEnd = () => {
|
||||
if (dragIndex !== null && overIndex !== null && dragIndex !== overIndex) {
|
||||
const reordered = [...items];
|
||||
const [moved] = reordered.splice(dragIndex, 1);
|
||||
reordered.splice(overIndex, 0, moved);
|
||||
setItems(reordered);
|
||||
setHasReordered(true);
|
||||
}
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await reorderPlacements(surfaceId, items.map(p => p.id));
|
||||
if (res.success) {
|
||||
toast.success(res.message);
|
||||
setHasReordered(false);
|
||||
onRefresh();
|
||||
} else { toast.error(res.message); }
|
||||
} catch { toast.error('Failed to save order'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handlePublish = async (id: string) => {
|
||||
if (!confirm('Publish this placement? It will be visible to users.')) return;
|
||||
setLoadingAction(id);
|
||||
try {
|
||||
const res = await publishPlacement(id);
|
||||
res.success ? toast.success(res.message) : toast.error(res.message);
|
||||
onRefresh();
|
||||
} catch { toast.error('Publish failed'); }
|
||||
finally { setLoadingAction(null); }
|
||||
};
|
||||
|
||||
const handleUnpublish = async (id: string) => {
|
||||
if (!confirm('Unpublish this placement?')) return;
|
||||
setLoadingAction(id);
|
||||
try {
|
||||
const res = await unpublishPlacement(id);
|
||||
res.success ? toast.success(res.message) : toast.error(res.message);
|
||||
onRefresh();
|
||||
} catch { toast.error('Unpublish failed'); }
|
||||
finally { setLoadingAction(null); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this placement permanently?')) return;
|
||||
setLoadingAction(id);
|
||||
try {
|
||||
const res = await deletePlacement(id);
|
||||
res.success ? toast.success(res.message) : toast.error(res.message);
|
||||
onRefresh();
|
||||
} catch { toast.error('Delete failed'); }
|
||||
finally { setLoadingAction(null); }
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center border-2 border-dashed border-border/50 rounded-xl bg-muted/10">
|
||||
<Sparkles className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-foreground">No placements yet</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1 max-w-sm">
|
||||
Add events to this surface to feature them on the public app.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Save Order Bar */}
|
||||
{hasReordered && (
|
||||
<div className="flex items-center justify-between bg-primary/5 border border-primary/20 rounded-lg px-4 py-2.5 animate-in slide-in-from-top-2">
|
||||
<p className="text-sm font-medium text-primary">Order has been changed</p>
|
||||
<Button size="sm" onClick={handleSaveOrder} disabled={saving} className="gap-2">
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
Save Order
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placement Cards */}
|
||||
{items.map((placement, index) => {
|
||||
const statusCfg = STATUS_CONFIG[placement.status];
|
||||
const priorityCfg = PRIORITY_CONFIG[placement.priority];
|
||||
const event = placement.event;
|
||||
const isLoading = loadingAction === placement.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={placement.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-xl border bg-card p-3 transition-all duration-200',
|
||||
'hover:shadow-md hover:border-primary/20',
|
||||
dragIndex === index && 'opacity-50 scale-[0.98]',
|
||||
overIndex === index && dragIndex !== index && 'border-primary border-dashed',
|
||||
isLoading && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Rank */}
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground flex-shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Event Cover */}
|
||||
{event?.coverImage ? (
|
||||
<img src={event.coverImage} alt="" className="h-14 w-20 rounded-lg object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="h-14 w-20 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-sm truncate">{event?.title || placement.eventId || 'Unknown Event'}</h4>
|
||||
{placement.boostLabel && (
|
||||
<Badge variant="outline" className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
|
||||
{placement.boostLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
{event && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" /> {event.city}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" /> {new Date(event.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
<span>{event.organizer}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Badge */}
|
||||
<Badge variant="secondary" className={cn('text-[10px] h-5', priorityCfg.color)}>
|
||||
{priorityCfg.label}
|
||||
</Badge>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant="outline" className={cn('text-[10px] h-5 gap-1', statusCfg.color)}>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dot)} />
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
|
||||
{/* Schedule */}
|
||||
{(placement.startAt || placement.endAt) && (
|
||||
<div className="text-[10px] text-muted-foreground text-right leading-tight hidden lg:block">
|
||||
{placement.startAt && <div>{new Date(placement.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>}
|
||||
{placement.endAt && <div>→ {new Date(placement.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => onEdit(placement)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
{(placement.status === 'DRAFT' || placement.status === 'DISABLED') && (
|
||||
<DropdownMenuItem onClick={() => handlePublish(placement.id)}>
|
||||
<Power className="mr-2 h-4 w-4 text-emerald-600" /> Publish
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(placement.status === 'ACTIVE' || placement.status === 'SCHEDULED') && (
|
||||
<DropdownMenuItem onClick={() => handleUnpublish(placement.id)}>
|
||||
<PowerOff className="mr-2 h-4 w-4 text-orange-600" /> Unpublish
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleDelete(placement.id)} className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/features/ad-control/components/SurfaceTabs.tsx
Normal file
74
src/features/ad-control/components/SurfaceTabs.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Sparkles, TrendingUp, Layers, MapPin, Search,
|
||||
} from 'lucide-react';
|
||||
import type { Surface, PlacementItem } from '@/lib/types/ad-control';
|
||||
|
||||
const SURFACE_ICONS: Record<string, React.ElementType> = {
|
||||
Sparkles, TrendingUp, Layers, MapPin, Search,
|
||||
};
|
||||
|
||||
interface SurfaceTabsProps {
|
||||
surfaces: Surface[];
|
||||
activeSurfaceId: string;
|
||||
onSelect: (surfaceId: string) => void;
|
||||
placementCounts: Record<string, number>; // surfaceId → active/scheduled count
|
||||
}
|
||||
|
||||
export function SurfaceTabs({ surfaces, activeSurfaceId, onSelect, placementCounts }: SurfaceTabsProps) {
|
||||
return (
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<div className="sticky top-24 space-y-1.5">
|
||||
<p className="px-3 text-[10px] uppercase tracking-widest text-muted-foreground font-semibold mb-3">
|
||||
Placement Surfaces
|
||||
</p>
|
||||
{surfaces.map((surface) => {
|
||||
const Icon = SURFACE_ICONS[surface.icon] || Sparkles;
|
||||
const count = placementCounts[surface.id] || 0;
|
||||
const isActive = surface.id === activeSurfaceId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={surface.id}
|
||||
onClick={() => onSelect(surface.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-3 rounded-xl text-left transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg'
|
||||
: 'hover:bg-muted/50 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-9 w-9 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
isActive ? 'bg-white/20' : 'bg-muted'
|
||||
)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{surface.name.replace('Home ', '').replace(' Events', '')}</p>
|
||||
<p className={cn(
|
||||
'text-[11px]',
|
||||
isActive ? 'text-primary-foreground/70' : 'text-muted-foreground'
|
||||
)}>
|
||||
{surface.layoutType} · {surface.sortBehavior}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isActive ? 'secondary' : 'outline'}
|
||||
className={cn(
|
||||
'text-[10px] h-5 min-w-[36px] justify-center font-mono',
|
||||
isActive && 'bg-white/20 text-primary-foreground border-transparent',
|
||||
count >= surface.maxSlots && !isActive && 'bg-red-50 text-red-600 border-red-200'
|
||||
)}
|
||||
>
|
||||
{count}/{surface.maxSlots}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ArrowLeft, Download, Loader2, IndianRupee, Eye,
|
||||
MousePointerClick, Percent, TrendingUp, Calendar,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getCampaignReport, exportCampaignCSV } from '@/lib/actions/ads';
|
||||
import type { CampaignReport } from '@/lib/types/ads';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
IN_REVIEW: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
PAUSED: 'bg-orange-50 text-orange-600 border-orange-200',
|
||||
ENDED: 'bg-zinc-50 text-zinc-500 border-zinc-200',
|
||||
REJECTED: 'bg-red-50 text-red-600 border-red-200',
|
||||
DRAFT: 'bg-slate-50 text-slate-600 border-slate-200',
|
||||
};
|
||||
|
||||
export function CampaignReportPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [report, setReport] = useState<CampaignReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await getCampaignReport(id);
|
||||
if (res.success && res.data) setReport(res.data);
|
||||
else toast.error(res.message);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!id) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await exportCampaignCSV(id);
|
||||
if (res.success && res.csv) {
|
||||
const blob = new Blob([res.csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `campaign-${id}-report.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('CSV downloaded');
|
||||
} else { toast.error(res.message); }
|
||||
} catch { toast.error('Export failed'); }
|
||||
finally { setExporting(false); }
|
||||
};
|
||||
|
||||
// Aggregate daily stats by date for the chart
|
||||
const chartData = useMemo(() => {
|
||||
if (!report) return [];
|
||||
const byDate: Record<string, { date: string; impressions: number; clicks: number }> = {};
|
||||
for (const stat of report.dailyStats) {
|
||||
if (!byDate[stat.date]) byDate[stat.date] = { date: stat.date, impressions: 0, clicks: 0 };
|
||||
byDate[stat.date].impressions += stat.impressions;
|
||||
byDate[stat.date].clicks += stat.clicks;
|
||||
}
|
||||
return Object.values(byDate).sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [report]);
|
||||
|
||||
const maxImpressions = useMemo(() => Math.max(1, ...chartData.map(d => d.impressions)), [chartData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Campaign not found</p>
|
||||
<Button variant="ghost" onClick={() => navigate('/ad-control/sponsored')} className="mt-4 gap-2">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { campaign, totals, bySurface } = report;
|
||||
const spendPct = campaign.totalBudget > 0 ? (totals.spend / campaign.totalBudget) * 100 : 0;
|
||||
|
||||
const summaryCards = [
|
||||
{ label: 'Impressions', value: totals.impressions.toLocaleString(), icon: Eye, color: 'text-violet-600', bg: 'bg-violet-50' },
|
||||
{ label: 'Clicks', value: totals.clicks.toLocaleString(), icon: MousePointerClick, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
{ label: 'CTR', value: `${(totals.ctr * 100).toFixed(2)}%`, icon: Percent, color: 'text-pink-600', bg: 'bg-pink-50' },
|
||||
{ label: 'Spend', value: `₹${totals.spend.toLocaleString()}`, icon: IndianRupee, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ label: 'Remaining', value: `₹${totals.remaining.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/ad-control/sponsored')} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold">{campaign.name}</h2>
|
||||
<Badge variant="outline" className={cn('text-xs', STATUS_COLORS[campaign.status])}>
|
||||
{campaign.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{campaign.partnerName} · {campaign.billingModel} · {new Date(campaign.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })} → {new Date(campaign.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleExport} disabled={exporting} className="gap-2">
|
||||
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Budget Progress */}
|
||||
<div className="bg-card border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Budget Utilization</span>
|
||||
<span className="text-sm font-mono">₹{totals.spend.toLocaleString()} / ₹{campaign.totalBudget.toLocaleString()} ({spendPct.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', spendPct > 90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')}
|
||||
style={{ width: `${Math.min(100, spendPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map(s => (
|
||||
<div key={s.label} className="rounded-xl border bg-card p-4 flex items-center gap-3">
|
||||
<div className={cn('h-10 w-10 rounded-lg flex items-center justify-center', s.bg)}>
|
||||
<s.icon className={cn('h-5 w-5', s.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold">{s.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart — CSS Bar Chart */}
|
||||
<div className="bg-card border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Daily Performance</h3>
|
||||
<div className="flex items-end gap-1.5 h-48">
|
||||
{chartData.map((d) => {
|
||||
const impHeight = (d.impressions / maxImpressions) * 100;
|
||||
const clkHeight = maxImpressions > 0 ? (d.clicks / maxImpressions) * 100 : 0;
|
||||
return (
|
||||
<div key={d.date} className="flex-1 flex flex-col items-center gap-0.5 group relative">
|
||||
{/* Tooltip */}
|
||||
<div className="absolute -top-16 left-1/2 -translate-x-1/2 bg-foreground text-background text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
|
||||
{d.date}: {d.impressions} imp, {d.clicks} clk
|
||||
</div>
|
||||
<div className="w-full flex items-end gap-0.5 flex-1">
|
||||
<div
|
||||
className="flex-1 bg-violet-400/70 rounded-t transition-all hover:bg-violet-500"
|
||||
style={{ height: `${impHeight}%`, minHeight: d.impressions > 0 ? '2px' : '0' }}
|
||||
/>
|
||||
<div
|
||||
className="flex-1 bg-amber-400/70 rounded-t transition-all hover:bg-amber-500"
|
||||
style={{ height: `${clkHeight}%`, minHeight: d.clicks > 0 ? '2px' : '0' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground mt-1">{new Date(d.date + 'T00:00:00').toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 justify-center">
|
||||
<div className="flex items-center gap-1.5"><div className="h-2.5 w-2.5 rounded-sm bg-violet-400" /><span className="text-xs text-muted-foreground">Impressions</span></div>
|
||||
<div className="flex items-center gap-1.5"><div className="h-2.5 w-2.5 rounded-sm bg-amber-400" /><span className="text-xs text-muted-foreground">Clicks</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Surface Breakdown Table */}
|
||||
<div className="bg-card border rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30">
|
||||
<h3 className="text-sm font-semibold">Performance by Surface</h3>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground">Surface</th>
|
||||
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Impressions</th>
|
||||
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Clicks</th>
|
||||
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">CTR</th>
|
||||
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Spend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bySurface.map(s => (
|
||||
<tr key={s.surfaceKey} className="border-b last:border-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3 text-sm font-medium">{s.surfaceName}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">{s.impressions.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">{s.clicks.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">{(s.ctr * 100).toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">₹{s.spend.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-muted/30 font-semibold">
|
||||
<td className="px-4 py-3 text-sm">Total</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">{totals.impressions.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">{totals.clicks.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">{(totals.ctr * 100).toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-mono">₹{totals.spend.toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
src/features/ad-control/components/sponsored/CampaignWizard.tsx
Normal file
426
src/features/ad-control/components/sponsored/CampaignWizard.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, Loader2, Sparkles,
|
||||
IndianRupee, Target, Calendar, Layers, Send,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { createCampaign, submitCampaign } from '@/lib/actions/ads';
|
||||
import { EventPickerModal } from '@/features/ad-control/components/EventPickerModal';
|
||||
import { MOCK_PICKER_EVENTS, MOCK_CITIES, MOCK_CATEGORIES } from '@/features/ad-control/data/mockAdData';
|
||||
import type { CampaignFormData, BillingModel, CampaignObjective } from '@/lib/types/ads';
|
||||
import type { SurfaceKey, PickerEvent } from '@/lib/types/ad-control';
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: 'Basics', icon: Sparkles },
|
||||
{ id: 2, title: 'Placement', icon: Layers },
|
||||
{ id: 3, title: 'Targeting', icon: Target },
|
||||
{ id: 4, title: 'Budget', icon: IndianRupee },
|
||||
{ id: 5, title: 'Review', icon: Send },
|
||||
];
|
||||
|
||||
const SURFACE_OPTIONS: { key: SurfaceKey; label: string }[] = [
|
||||
{ key: 'HOME_FEATURED_CAROUSEL', label: 'Home Featured Carousel' },
|
||||
{ key: 'HOME_TOP_EVENTS', label: 'Home Top Events' },
|
||||
{ key: 'CATEGORY_FEATURED', label: 'Category Featured' },
|
||||
{ key: 'CITY_TRENDING', label: 'City Trending' },
|
||||
{ key: 'SEARCH_BOOSTED', label: 'Search Boosted' },
|
||||
];
|
||||
|
||||
export function CampaignWizard() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Step 1: Basics
|
||||
const [partnerName, setPartnerName] = useState('');
|
||||
const [campaignName, setCampaignName] = useState('');
|
||||
const [objective, setObjective] = useState<CampaignObjective>('AWARENESS');
|
||||
const [startAt, setStartAt] = useState('');
|
||||
const [endAt, setEndAt] = useState('');
|
||||
|
||||
// Step 2: Placement
|
||||
const [surfaceKeys, setSurfaceKeys] = useState<SurfaceKey[]>([]);
|
||||
const [selectedEvents, setSelectedEvents] = useState<PickerEvent[]>([]);
|
||||
|
||||
// Step 3: Targeting
|
||||
const [selectedCities, setSelectedCities] = useState<string[]>([]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
// Step 4: Budget
|
||||
const [billingModel, setBillingModel] = useState<BillingModel>('CPM');
|
||||
const [totalBudget, setTotalBudget] = useState(10000);
|
||||
const [dailyCap, setDailyCap] = useState<number | null>(null);
|
||||
const [frequencyCap, setFrequencyCap] = useState(5);
|
||||
|
||||
const toggleSurface = (key: SurfaceKey) => {
|
||||
setSurfaceKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
|
||||
};
|
||||
const toggleCity = (id: string) => setSelectedCities(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
|
||||
const toggleCategory = (id: string) => setSelectedCategories(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
|
||||
|
||||
const handleEventSelected = (event: PickerEvent) => {
|
||||
if (!selectedEvents.find(e => e.id === event.id)) {
|
||||
setSelectedEvents(prev => [...prev, event]);
|
||||
}
|
||||
};
|
||||
const removeEvent = (id: string) => setSelectedEvents(prev => prev.filter(e => e.id !== id));
|
||||
|
||||
// --- Validation ---
|
||||
const stepValid = (s: number): boolean => {
|
||||
switch (s) {
|
||||
case 1: return !!partnerName.trim() && !!campaignName.trim() && !!startAt && !!endAt;
|
||||
case 2: return surfaceKeys.length > 0 && selectedEvents.length > 0;
|
||||
case 3: return true; // targeting is optional
|
||||
case 4: return totalBudget > 0;
|
||||
case 5: return true;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: CampaignFormData = {
|
||||
partnerName,
|
||||
name: campaignName,
|
||||
objective,
|
||||
startAt: new Date(startAt).toISOString(),
|
||||
endAt: new Date(endAt).toISOString(),
|
||||
surfaceKeys,
|
||||
eventIds: selectedEvents.map(e => e.id),
|
||||
targeting: { cityIds: selectedCities, categoryIds: selectedCategories, countryCodes: ['IN'] },
|
||||
billingModel,
|
||||
totalBudget,
|
||||
dailyCap,
|
||||
frequencyCap,
|
||||
};
|
||||
|
||||
const res = await createCampaign(data);
|
||||
if (!res.success || !res.data) { toast.error(res.message); setLoading(false); return; }
|
||||
|
||||
// Auto-submit for review
|
||||
const submitRes = await submitCampaign(res.data.id);
|
||||
submitRes.success
|
||||
? toast.success('Campaign submitted for review!')
|
||||
: toast.error(submitRes.message);
|
||||
|
||||
navigate('/ad-control/sponsored');
|
||||
} catch { toast.error('Failed to create campaign'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => s.id <= step && setStep(s.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all',
|
||||
step === s.id ? 'bg-primary text-primary-foreground' :
|
||||
step > s.id ? 'bg-emerald-50 text-emerald-700' : 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{step > s.id ? <Check className="h-4 w-4" /> : <s.icon className="h-4 w-4" />}
|
||||
{s.title}
|
||||
</button>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={cn('w-8 h-0.5 mx-1', step > s.id ? 'bg-emerald-400' : 'bg-muted')} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-card border rounded-xl p-6 min-h-[400px]">
|
||||
{/* STEP 1: Basics */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-bold">Campaign Basics</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Partner / Organizer Name</Label>
|
||||
<Input value={partnerName} onChange={e => setPartnerName(e.target.value)} placeholder="e.g. SoundWave Productions" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Objective</Label>
|
||||
<Select value={objective} onValueChange={v => setObjective(v as CampaignObjective)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AWARENESS">Awareness — Maximize reach</SelectItem>
|
||||
<SelectItem value="SALES">Sales — Drive ticket purchases</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Campaign Name</Label>
|
||||
<Input value={campaignName} onChange={e => setCampaignName(e.target.value)} placeholder="e.g. Mumbai Music Festival – Premium Push" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Start Date</Label>
|
||||
<Input type="datetime-local" value={startAt} onChange={e => setStartAt(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">End Date</Label>
|
||||
<Input type="datetime-local" value={endAt} onChange={e => setEndAt(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: Placement */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-bold">Placement Surfaces & Events</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Surfaces</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SURFACE_OPTIONS.map(s => (
|
||||
<label
|
||||
key={s.key}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-all',
|
||||
surfaceKeys.includes(s.key) ? 'border-primary bg-primary/5' : 'hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={surfaceKeys.includes(s.key)}
|
||||
onCheckedChange={() => toggleSurface(s.key)}
|
||||
/>
|
||||
<span className="text-sm">{s.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Events</Label>
|
||||
<Button variant="outline" size="sm" onClick={() => setPickerOpen(true)} className="gap-1">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Add Event
|
||||
</Button>
|
||||
</div>
|
||||
{selectedEvents.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">No events selected. Click "Add Event" to begin.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{selectedEvents.map(event => (
|
||||
<div key={event.id} className="flex items-center gap-3 rounded-lg border p-2">
|
||||
{event.coverImage && <img src={event.coverImage} alt="" className="h-10 w-16 rounded object-cover" />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{event.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{event.city} · {event.organizer}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeEvent(event.id)} className="text-red-500 hover:text-red-700 text-xs">Remove</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Targeting */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-bold">Audience Targeting</h3>
|
||||
<p className="text-sm text-muted-foreground">Leave empty for nationwide, all-category targeting.</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Cities</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{MOCK_CITIES.map(city => (
|
||||
<Badge
|
||||
key={city.id}
|
||||
variant="outline"
|
||||
className={cn('cursor-pointer text-xs py-1 px-2 transition-all', selectedCities.includes(city.id) && 'bg-primary text-primary-foreground border-primary')}
|
||||
onClick={() => toggleCity(city.id)}
|
||||
>
|
||||
{selectedCities.includes(city.id) && '✓ '}{city.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Categories</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{MOCK_CATEGORIES.map(cat => (
|
||||
<Badge
|
||||
key={cat.id}
|
||||
variant="outline"
|
||||
className={cn('cursor-pointer text-xs py-1 px-2 transition-all', selectedCategories.includes(cat.id) && 'bg-primary text-primary-foreground border-primary')}
|
||||
onClick={() => toggleCategory(cat.id)}
|
||||
>
|
||||
{selectedCategories.includes(cat.id) && '✓ '}{cat.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 4: Budget & Pricing */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-bold">Budget & Pricing</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Billing Model</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['FIXED', 'CPM', 'CPC'] as BillingModel[]).map(model => (
|
||||
<button
|
||||
key={model}
|
||||
onClick={() => setBillingModel(model)}
|
||||
className={cn(
|
||||
'rounded-xl border p-4 text-left transition-all',
|
||||
billingModel === model ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<p className="font-semibold text-sm">{model === 'FIXED' ? 'Fixed Fee' : model}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{model === 'FIXED' ? 'Flat amount for date range' :
|
||||
model === 'CPM' ? 'Cost per 1,000 impressions' :
|
||||
'Cost per click'}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Total Budget (₹)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={totalBudget}
|
||||
onChange={e => setTotalBudget(Number(e.target.value))}
|
||||
min={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Daily Cap (₹, optional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={dailyCap ?? ''}
|
||||
onChange={e => setDailyCap(e.target.value ? Number(e.target.value) : null)}
|
||||
placeholder="No daily limit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Frequency Cap (impressions / user / day)</Label>
|
||||
<span className="text-sm font-mono font-bold">{frequencyCap === 0 ? 'Unlimited' : frequencyCap}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[frequencyCap]}
|
||||
onValueChange={([v]) => setFrequencyCap(v)}
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Set to 0 for unlimited. Recommended: 3-5 per day.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 5: Review */}
|
||||
{step === 5 && (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-bold">Review & Submit</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-3 p-4 bg-muted/20 rounded-xl">
|
||||
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Campaign</h4>
|
||||
<div><span className="text-muted-foreground">Partner:</span> <strong>{partnerName}</strong></div>
|
||||
<div><span className="text-muted-foreground">Name:</span> <strong>{campaignName}</strong></div>
|
||||
<div><span className="text-muted-foreground">Objective:</span> <strong>{objective}</strong></div>
|
||||
<div><span className="text-muted-foreground">Period:</span> <strong>{startAt ? new Date(startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} → {endAt ? new Date(endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}</strong></div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4 bg-muted/20 rounded-xl">
|
||||
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Budget</h4>
|
||||
<div><span className="text-muted-foreground">Model:</span> <strong>{billingModel === 'FIXED' ? 'Fixed Fee' : billingModel}</strong></div>
|
||||
<div><span className="text-muted-foreground">Total:</span> <strong>₹{totalBudget.toLocaleString()}</strong></div>
|
||||
<div><span className="text-muted-foreground">Daily Cap:</span> <strong>{dailyCap ? `₹${dailyCap.toLocaleString()}` : 'None'}</strong></div>
|
||||
<div><span className="text-muted-foreground">Frequency:</span> <strong>{frequencyCap || 'Unlimited'}/user/day</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/20 rounded-xl space-y-2">
|
||||
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Placement</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{surfaceKeys.map(sk => (
|
||||
<Badge key={sk} variant="secondary" className="text-xs">{sk.replace(/_/g, ' ')}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{selectedEvents.map(e => (
|
||||
<Badge key={e.id} variant="outline" className="text-xs">{e.title}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/20 rounded-xl space-y-2">
|
||||
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Targeting</h4>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Cities:</span>{' '}
|
||||
{selectedCities.length > 0 ? selectedCities.map(c => MOCK_CITIES.find(m => m.id === c)?.name).join(', ') : 'All (Nationwide)'}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Categories:</span>{' '}
|
||||
{selectedCategories.length > 0 ? selectedCategories.map(c => MOCK_CATEGORIES.find(m => m.id === c)?.name).join(', ') : 'All'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => step === 1 ? navigate('/ad-control/sponsored') : setStep(step - 1)}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{step === 1 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
|
||||
{step < 5 ? (
|
||||
<Button onClick={() => setStep(step + 1)} disabled={!stepValid(step)} className="gap-2">
|
||||
Next <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={loading} className="gap-2 bg-emerald-600 hover:bg-emerald-700">
|
||||
{loading ? <><Loader2 className="h-4 w-4 animate-spin" /> Submitting...</> : <><Send className="h-4 w-4" /> Submit for Approval</>}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Picker */}
|
||||
<EventPickerModal
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
events={MOCK_PICKER_EVENTS}
|
||||
onSelectEvent={handleEventSelected}
|
||||
alreadyPlacedEventIds={selectedEvents.map(e => e.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Plus, Search, MoreHorizontal, BarChart3, Pause, Play,
|
||||
CheckCircle2, XCircle, Loader2, IndianRupee, Eye, MousePointerClick,
|
||||
Percent, TrendingUp, Megaphone,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getCampaigns, getSponsoredStats, approveCampaign, rejectCampaign, pauseCampaign, resumeCampaign } from '@/lib/actions/ads';
|
||||
import type { CampaignWithEvents, CampaignStatus } from '@/lib/types/ads';
|
||||
|
||||
const STATUS_CONFIG: Record<CampaignStatus, { label: string; color: string; dot: string }> = {
|
||||
ACTIVE: { label: 'Active', color: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
|
||||
IN_REVIEW: { label: 'In Review', color: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-500' },
|
||||
PAUSED: { label: 'Paused', color: 'bg-orange-50 text-orange-600 border-orange-200', dot: 'bg-orange-400' },
|
||||
DRAFT: { label: 'Draft', color: 'bg-slate-50 text-slate-600 border-slate-200', dot: 'bg-slate-400' },
|
||||
ENDED: { label: 'Ended', color: 'bg-zinc-50 text-zinc-500 border-zinc-200', dot: 'bg-zinc-400' },
|
||||
REJECTED: { label: 'Rejected', color: 'bg-red-50 text-red-600 border-red-200', dot: 'bg-red-400' },
|
||||
};
|
||||
|
||||
const BILLING_LABELS: Record<string, string> = { FIXED: 'Fixed Fee', CPM: 'CPM', CPC: 'CPC' };
|
||||
|
||||
type StatusFilter = 'ALL' | CampaignStatus;
|
||||
|
||||
export function SponsoredDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [campaigns, setCampaigns] = useState<CampaignWithEvents[]>([]);
|
||||
const [stats, setStats] = useState({ activeCampaigns: 0, todaySpend: 0, impressions24h: 0, clicks24h: 0, ctr24h: 0 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<string | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const [campRes, statRes] = await Promise.all([getCampaigns(statusFilter === 'ALL' ? undefined : statusFilter), getSponsoredStats()]);
|
||||
if (campRes.success) setCampaigns(campRes.data);
|
||||
if (statRes.success) setStats(statRes.data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [statusFilter]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return campaigns;
|
||||
const q = query.toLowerCase();
|
||||
return campaigns.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.partnerName.toLowerCase().includes(q) ||
|
||||
c.id.toLowerCase().includes(q)
|
||||
);
|
||||
}, [campaigns, query]);
|
||||
|
||||
const doAction = async (id: string, action: () => Promise<{ success: boolean; message: string }>, successMsg?: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
const res = await action();
|
||||
res.success ? toast.success(successMsg || res.message) : toast.error(res.message);
|
||||
await load();
|
||||
} catch { toast.error('Action failed'); }
|
||||
finally { setActionLoading(null); }
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectTarget || !rejectReason.trim()) { toast.error('Reason is required'); return; }
|
||||
await doAction(rejectTarget, () => rejectCampaign(rejectTarget, rejectReason));
|
||||
setRejectDialogOpen(false);
|
||||
setRejectTarget(null);
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Active Campaigns', value: stats.activeCampaigns, icon: Megaphone, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
{ label: 'Spend Today', value: `₹${stats.todaySpend.toLocaleString()}`, icon: IndianRupee, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ label: 'Impressions (24h)', value: stats.impressions24h.toLocaleString(), icon: Eye, color: 'text-violet-600', bg: 'bg-violet-50' },
|
||||
{ label: 'Clicks (24h)', value: stats.clicks24h.toLocaleString(), icon: MousePointerClick, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
{ label: 'CTR (24h)', value: `${(stats.ctr24h * 100).toFixed(2)}%`, icon: Percent, color: 'text-pink-600', bg: 'bg-pink-50' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{statCards.map((s) => (
|
||||
<div key={s.label} className="rounded-xl border bg-card p-4 flex items-center gap-3">
|
||||
<div className={cn('h-10 w-10 rounded-lg flex items-center justify-center', s.bg)}>
|
||||
<s.icon className={cn('h-5 w-5', s.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{s.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search campaigns..." className="pl-10" />
|
||||
</div>
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="ALL" className="text-xs px-3">All</TabsTrigger>
|
||||
<TabsTrigger value="ACTIVE" className="text-xs px-3">Active</TabsTrigger>
|
||||
<TabsTrigger value="IN_REVIEW" className="text-xs px-3">In Review</TabsTrigger>
|
||||
<TabsTrigger value="PAUSED" className="text-xs px-3">Paused</TabsTrigger>
|
||||
<TabsTrigger value="ENDED" className="text-xs px-3">Ended</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button onClick={() => navigate('/ad-control/sponsored/new')} className="gap-2 ml-auto">
|
||||
<Plus className="h-4 w-4" /> New Campaign
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Campaign Table */}
|
||||
<div className="border rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-muted/30 border-b text-left">
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Partner</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Campaign</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Model</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Budget</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Surfaces</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Dates</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={8} className="text-center py-12"><Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" /></td></tr>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-muted-foreground">No campaigns found</td></tr>
|
||||
)}
|
||||
{!loading && filtered.map(c => {
|
||||
const statusCfg = STATUS_CONFIG[c.status];
|
||||
const spendPct = c.totalBudget > 0 ? Math.min(100, (c.spent / c.totalBudget) * 100) : 0;
|
||||
const isLoading = actionLoading === c.id;
|
||||
|
||||
return (
|
||||
<tr key={c.id} className={cn('border-b hover:bg-muted/20 transition-colors', isLoading && 'opacity-50')}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium">{c.partnerName}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-semibold">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{c.events.map(e => e.title).join(', ') || 'No events'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className={cn('text-[10px] gap-1', statusCfg.color)}>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dot)} />
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground">{BILLING_LABELS[c.billingModel]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-mono">₹{c.totalBudget.toLocaleString()}</p>
|
||||
<div className="w-20 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', spendPct > 90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')}
|
||||
style={{ width: `${spendPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{spendPct.toFixed(0)}% spent</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{c.surfaceKeys.slice(0, 2).map(sk => (
|
||||
<Badge key={sk} variant="secondary" className="text-[9px] py-0 px-1.5">
|
||||
{sk.replace(/_/g, ' ').replace('HOME ', '')}
|
||||
</Badge>
|
||||
))}
|
||||
{c.surfaceKeys.length > 2 && (
|
||||
<Badge variant="secondary" className="text-[9px] py-0 px-1.5">+{c.surfaceKeys.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground leading-tight">
|
||||
<div>{new Date(c.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>
|
||||
<div>→ {new Date(c.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => navigate(`/ad-control/sponsored/${c.id}/report`)}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" /> View Report
|
||||
</DropdownMenuItem>
|
||||
{c.status === 'IN_REVIEW' && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => doAction(c.id, () => approveCampaign(c.id))}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4 text-emerald-600" /> Approve
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { setRejectTarget(c.id); setRejectDialogOpen(true); }}>
|
||||
<XCircle className="mr-2 h-4 w-4 text-red-600" /> Reject
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{c.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem onClick={() => doAction(c.id, () => pauseCampaign(c.id))}>
|
||||
<Pause className="mr-2 h-4 w-4 text-orange-600" /> Pause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{c.status === 'PAUSED' && (
|
||||
<DropdownMenuItem onClick={() => doAction(c.id, () => resumeCampaign(c.id))}>
|
||||
<Play className="mr-2 h-4 w-4 text-emerald-600" /> Resume
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Reject Dialog */}
|
||||
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject Campaign</DialogTitle>
|
||||
<DialogDescription>Provide a reason for rejection. This will be visible to the partner.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason</Label>
|
||||
<Textarea value={rejectReason} onChange={e => setRejectReason(e.target.value)} placeholder="e.g. Event not approved, budget too low..." rows={3} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setRejectDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={handleReject} disabled={!rejectReason.trim()}>Reject Campaign</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
src/features/ad-control/data/mockAdData.ts
Normal file
269
src/features/ad-control/data/mockAdData.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// Ad Control — Mock Data: Surfaces, Events, and Seed Placements
|
||||
|
||||
import type {
|
||||
Surface, PlacementItem, PickerEvent,
|
||||
} from '@/lib/types/ad-control';
|
||||
|
||||
// ===== SURFACES =====
|
||||
|
||||
export const MOCK_SURFACES: Surface[] = [
|
||||
{
|
||||
id: 'srf-001', key: 'HOME_FEATURED_CAROUSEL', name: 'Home Featured Carousel',
|
||||
description: 'Main hero carousel on the homepage — high visibility, prime real estate',
|
||||
maxSlots: 8, layoutType: 'carousel', sortBehavior: 'rank', icon: 'Sparkles',
|
||||
createdAt: '2025-06-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'srf-002', key: 'HOME_TOP_EVENTS', name: 'Home Top Events',
|
||||
description: 'Curated "Top Events" grid below the carousel on the homepage',
|
||||
maxSlots: 12, layoutType: 'grid', sortBehavior: 'rank', icon: 'TrendingUp',
|
||||
createdAt: '2025-06-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'srf-003', key: 'CATEGORY_FEATURED', name: 'Category Featured',
|
||||
description: 'Featured events pinned at the top of category pages',
|
||||
maxSlots: 6, layoutType: 'grid', sortBehavior: 'rank', icon: 'Layers',
|
||||
createdAt: '2025-07-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'srf-004', key: 'CITY_TRENDING', name: 'City Trending',
|
||||
description: 'Trending events shown on city landing pages',
|
||||
maxSlots: 10, layoutType: 'list', sortBehavior: 'popularity', icon: 'MapPin',
|
||||
createdAt: '2025-08-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'srf-005', key: 'SEARCH_BOOSTED', name: 'Search Boosted',
|
||||
description: 'Sponsored results that appear at the top of search results',
|
||||
maxSlots: 5, layoutType: 'list', sortBehavior: 'rank', icon: 'Search',
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK EVENTS (for picker) =====
|
||||
|
||||
export const MOCK_PICKER_EVENTS: PickerEvent[] = [
|
||||
{
|
||||
id: 'evt-101', title: 'Mumbai Music Festival 2026', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
|
||||
date: '2026-03-15T18:00:00Z', endDate: '2026-03-17T23:00:00Z', organizer: 'SoundWave Productions',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=soundwave', category: 'Music',
|
||||
coverImage: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 4200, capacity: 8000,
|
||||
},
|
||||
{
|
||||
id: 'evt-102', title: 'Delhi Tech Summit', city: 'New Delhi', state: 'Delhi', country: 'IN',
|
||||
date: '2026-03-20T09:00:00Z', endDate: '2026-03-21T18:00:00Z', organizer: 'TechConf India',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=techconf', category: 'Technology',
|
||||
coverImage: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 1800, capacity: 3000,
|
||||
},
|
||||
{
|
||||
id: 'evt-103', title: 'Bangalore Food Carnival', city: 'Bangalore', state: 'Karnataka', country: 'IN',
|
||||
date: '2026-04-05T11:00:00Z', endDate: '2026-04-07T22:00:00Z', organizer: 'FoodieHub',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=foodiehub', category: 'Food & Drink',
|
||||
coverImage: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 3100, capacity: 5000,
|
||||
},
|
||||
{
|
||||
id: 'evt-104', title: 'Hyderabad Comedy Night', city: 'Hyderabad', state: 'Telangana', country: 'IN',
|
||||
date: '2026-03-28T19:00:00Z', endDate: '2026-03-28T22:00:00Z', organizer: 'LaughFactory',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=laughfactory', category: 'Comedy',
|
||||
coverImage: 'https://images.unsplash.com/photo-1527224857830-43a7acc85260?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 450, capacity: 600,
|
||||
},
|
||||
{
|
||||
id: 'evt-105', title: 'Chennai Classical Dance Festival', city: 'Chennai', state: 'Tamil Nadu', country: 'IN',
|
||||
date: '2026-04-12T17:00:00Z', endDate: '2026-04-14T21:00:00Z', organizer: 'Natya Academy',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=natya', category: 'Arts & Culture',
|
||||
coverImage: 'https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 900, capacity: 1500,
|
||||
},
|
||||
{
|
||||
id: 'evt-106', title: 'Pune Marathon 2026', city: 'Pune', state: 'Maharashtra', country: 'IN',
|
||||
date: '2026-03-10T05:30:00Z', endDate: '2026-03-10T12:00:00Z', organizer: 'RunIndia',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=runindia', category: 'Sports',
|
||||
coverImage: 'https://images.unsplash.com/photo-1513593771513-7b58b6c4af38?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 6500, capacity: 10000,
|
||||
},
|
||||
{
|
||||
id: 'evt-107', title: 'Goa Sunburn Festival', city: 'Goa', state: 'Goa', country: 'IN',
|
||||
date: '2026-04-25T14:00:00Z', endDate: '2026-04-27T04:00:00Z', organizer: 'Sunburn Events',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=sunburn', category: 'Music',
|
||||
coverImage: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 12000, capacity: 20000,
|
||||
},
|
||||
{
|
||||
id: 'evt-108', title: 'Jaipur Literature Fest', city: 'Jaipur', state: 'Rajasthan', country: 'IN',
|
||||
date: '2026-02-20T10:00:00Z', endDate: '2026-02-24T18:00:00Z', organizer: 'JLF Foundation',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=jlf', category: 'Arts & Culture',
|
||||
coverImage: 'https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 2200, capacity: 5000,
|
||||
},
|
||||
{
|
||||
id: 'evt-109', title: 'Kolkata Film Festival', city: 'Kolkata', state: 'West Bengal', country: 'IN',
|
||||
date: '2026-05-10T10:00:00Z', endDate: '2026-05-17T22:00:00Z', organizer: 'KIFF',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=kiff', category: 'Film',
|
||||
coverImage: 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=400', approvalStatus: 'PENDING',
|
||||
ticketsSold: 0, capacity: 3000,
|
||||
},
|
||||
{
|
||||
id: 'evt-110', title: 'Ahmedabad Startup Week', city: 'Ahmedabad', state: 'Gujarat', country: 'IN',
|
||||
date: '2026-04-01T09:00:00Z', endDate: '2026-04-05T18:00:00Z', organizer: 'Startup Gujarat',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=startupguj', category: 'Business',
|
||||
coverImage: null, approvalStatus: 'APPROVED',
|
||||
ticketsSold: 800, capacity: 2000,
|
||||
},
|
||||
{
|
||||
id: 'evt-111', title: 'Mumbai Art Walk', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
|
||||
date: '2026-03-08T16:00:00Z', endDate: '2026-03-08T21:00:00Z', organizer: 'Art District',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=artdistrict', category: 'Arts & Culture',
|
||||
coverImage: 'https://images.unsplash.com/photo-1531243269054-5ebf6f34081e?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 320, capacity: 500,
|
||||
},
|
||||
{
|
||||
id: 'evt-112', title: 'Delhi Wine Experience', city: 'New Delhi', state: 'Delhi', country: 'IN',
|
||||
date: '2026-03-22T18:00:00Z', endDate: '2026-03-22T23:00:00Z', organizer: 'Vineyard Co.',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=vineyard', category: 'Food & Drink',
|
||||
coverImage: 'https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 180, capacity: 200,
|
||||
},
|
||||
{
|
||||
id: 'evt-113', title: 'Bangalore Indie Music Showcase', city: 'Bangalore', state: 'Karnataka', country: 'IN',
|
||||
date: '2026-03-30T19:00:00Z', endDate: '2026-03-30T23:00:00Z', organizer: 'IndieWave',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=indiewave', category: 'Music',
|
||||
coverImage: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 700, capacity: 1000,
|
||||
},
|
||||
{
|
||||
id: 'evt-114', title: 'Old Event (Ended)', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
|
||||
date: '2025-12-01T10:00:00Z', endDate: '2025-12-03T20:00:00Z', organizer: 'Past Events Co.',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=pastevents', category: 'Music',
|
||||
coverImage: 'https://images.unsplash.com/photo-1501281668745-f7f57925c3b4?w=400', approvalStatus: 'APPROVED',
|
||||
ticketsSold: 5000, capacity: 5000,
|
||||
},
|
||||
{
|
||||
id: 'evt-115', title: 'Rejected Event Example', city: 'Pune', state: 'Maharashtra', country: 'IN',
|
||||
date: '2026-05-01T18:00:00Z', endDate: '2026-05-01T22:00:00Z', organizer: 'Shady Promo',
|
||||
organizerLogo: 'https://i.pravatar.cc/40?u=shady', category: 'Nightlife',
|
||||
coverImage: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=400', approvalStatus: 'REJECTED',
|
||||
ticketsSold: 0, capacity: 500,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== SEED PLACEMENTS =====
|
||||
|
||||
export const MOCK_PLACEMENTS: PlacementItem[] = [
|
||||
// HOME_FEATURED_CAROUSEL — 4 active items
|
||||
{
|
||||
id: 'plc-001', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-101',
|
||||
status: 'ACTIVE', priority: 'SPONSORED', rank: 1,
|
||||
startAt: '2026-02-01T00:00:00Z', endAt: '2026-03-20T00:00:00Z',
|
||||
targeting: { cityIds: [], categoryIds: [], countryCodes: ['IN'] },
|
||||
boostLabel: 'Featured', notes: 'Headline sponsor placement', createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-01-28T10:00:00Z', updatedAt: '2026-02-05T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'plc-002', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-107',
|
||||
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
|
||||
startAt: '2026-02-10T00:00:00Z', endAt: '2026-04-28T00:00:00Z',
|
||||
targeting: { cityIds: [], categoryIds: [], countryCodes: ['IN'] },
|
||||
boostLabel: 'Featured', notes: 'High demand festival', createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-02-01T10:00:00Z', updatedAt: '2026-02-10T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'plc-003', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-102',
|
||||
status: 'ACTIVE', priority: 'MANUAL', rank: 3,
|
||||
startAt: null, endAt: null,
|
||||
targeting: { cityIds: ['delhi'], categoryIds: ['technology'], countryCodes: ['IN'] },
|
||||
boostLabel: null, notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-02-05T11:00:00Z', updatedAt: '2026-02-05T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'plc-004', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-105',
|
||||
status: 'SCHEDULED', priority: 'MANUAL', rank: 4,
|
||||
startAt: '2026-03-01T00:00:00Z', endAt: '2026-04-15T00:00:00Z',
|
||||
targeting: { cityIds: ['chennai'], categoryIds: [], countryCodes: ['IN'] },
|
||||
boostLabel: 'Featured', notes: 'Scheduled for March launch', createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-02-08T09:00:00Z', updatedAt: '2026-02-08T09:00:00Z',
|
||||
},
|
||||
|
||||
// HOME_TOP_EVENTS — 3 items
|
||||
{
|
||||
id: 'plc-005', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-103',
|
||||
status: 'ACTIVE', priority: 'MANUAL', rank: 1,
|
||||
startAt: null, endAt: null,
|
||||
targeting: { cityIds: ['bangalore'], categoryIds: ['food-drink'], countryCodes: ['IN'] },
|
||||
boostLabel: 'Top', notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-02-03T10:00:00Z', updatedAt: '2026-02-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'plc-006', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-106',
|
||||
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
|
||||
startAt: null, endAt: null,
|
||||
targeting: { cityIds: ['pune'], categoryIds: ['sports'], countryCodes: ['IN'] },
|
||||
boostLabel: 'Top', notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-02-04T12:00:00Z', updatedAt: '2026-02-04T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'plc-007', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-104',
|
||||
status: 'DRAFT', priority: 'MANUAL', rank: 3,
|
||||
startAt: null, endAt: null,
|
||||
targeting: { cityIds: ['hyderabad'], categoryIds: ['comedy'], countryCodes: ['IN'] },
|
||||
boostLabel: null, notes: 'Pending manager approval', createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-02-07T15:00:00Z', updatedAt: '2026-02-07T15:00:00Z',
|
||||
},
|
||||
|
||||
// CITY_TRENDING
|
||||
{
|
||||
id: 'plc-008', surfaceId: 'srf-004', itemType: 'EVENT', eventId: 'evt-111',
|
||||
status: 'ACTIVE', priority: 'ALGO', rank: 1,
|
||||
startAt: null, endAt: null,
|
||||
targeting: { cityIds: ['mumbai'], categoryIds: [], countryCodes: ['IN'] },
|
||||
boostLabel: null, notes: 'Auto-promoted by algorithm', createdBy: 'system', updatedBy: 'system',
|
||||
createdAt: '2026-02-09T00:00:00Z', updatedAt: '2026-02-09T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'plc-009', surfaceId: 'srf-004', itemType: 'EVENT', eventId: 'evt-113',
|
||||
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
|
||||
startAt: null, endAt: null,
|
||||
targeting: { cityIds: ['bangalore'], categoryIds: ['music'], countryCodes: ['IN'] },
|
||||
boostLabel: null, notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2026-02-09T08:00:00Z', updatedAt: '2026-02-09T08:00:00Z',
|
||||
},
|
||||
|
||||
// Expired placement
|
||||
{
|
||||
id: 'plc-010', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-114',
|
||||
status: 'EXPIRED', priority: 'MANUAL', rank: 99,
|
||||
startAt: '2025-11-15T00:00:00Z', endAt: '2025-12-05T00:00:00Z',
|
||||
targeting: { cityIds: ['mumbai'], categoryIds: [], countryCodes: ['IN'] },
|
||||
boostLabel: 'Featured', notes: 'Event has ended', createdBy: 'admin-1', updatedBy: 'admin-1',
|
||||
createdAt: '2025-11-10T10:00:00Z', updatedAt: '2025-12-05T00:01:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Targeting options for UI =====
|
||||
|
||||
export const MOCK_CITIES = [
|
||||
{ id: 'mumbai', name: 'Mumbai' },
|
||||
{ id: 'delhi', name: 'New Delhi' },
|
||||
{ id: 'bangalore', name: 'Bangalore' },
|
||||
{ id: 'hyderabad', name: 'Hyderabad' },
|
||||
{ id: 'chennai', name: 'Chennai' },
|
||||
{ id: 'pune', name: 'Pune' },
|
||||
{ id: 'kolkata', name: 'Kolkata' },
|
||||
{ id: 'jaipur', name: 'Jaipur' },
|
||||
{ id: 'goa', name: 'Goa' },
|
||||
{ id: 'ahmedabad', name: 'Ahmedabad' },
|
||||
];
|
||||
|
||||
export const MOCK_CATEGORIES = [
|
||||
{ id: 'music', name: 'Music' },
|
||||
{ id: 'technology', name: 'Technology' },
|
||||
{ id: 'food-drink', name: 'Food & Drink' },
|
||||
{ id: 'comedy', name: 'Comedy' },
|
||||
{ id: 'arts-culture', name: 'Arts & Culture' },
|
||||
{ id: 'sports', name: 'Sports' },
|
||||
{ id: 'business', name: 'Business' },
|
||||
{ id: 'film', name: 'Film' },
|
||||
{ id: 'nightlife', name: 'Nightlife' },
|
||||
];
|
||||
193
src/features/ad-control/data/mockAdsData.ts
Normal file
193
src/features/ad-control/data/mockAdsData.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Sponsored Ads — Mock Data: Campaigns, Tracking Events, Daily Stats
|
||||
|
||||
import type { Campaign, AdTrackingEvent, PlacementDailyStats } from '@/lib/types/ads';
|
||||
|
||||
// ===== MOCK CAMPAIGNS =====
|
||||
|
||||
export const MOCK_CAMPAIGNS: Campaign[] = [
|
||||
{
|
||||
id: 'camp-001',
|
||||
partnerId: 'partner-sw',
|
||||
partnerName: 'SoundWave Productions',
|
||||
name: 'Mumbai Music Festival – Premium Push',
|
||||
objective: 'AWARENESS',
|
||||
status: 'ACTIVE',
|
||||
startAt: '2026-02-01T00:00:00Z',
|
||||
endAt: '2026-03-15T23:59:59Z',
|
||||
billingModel: 'CPM',
|
||||
totalBudget: 50000,
|
||||
dailyCap: 2500,
|
||||
spent: 18750,
|
||||
targeting: { cityIds: ['mumbai', 'pune'], categoryIds: ['music'], countryCodes: ['IN'] },
|
||||
surfaceKeys: ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'],
|
||||
eventIds: ['evt-101'],
|
||||
frequencyCap: 5,
|
||||
approvedBy: 'admin-1',
|
||||
rejectedReason: null,
|
||||
createdBy: 'admin-1',
|
||||
createdAt: '2026-01-25T10:00:00Z',
|
||||
updatedAt: '2026-02-10T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'camp-002',
|
||||
partnerId: 'partner-sb',
|
||||
partnerName: 'Sunburn Events',
|
||||
name: 'Goa Sunburn – Early Bird Blitz',
|
||||
objective: 'SALES',
|
||||
status: 'IN_REVIEW',
|
||||
startAt: '2026-03-01T00:00:00Z',
|
||||
endAt: '2026-04-25T23:59:59Z',
|
||||
billingModel: 'CPC',
|
||||
totalBudget: 75000,
|
||||
dailyCap: 5000,
|
||||
spent: 0,
|
||||
targeting: { cityIds: [], categoryIds: ['music', 'nightlife'], countryCodes: ['IN'] },
|
||||
surfaceKeys: ['HOME_FEATURED_CAROUSEL', 'HOME_TOP_EVENTS', 'SEARCH_BOOSTED'],
|
||||
eventIds: ['evt-107'],
|
||||
frequencyCap: 3,
|
||||
approvedBy: null,
|
||||
rejectedReason: null,
|
||||
createdBy: 'admin-1',
|
||||
createdAt: '2026-02-08T14:00:00Z',
|
||||
updatedAt: '2026-02-08T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'camp-003',
|
||||
partnerId: 'partner-tc',
|
||||
partnerName: 'TechConf India',
|
||||
name: 'Delhi Tech Summit – Sponsor Package',
|
||||
objective: 'AWARENESS',
|
||||
status: 'DRAFT',
|
||||
startAt: '2026-03-10T00:00:00Z',
|
||||
endAt: '2026-03-21T23:59:59Z',
|
||||
billingModel: 'FIXED',
|
||||
totalBudget: 30000,
|
||||
dailyCap: null,
|
||||
spent: 0,
|
||||
targeting: { cityIds: ['delhi'], categoryIds: ['technology', 'business'], countryCodes: ['IN'] },
|
||||
surfaceKeys: ['HOME_TOP_EVENTS', 'CATEGORY_FEATURED'],
|
||||
eventIds: ['evt-102'],
|
||||
frequencyCap: 0,
|
||||
approvedBy: null,
|
||||
rejectedReason: null,
|
||||
createdBy: 'admin-1',
|
||||
createdAt: '2026-02-09T11:00:00Z',
|
||||
updatedAt: '2026-02-09T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'camp-004',
|
||||
partnerId: 'partner-ri',
|
||||
partnerName: 'RunIndia',
|
||||
name: 'Pune Marathon – Registration Drive',
|
||||
objective: 'SALES',
|
||||
status: 'ENDED',
|
||||
startAt: '2026-01-15T00:00:00Z',
|
||||
endAt: '2026-02-05T23:59:59Z',
|
||||
billingModel: 'CPM',
|
||||
totalBudget: 20000,
|
||||
dailyCap: 1500,
|
||||
spent: 19800,
|
||||
targeting: { cityIds: ['pune', 'mumbai'], categoryIds: ['sports'], countryCodes: ['IN'] },
|
||||
surfaceKeys: ['HOME_TOP_EVENTS', 'CITY_TRENDING'],
|
||||
eventIds: ['evt-106'],
|
||||
frequencyCap: 4,
|
||||
approvedBy: 'admin-1',
|
||||
rejectedReason: null,
|
||||
createdBy: 'admin-1',
|
||||
createdAt: '2026-01-10T09:00:00Z',
|
||||
updatedAt: '2026-02-05T23:59:59Z',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK TRACKING EVENTS (for camp-001) =====
|
||||
|
||||
function genTrackingEvents(): AdTrackingEvent[] {
|
||||
const events: AdTrackingEvent[] = [];
|
||||
const surfaces = ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'] as const;
|
||||
const devices = ['mobile-ios', 'mobile-android', 'web-desktop', 'web-mobile'];
|
||||
const cities = ['mumbai', 'pune'];
|
||||
const baseTime = new Date('2026-02-03T00:00:00Z');
|
||||
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const impressionsPerDay = 80 + Math.floor(Math.random() * 40);
|
||||
for (let i = 0; i < impressionsPerDay; i++) {
|
||||
const ts = new Date(baseTime.getTime() + day * 86400000 + Math.floor(Math.random() * 86400000));
|
||||
const surface = surfaces[Math.floor(Math.random() * surfaces.length)];
|
||||
events.push({
|
||||
id: `te-imp-${day}-${i}`,
|
||||
type: 'IMPRESSION',
|
||||
placementId: `splc-camp001-${surface}`,
|
||||
campaignId: 'camp-001',
|
||||
surfaceKey: surface,
|
||||
eventId: 'evt-101',
|
||||
userId: Math.random() > 0.4 ? `user-${100 + Math.floor(Math.random() * 50)}` : null,
|
||||
anonId: `anon-${Math.floor(Math.random() * 200)}`,
|
||||
sessionId: `sess-${day}-${Math.floor(Math.random() * 100)}`,
|
||||
timestamp: ts.toISOString(),
|
||||
device: devices[Math.floor(Math.random() * devices.length)],
|
||||
cityId: cities[Math.floor(Math.random() * cities.length)],
|
||||
});
|
||||
}
|
||||
|
||||
// Clicks (~8-15% of impressions)
|
||||
const clicksPerDay = Math.floor(impressionsPerDay * (0.08 + Math.random() * 0.07));
|
||||
for (let c = 0; c < clicksPerDay; c++) {
|
||||
const ts = new Date(baseTime.getTime() + day * 86400000 + Math.floor(Math.random() * 86400000));
|
||||
const surface = surfaces[Math.floor(Math.random() * surfaces.length)];
|
||||
events.push({
|
||||
id: `te-clk-${day}-${c}`,
|
||||
type: 'CLICK',
|
||||
placementId: `splc-camp001-${surface}`,
|
||||
campaignId: 'camp-001',
|
||||
surfaceKey: surface,
|
||||
eventId: 'evt-101',
|
||||
userId: Math.random() > 0.3 ? `user-${100 + Math.floor(Math.random() * 50)}` : null,
|
||||
anonId: `anon-${Math.floor(Math.random() * 200)}`,
|
||||
sessionId: `sess-${day}-${Math.floor(Math.random() * 100)}`,
|
||||
timestamp: ts.toISOString(),
|
||||
device: devices[Math.floor(Math.random() * devices.length)],
|
||||
cityId: cities[Math.floor(Math.random() * cities.length)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export const MOCK_TRACKING_EVENTS = genTrackingEvents();
|
||||
|
||||
// ===== MOCK DAILY STATS =====
|
||||
|
||||
export function generateMockDailyStats(): PlacementDailyStats[] {
|
||||
const stats: PlacementDailyStats[] = [];
|
||||
const baseDate = new Date('2026-02-03');
|
||||
const cpmRate = 50000 / (7 * 100); // simplified: budget / (days * avg impressions per batch)
|
||||
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const date = new Date(baseDate.getTime() + day * 86400000).toISOString().slice(0, 10);
|
||||
const surfaces = ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'] as const;
|
||||
|
||||
for (const surface of surfaces) {
|
||||
const impressions = 40 + Math.floor(Math.random() * 25);
|
||||
const clicks = Math.floor(impressions * (0.08 + Math.random() * 0.07));
|
||||
const ctr = impressions > 0 ? Number((clicks / impressions).toFixed(4)) : 0;
|
||||
const spend = Number(((impressions / 1000) * 250).toFixed(2)); // ₹250 CPM
|
||||
|
||||
stats.push({
|
||||
id: `ds-${day}-${surface}`,
|
||||
campaignId: 'camp-001',
|
||||
placementId: `splc-camp001-${surface}`,
|
||||
surfaceKey: surface,
|
||||
date,
|
||||
impressions,
|
||||
clicks,
|
||||
ctr,
|
||||
spend,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
export const MOCK_DAILY_STATS = generateMockDailyStats();
|
||||
@@ -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="neu-card p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
|
||||
<UserCheck className="h-3.5 w-3.5" /> Active
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-success">{stats.active}</p>
|
||||
</div>
|
||||
<div className="neu-card p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
|
||||
<Clock className="h-3.5 w-3.5" /> Pending KYC
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-warning">{stats.pendingKYC}</p>
|
||||
</div>
|
||||
<div className="neu-card p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> High Risk
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-destructive">{stats.highRisk}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center">
|
||||
<div className="relative w-full sm:w-96">
|
||||
{/* 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 partners..."
|
||||
className="pl-10 bg-secondary border-border/50 focus:border-accent"
|
||||
placeholder="Search by name, email, or contact..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</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 className="gap-2 shrink-0">
|
||||
<Plus className="h-4 w-4" /> Add Partner
|
||||
</Button>
|
||||
</AddPartnerSheet>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
<TabsContent value="all" className="mt-6">
|
||||
{/* Render grid... handled below */}
|
||||
</TabsContent>
|
||||
<TabsContent value="pending_kyc" className="mt-6">
|
||||
{/* Render grid... handled below */}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 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 ? (
|
||||
<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>
|
||||
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" />
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.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>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
const kycDocs = mockKYCDocuments.filter(d => d.partnerId === partner.id);
|
||||
const partnerEvents = mockPartnerEvents.filter(e => e.partnerId === partner.id);
|
||||
const dealTerms = mockDealTerms.filter(d => d.partnerId === partner.id);
|
||||
const ledger = mockLedger.filter(l => l.partnerId === partner.id);
|
||||
const riskLevel = getRiskLevel(partner.riskScore);
|
||||
|
||||
const handleReset2FA = async () => {
|
||||
const result = await resetPartner2FA(partner.id);
|
||||
if (result.success) toast.success(result.message);
|
||||
else toast.error(result.message);
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
const result = await resetPartnerPassword(partner.id);
|
||||
if (result.success) toast.success(result.message);
|
||||
else toast.error(result.message);
|
||||
};
|
||||
|
||||
const handleSuspend = async () => {
|
||||
const result = await suspendPartner(partner.id, 'Suspended by admin from profile page');
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setPartnerStatus('Suspended');
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsuspend = async () => {
|
||||
const result = await unsuspendPartner(partner.id);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setPartnerStatus('Active');
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout title={partner.name}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/partners')} className="shrink-0">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="h-12 w-12 rounded-xl bg-secondary flex items-center justify-center overflow-hidden border border-border/50 shrink-0">
|
||||
{partner.logo ? (
|
||||
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-bold truncate">{partner.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<TypeBadge type={partner.type} />
|
||||
<StatusBadge status={partnerStatus as any} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* ── Column 1: Identity & Stats ───────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
{/* Contact Card */}
|
||||
<div className="neu-card p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Contact</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-primary">{partner.primaryContact.name.substring(0, 2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{partner.primaryContact.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{partner.primaryContact.role || 'Contact'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="h-3.5 w-3.5" /> {partner.primaryContact.email}
|
||||
</div>
|
||||
{partner.primaryContact.phone && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Phone className="h-3.5 w-3.5" /> {partner.primaryContact.phone}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3.5 w-3.5" /> Joined {new Date(partner.joinedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="neu-card p-5">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Stats</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1"><Wallet className="h-3 w-3" /> Revenue</p>
|
||||
<p className="font-bold text-lg mt-1">₹{partner.metrics.totalRevenue.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1"><TrendingUp className="h-3 w-3" /> Events</p>
|
||||
<p className="font-bold text-lg mt-1">{partner.metrics.eventsCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-secondary/30 border border-border/30">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1"><DollarSign className="h-3 w-3" /> Open Bal.</p>
|
||||
<p className="font-bold text-lg mt-1">₹{partner.metrics.openBalance.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
riskLevel === 'low' ? 'bg-success/5 border-success/20' :
|
||||
riskLevel === 'medium' ? 'bg-warning/5 border-warning/20' :
|
||||
'bg-destructive/5 border-destructive/20'
|
||||
)}>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1"><AlertTriangle className="h-3 w-3" /> Risk</p>
|
||||
<p className={cn(
|
||||
'font-bold text-lg mt-1',
|
||||
riskLevel === 'low' ? 'text-success' :
|
||||
riskLevel === 'medium' ? 'text-warning' :
|
||||
'text-destructive'
|
||||
)}>{partner.riskScore}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Actions */}
|
||||
<div className="neu-card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Admin Actions</h3>
|
||||
|
||||
<ImpersonationDialog partnerId={partner.id} partnerName={partner.name}>
|
||||
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
|
||||
<LogIn className="h-4 w-4 text-warning" /> Login as Partner
|
||||
<ExternalLink className="h-3 w-3 ml-auto text-muted-foreground" />
|
||||
</Button>
|
||||
</ImpersonationDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
|
||||
<KeyRound className="h-4 w-4 text-blue-400" /> Reset Password
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset Password</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will send a password reset email to {partner.primaryContact.email}. This action is logged.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResetPassword}>Send Reset Email</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm">
|
||||
<ShieldOff className="h-4 w-4 text-orange-400" /> Reset 2FA
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset Two-Factor Authentication</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove {partner.name}'s 2FA setup. They will be required to re-enroll on their next login. This action is logged.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleReset2FA}>Reset 2FA</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{partnerStatus === 'Suspended' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 h-9 text-sm text-success border-success/30 hover:bg-success/10"
|
||||
onClick={handleUnsuspend}
|
||||
>
|
||||
<UserCheck className="h-4 w-4" /> Revoke Suspension
|
||||
</Button>
|
||||
) : (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start gap-2 h-9 text-sm text-destructive border-destructive/30 hover:bg-destructive/10">
|
||||
<Ban className="h-4 w-4" /> Suspend Partner
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Suspend Partner</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will suspend {partner.name}'s account. They will be unable to access their dashboard or manage events. This action is logged.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleSuspend} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Suspend
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deal Terms & Finance Accordion */}
|
||||
<Accordion type="multiple" className="neu-card overflow-hidden">
|
||||
<AccordionItem value="deals" className="border-b-0 px-5">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileSignature className="h-4 w-4 text-muted-foreground" /> Deal Terms
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">{dealTerms.length}</Badge>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{dealTerms.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{dealTerms.map(dt => (
|
||||
<div key={dt.id} className="p-3 rounded-lg bg-secondary/20 border border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{dt.name}</p>
|
||||
<Badge variant="outline" className={cn('text-[10px]',
|
||||
dt.status === 'Active' ? 'bg-success/10 text-success border-success/20' :
|
||||
dt.status === 'Draft' ? 'bg-muted text-muted-foreground' :
|
||||
'bg-warning/10 text-warning border-warning/20'
|
||||
)}>
|
||||
{dt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{dt.type} • {dt.params.percentage ? `${dt.params.percentage}%` : `₹${dt.params.amount}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-3">No deals configured</p>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="finance" className="border-b-0 px-5">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
<span className="flex items-center gap-2">
|
||||
<Wallet className="h-4 w-4 text-muted-foreground" /> Finance Ledger
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">{ledger.length}</Badge>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{ledger.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{ledger.map(entry => (
|
||||
<div key={entry.id} className="p-3 rounded-lg bg-secondary/20 border border-border/30 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{entry.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(entry.createdAt).toLocaleDateString()} • {entry.type}</p>
|
||||
</div>
|
||||
<p className={cn('font-semibold text-sm', entry.amount >= 0 ? 'text-success' : 'text-destructive')}>
|
||||
{entry.amount >= 0 ? '+' : ''}₹{Math.abs(entry.amount).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-3">No ledger entries</p>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* ── Column 2: KYC Vault ──────────────────────────────────── */}
|
||||
<div className="neu-card p-5">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
KYC & Compliance
|
||||
</h3>
|
||||
<KYCVaultPanel
|
||||
partnerId={partner.id}
|
||||
partnerName={partner.name}
|
||||
verificationStatus={partner.verificationStatus}
|
||||
documents={kycDocs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Column 3: Event Governance ──────────────────────────── */}
|
||||
<div className="neu-card p-5">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
Event Governance
|
||||
</h3>
|
||||
<EventApprovalQueue
|
||||
partnerId={partner.id}
|
||||
events={partnerEvents}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{partner.tags && partner.tags.length > 0 && (
|
||||
<div className="mt-6 flex items-center gap-2 flex-wrap">
|
||||
{partner.tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{partner.notes && (
|
||||
<div className="mt-4 p-4 bg-warning/5 border border-warning/20 rounded-lg">
|
||||
<p className="text-sm text-warning font-medium flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" /> Notes
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{partner.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
try {
|
||||
await createPartner(data);
|
||||
toast.success("Partner added successfully");
|
||||
|
||||
setIsSubmitting(false);
|
||||
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>
|
||||
|
||||
279
src/features/partners/components/EventApprovalQueue.tsx
Normal file
279
src/features/partners/components/EventApprovalQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/features/partners/components/ImpersonationDialog.tsx
Normal file
122
src/features/partners/components/ImpersonationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src/features/partners/components/KYCVaultPanel.tsx
Normal file
223
src/features/partners/components/KYCVaultPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/features/reviews/components/DeleteReviewDialog.tsx
Normal file
98
src/features/reviews/components/DeleteReviewDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/features/reviews/components/LiveReviewsTable.tsx
Normal file
147
src/features/reviews/components/LiveReviewsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/features/reviews/components/PendingReviewsTable.tsx
Normal file
138
src/features/reviews/components/PendingReviewsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/features/reviews/components/RejectReviewDialog.tsx
Normal file
98
src/features/reviews/components/RejectReviewDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/features/reviews/components/ReviewDrawer.tsx
Normal file
169
src/features/reviews/components/ReviewDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/features/reviews/components/ReviewEmptyState.tsx
Normal file
29
src/features/reviews/components/ReviewEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/features/reviews/components/ReviewMetricsBar.tsx
Normal file
74
src/features/reviews/components/ReviewMetricsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/features/reviews/components/StarRating.tsx
Normal file
48
src/features/reviews/components/StarRating.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
419
src/features/reviews/data/mockReviewData.ts
Normal file
419
src/features/reviews/data/mockReviewData.ts
Normal 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,
|
||||
};
|
||||
219
src/features/settings/components/GatewayConfigSheet.tsx
Normal file
219
src/features/settings/components/GatewayConfigSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/features/settings/components/SettingsLayout.tsx
Normal file
169
src/features/settings/components/SettingsLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
125
src/features/settings/components/dialogs/CreateSquadDialog.tsx
Normal file
125
src/features/settings/components/dialogs/CreateSquadDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/features/settings/components/dialogs/InviteStaffDialog.tsx
Normal file
111
src/features/settings/components/dialogs/InviteStaffDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/features/settings/components/tabs/OrganizationSettings.tsx
Normal file
158
src/features/settings/components/tabs/OrganizationSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/features/settings/components/tabs/PartnerGovernance.tsx
Normal file
173
src/features/settings/components/tabs/PartnerGovernance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src/features/settings/components/tabs/PaymentConfig.tsx
Normal file
196
src/features/settings/components/tabs/PaymentConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
src/features/settings/components/tabs/PublicAppConfig.tsx
Normal file
193
src/features/settings/components/tabs/PublicAppConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
src/features/settings/components/tabs/StaffDirectory.tsx
Normal file
203
src/features/settings/components/tabs/StaffDirectory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
src/features/settings/components/tabs/SystemHealth.tsx
Normal file
244
src/features/settings/components/tabs/SystemHealth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/features/settings/components/tabs/TeamTreeView.tsx
Normal file
190
src/features/settings/components/tabs/TeamTreeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/features/users/components/BulkActionBar.tsx
Normal file
146
src/features/users/components/BulkActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/features/users/components/dialogs/BulkSuspendDialog.tsx
Normal file
164
src/features/users/components/dialogs/BulkSuspendDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/features/users/components/dialogs/BulkTagDialog.tsx
Normal file
162
src/features/users/components/dialogs/BulkTagDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,24 +11,36 @@
|
||||
@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 */
|
||||
@@ -114,6 +128,7 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
/* Neumorphic utility classes */
|
||||
.neu-card {
|
||||
@apply bg-card rounded-2xl transition-all duration-200;
|
||||
|
||||
280
src/lib/actions/ad-control.ts
Normal file
280
src/lib/actions/ad-control.ts
Normal 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
388
src/lib/actions/ads.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
240
src/lib/actions/bulk-users.ts
Normal file
240
src/lib/actions/bulk-users.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
287
src/lib/actions/partner-governance.ts
Normal file
287
src/lib/actions/partner-governance.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
src/lib/actions/payment-settings.ts
Normal file
79
src/lib/actions/payment-settings.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
95
src/lib/actions/settings.ts
Normal file
95
src/lib/actions/settings.ts
Normal 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.'
|
||||
};
|
||||
}
|
||||
178
src/lib/actions/staff-management.ts
Normal file
178
src/lib/actions/staff-management.ts
Normal 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
115
src/lib/ads/serving.ts
Normal 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
194
src/lib/ads/tracking.ts
Normal 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
175
src/lib/api/public-ads.ts
Normal 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}` };
|
||||
}
|
||||
50
src/lib/audit/placement-audit.ts
Normal file
50
src/lib/audit/placement-audit.ts
Normal 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();
|
||||
}
|
||||
83
src/lib/auth/permissions.ts
Normal file
83
src/lib/auth/permissions.ts
Normal 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;
|
||||
}
|
||||
33
src/lib/payment-encryption.ts
Normal file
33
src/lib/payment-encryption.ts
Normal 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
110
src/lib/types/ad-control.ts
Normal 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
129
src/lib/types/ads.ts
Normal 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
173
src/lib/types/settings.ts
Normal 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
203
src/lib/types/staff.ts
Normal 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
256
src/pages/AdControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/pages/CampaignReport.tsx
Normal file
13
src/pages/CampaignReport.tsx
Normal 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
13
src/pages/NewCampaign.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 Stripe connections."
|
||||
description="Manage partner accounts, KYC approvals, and onboarding."
|
||||
>
|
||||
{/* 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" />
|
||||
{/* 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>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">156</p>
|
||||
<p className="text-sm text-muted-foreground">Total Partners</p>
|
||||
<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-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 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{/* 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
|
||||
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"
|
||||
<Input
|
||||
placeholder="Search by name, email, or contact..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</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>
|
||||
<AddPartnerSheet>
|
||||
<Button className="gap-2 shrink-0">
|
||||
<Plus className="h-4 w-4" /> Add Partner
|
||||
</Button>
|
||||
</AddPartnerSheet>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* 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-foreground">{partner.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{partner.email}</p>
|
||||
</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>
|
||||
<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
238
src/pages/Reviews.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/pages/SponsoredAds.tsx
Normal file
13
src/pages/SponsoredAds.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
45
src/services/api.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
partner_type: string;
|
||||
primary_contact_person_name: string;
|
||||
primary_contact_person_email: string;
|
||||
primary_contact_person_phone?: string;
|
||||
website_url?: string;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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
28
src/types/review.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user