feat: Complete platform enhancement with multi-tenant architecture
Major additions: - Territory manager system with application workflow - Custom pricing and page builder with Craft.js - Enhanced Stripe Connect onboarding - CodeReadr QR scanning integration - Kiosk mode for venue sales - Super admin dashboard and analytics - MCP integration for AI-powered operations Infrastructure improvements: - Centralized API client and routing system - Enhanced authentication with organization context - Comprehensive theme management system - Advanced event management with custom tabs - Performance monitoring and accessibility features Database schema updates: - Territory management tables - Custom pages and pricing structures - Kiosk PIN system - Enhanced organization profiles - CodeReadr integration tables 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
40
CLAUDE.md
40
CLAUDE.md
@@ -22,6 +22,10 @@ npm run preview # Preview production build locally
|
||||
|
||||
# Database
|
||||
node setup-schema.js # Initialize database schema (run once)
|
||||
|
||||
# Stripe MCP (Model Context Protocol)
|
||||
npm run mcp:stripe # Start Stripe MCP server for AI integration
|
||||
npm run mcp:stripe:debug # Start MCP server with debugging interface
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
@@ -82,6 +86,14 @@ node setup-schema.js # Initialize database schema (run once)
|
||||
- **Platform Fees**: Automatically split from each transaction
|
||||
- **Webhooks**: Payment confirmation and dispute handling
|
||||
- **Environment**: Uses publishable/secret key pairs
|
||||
- **MCP Server**: AI-powered Stripe operations through Model Context Protocol
|
||||
|
||||
### Stripe MCP (Model Context Protocol)
|
||||
- **Purpose**: AI-powered interactions with Stripe API and knowledge base
|
||||
- **Tools**: Customer management, payment processing, subscriptions, refunds
|
||||
- **Configuration**: See `MCP_SETUP.md` for detailed setup instructions
|
||||
- **Commands**: `npm run mcp:stripe` (production) or `npm run mcp:stripe:debug` (development)
|
||||
- **Integration**: Works with Claude Desktop and other AI agents
|
||||
|
||||
### Design System
|
||||
- **Theme**: Glassmorphism with dark gradients (see DESIGN_SYSTEM.md)
|
||||
@@ -134,9 +146,35 @@ src/
|
||||
- **Form State**: Native form handling with progressive enhancement
|
||||
- **Auth State**: Supabase auth context with organization data
|
||||
|
||||
### API System
|
||||
- **Centralized API Client**: `/src/lib/api-client.ts` - Main client with authentication
|
||||
- **API Router**: `/src/lib/api-router.ts` - Browser-friendly wrapper for common operations
|
||||
- **Authentication**: Automatic session management and organization context
|
||||
- **Error Handling**: Consistent error responses with user-friendly messages
|
||||
- **Usage**: Use `import { api } from '../lib/api-router'` in components
|
||||
|
||||
#### API System Usage Examples:
|
||||
```typescript
|
||||
// In Astro components (client-side scripts)
|
||||
const { api } = await import('../lib/api-router');
|
||||
|
||||
// Load event stats
|
||||
const stats = await api.loadEventStats(eventId);
|
||||
|
||||
// Load event details
|
||||
const event = await api.loadEventDetails(eventId);
|
||||
|
||||
// Load complete event page data
|
||||
const { event, stats, error } = await api.loadEventPage(eventId);
|
||||
|
||||
// Format currency and dates
|
||||
const formattedPrice = api.formatCurrency(priceInCents);
|
||||
const formattedDate = api.formatDate(dateString);
|
||||
```
|
||||
|
||||
### API Design
|
||||
- **RESTful**: Standard HTTP methods with proper status codes
|
||||
- **Authentication**: Supabase JWT validation on all protected routes
|
||||
- **Authentication**: Automatic Supabase JWT validation on all API calls
|
||||
- **Error Handling**: Consistent error responses with user-friendly messages
|
||||
- **Rate Limiting**: Built-in protection against abuse
|
||||
|
||||
|
||||
167
MCP_SETUP.md
Normal file
167
MCP_SETUP.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Stripe MCP (Model Context Protocol) Setup
|
||||
|
||||
This project includes Stripe MCP integration for AI-powered Stripe operations through Claude Code and other AI agents.
|
||||
|
||||
## What is Stripe MCP?
|
||||
|
||||
The Stripe Model Context Protocol (MCP) defines a set of tools that AI agents can use to:
|
||||
- Interact with the Stripe API
|
||||
- Search Stripe's knowledge base and documentation
|
||||
- Automate payment processing tasks
|
||||
- Manage customers, products, and subscriptions
|
||||
- Handle refunds and disputes
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Environment Setup
|
||||
|
||||
Ensure your `.env` file contains your Stripe secret key:
|
||||
```bash
|
||||
STRIPE_SECRET_KEY=sk_test_... # or sk_live_... for production
|
||||
```
|
||||
|
||||
### 2. Run Stripe MCP Server
|
||||
|
||||
```bash
|
||||
# Start the MCP server with all available tools
|
||||
npm run mcp:stripe
|
||||
|
||||
# Or with debugging interface
|
||||
npm run mcp:stripe:debug
|
||||
```
|
||||
|
||||
### 3. Claude Desktop Configuration
|
||||
|
||||
Copy the provided `claude_desktop_config.json` to your Claude Desktop configuration directory:
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
cp claude_desktop_config.json ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
copy claude_desktop_config.json %APPDATA%\Claude\claude_desktop_config.json
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
cp claude_desktop_config.json ~/.config/claude/claude_desktop_config.json
|
||||
```
|
||||
|
||||
Then update the configuration with your actual Stripe secret key.
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
The Stripe MCP server provides these tool categories:
|
||||
|
||||
### Customer Management
|
||||
- `customers.create` - Create new customers
|
||||
- `customers.read` - Retrieve customer information
|
||||
- `customers.update` - Update customer details
|
||||
- `customers.delete` - Delete customers
|
||||
|
||||
### Payment Processing
|
||||
- `payment_intents.create` - Create payment intents
|
||||
- `payment_intents.confirm` - Confirm payments
|
||||
- `payment_intents.cancel` - Cancel payments
|
||||
- `charges.capture` - Capture authorized charges
|
||||
|
||||
### Product & Pricing
|
||||
- `products.create` - Create products
|
||||
- `products.read` - Retrieve product information
|
||||
- `prices.create` - Create pricing tiers
|
||||
- `prices.read` - Retrieve pricing information
|
||||
|
||||
### Subscription Management
|
||||
- `subscriptions.create` - Create subscriptions
|
||||
- `subscriptions.read` - Retrieve subscription details
|
||||
- `subscriptions.update` - Update subscriptions
|
||||
- `subscriptions.cancel` - Cancel subscriptions
|
||||
|
||||
### Financial Operations
|
||||
- `refunds.create` - Process refunds
|
||||
- `refunds.read` - Retrieve refund information
|
||||
- `disputes.read` - Review disputes
|
||||
- `payouts.read` - Check payout status
|
||||
|
||||
## Custom Tool Selection
|
||||
|
||||
To use specific tools only:
|
||||
```bash
|
||||
npx @stripe/mcp --tools=customers.create,customers.read,products.create --api-key=$STRIPE_SECRET_KEY
|
||||
```
|
||||
|
||||
## Stripe Connect Account
|
||||
|
||||
For Stripe Connect operations:
|
||||
```bash
|
||||
npx @stripe/mcp --tools=all --api-key=$STRIPE_SECRET_KEY --stripe-account=CONNECTED_ACCOUNT_ID
|
||||
```
|
||||
|
||||
## Integration with BCT Platform
|
||||
|
||||
The MCP integration enhances the Black Canyon Tickets platform by enabling:
|
||||
|
||||
1. **AI-Powered Customer Support**: Automatically handle customer inquiries about payments and refunds
|
||||
2. **Intelligent Analytics**: Generate insights from payment and subscription data
|
||||
3. **Automated Dispute Resolution**: Handle disputes with AI assistance
|
||||
4. **Smart Subscription Management**: Optimize subscription plans based on usage patterns
|
||||
5. **Enhanced Event Management**: AI-assisted pricing and capacity optimization
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Never commit your actual Stripe secret keys to version control
|
||||
- Use test keys during development
|
||||
- Ensure proper environment variable configuration
|
||||
- The MCP server respects all Stripe API security measures
|
||||
- All operations are logged and auditable
|
||||
|
||||
## Debugging
|
||||
|
||||
Use the MCP Inspector for debugging:
|
||||
```bash
|
||||
npm run mcp:stripe:debug
|
||||
```
|
||||
|
||||
This opens a web interface showing:
|
||||
- Available tools and their schemas
|
||||
- Real-time request/response logs
|
||||
- Error messages and stack traces
|
||||
- Performance metrics
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Could not connect to MCP server"**
|
||||
- Verify your Stripe secret key is correct
|
||||
- Check that the environment variable is properly set
|
||||
- Ensure npx can access @stripe/mcp package
|
||||
|
||||
2. **"Tool not found"**
|
||||
- Verify the tool name matches exactly
|
||||
- Check if you're using the correct tool subset
|
||||
- Ensure the MCP server started successfully
|
||||
|
||||
3. **"API key invalid"**
|
||||
- Confirm you're using the correct environment (test vs live)
|
||||
- Verify the API key has necessary permissions
|
||||
- Check for typos in the environment variable
|
||||
|
||||
### Logs
|
||||
|
||||
MCP server logs are available in the Claude Desktop application logs. Check:
|
||||
- macOS: `~/Library/Logs/Claude/`
|
||||
- Windows: `%LOCALAPPDATA%\Claude\logs\`
|
||||
- Linux: `~/.local/share/claude/logs/`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Configure Claude Desktop with your Stripe credentials
|
||||
2. Test the MCP integration with simple customer operations
|
||||
3. Explore AI-powered payment analytics
|
||||
4. Implement automated customer support workflows
|
||||
5. Integrate with the BCT platform's existing Stripe Connect setup
|
||||
|
||||
For more information, see the [Stripe MCP Documentation](https://docs.stripe.com/building-with-llms).
|
||||
165
POLISH_PLAN.md
Normal file
165
POLISH_PLAN.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 🚀 Black Canyon Tickets - "WOW Factor" Production Polish Plan
|
||||
|
||||
## Mission: Create a website that impresses ANYONE who uses it
|
||||
|
||||
This plan focuses on transforming your solid foundation into a jaw-dropping, premium experience that makes users think "this company is clearly successful and professional."
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Immediate Visual Impact (High Priority)
|
||||
|
||||
### 1.1 Create Master Tracking Document
|
||||
- [x] Create `POLISH_PLAN.md` in root directory
|
||||
- [x] Set up checkbox tracking system for all tasks
|
||||
- [ ] Document before/after comparisons
|
||||
|
||||
### 1.2 Premium Color System Enhancement
|
||||
- [x] Add strategic white/light content areas for important sections
|
||||
- [x] Introduce emerald green for success metrics and positive actions
|
||||
- [x] Add gold/amber accents for premium features and CTAs
|
||||
- [x] Include sophisticated grays for better visual hierarchy
|
||||
- [x] Use red strategically for alerts and critical actions
|
||||
- [x] Keep purple as primary but not overwhelming
|
||||
|
||||
### 1.3 Navigation Excellence
|
||||
- [x] Create professional user dropdown with avatar
|
||||
- [x] Add smooth micro-animations to all interactive elements
|
||||
- [ ] Implement breadcrumb navigation for complex sections
|
||||
- [ ] Add notification badges for admin users
|
||||
- [x] Polish mobile menu with smooth slide transitions
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Dashboard That Screams "Premium" (High Priority)
|
||||
|
||||
### 2.1 Stats Cards with Maximum Impact
|
||||
- [x] Add count-up animations that make numbers feel alive
|
||||
- [x] Implement skeleton loading states for professional feel
|
||||
- [x] Add trend indicators with animated arrows
|
||||
- [ ] Include sparkline mini-charts for visual appeal
|
||||
- [x] Add hover effects that feel expensive (subtle shadows, transforms)
|
||||
|
||||
### 2.2 Event Cards Redesign
|
||||
- [ ] Create card layouts that rival top SaaS platforms
|
||||
- [ ] Add status badges with smooth color transitions
|
||||
- [ ] Include progress indicators for event completion
|
||||
- [ ] Add quick action buttons with premium styling
|
||||
- [ ] Implement smooth loading states between views
|
||||
|
||||
### 2.3 Calendar Interface Enhancement
|
||||
- [ ] Make calendar interactions feel native and smooth
|
||||
- [ ] Add subtle animations for date transitions
|
||||
- [ ] Include event preview popups on hover
|
||||
- [ ] Add drag-and-drop capabilities where appropriate
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Component Excellence (Medium Priority)
|
||||
|
||||
### 3.1 Form & Input Polish
|
||||
- [ ] Create floating label inputs that feel premium
|
||||
- [ ] Add real-time validation with smooth feedback
|
||||
- [ ] Include loading states for all form submissions
|
||||
- [ ] Add success animations for completed actions
|
||||
- [ ] Implement smart error handling with helpful messages
|
||||
|
||||
### 3.2 Event Management Interface
|
||||
- [ ] Polish all 12 tabs for consistency and premium feel
|
||||
- [ ] Add smooth tab transitions with content fade-ins
|
||||
- [ ] Include context-sensitive help tooltips
|
||||
- [ ] Add bulk action capabilities with smooth animations
|
||||
- [ ] Implement auto-save indicators for peace of mind
|
||||
|
||||
### 3.3 Interactive Elements
|
||||
- [ ] Create button states that feel responsive and premium
|
||||
- [ ] Add loading spinners that match the brand aesthetic
|
||||
- [ ] Include hover effects on all clickable elements
|
||||
- [ ] Add keyboard shortcuts for power users
|
||||
- [ ] Implement toast notifications with slide-in animations
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Features That Impress (Medium Priority)
|
||||
|
||||
### 4.1 Real-time Capabilities
|
||||
- [ ] Add live updates for ticket sales and attendance
|
||||
- [ ] Include real-time notifications for important events
|
||||
- [ ] Show typing indicators where appropriate
|
||||
- [ ] Add live collaboration features for team members
|
||||
|
||||
### 4.2 Advanced Analytics Display
|
||||
- [ ] Create beautiful charts and graphs for revenue
|
||||
- [ ] Add animated data transitions
|
||||
- [ ] Include export capabilities with professional formatting
|
||||
- [ ] Add trend analysis and insights
|
||||
|
||||
### 4.3 Mobile Experience Excellence
|
||||
- [ ] Ensure all interactions feel native on mobile
|
||||
- [ ] Add touch gestures for common actions
|
||||
- [ ] Include haptic feedback simulation where possible
|
||||
- [ ] Make mobile experience feel like a premium app
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Production Polish (Lower Priority)
|
||||
|
||||
### 5.1 Performance That Impresses
|
||||
- [ ] Optimize all animations for 60fps smoothness
|
||||
- [ ] Add intelligent loading states that reduce perceived wait time
|
||||
- [ ] Implement progressive image loading
|
||||
- [ ] Add smooth page transitions
|
||||
|
||||
### 5.2 Error Handling Excellence
|
||||
- [ ] Create error pages that don't break the premium feel
|
||||
- [ ] Add helpful error messages with suggested actions
|
||||
- [ ] Include contact options for users who need help
|
||||
- [ ] Add retry mechanisms with smooth animations
|
||||
|
||||
### 5.3 Accessibility Without Compromise
|
||||
- [ ] Ensure all premium features work with screen readers
|
||||
- [ ] Add keyboard navigation that feels natural
|
||||
- [ ] Include high contrast mode that still looks premium
|
||||
- [ ] Add focus indicators that match the design aesthetic
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics: "Impressive" Checklist
|
||||
|
||||
- [ ] **First Impression Test**: New users say "wow" within 5 seconds
|
||||
- [ ] **Smooth Factor**: All interactions feel buttery smooth
|
||||
- [ ] **Professional Credibility**: Users trust this is a serious business
|
||||
- [ ] **Mobile Excellence**: Mobile experience rivals native apps
|
||||
- [ ] **Speed Perception**: Loading feels instant, even when it's not
|
||||
- [ ] **Visual Hierarchy**: Users intuitively know where to look
|
||||
- [ ] **Error Recovery**: Even errors feel polished and helpful
|
||||
|
||||
---
|
||||
|
||||
## Timeline for Maximum Impact
|
||||
- **Phase 1 (Foundation)**: 8-10 hours - Creates immediate wow factor
|
||||
- **Phase 2 (Dashboard)**: 10-12 hours - Makes core experience impressive
|
||||
- **Phase 3 (Components)**: 8-10 hours - Ensures consistency throughout
|
||||
- **Phase 4 (Features)**: 6-8 hours - Adds impressive functionality
|
||||
- **Phase 5 (Polish)**: 4-6 hours - Final touches for perfection
|
||||
|
||||
**Total: 36-46 hours for a website that impresses everyone**
|
||||
|
||||
---
|
||||
|
||||
## Progress Notes
|
||||
|
||||
**Started:** [Date]
|
||||
**Phase 1 Begin:** [Date]
|
||||
**Phase 1 Complete:** [Date]
|
||||
**Phase 2 Begin:** [Date]
|
||||
**Phase 2 Complete:** [Date]
|
||||
**Phase 3 Begin:** [Date]
|
||||
**Phase 3 Complete:** [Date]
|
||||
**Phase 4 Begin:** [Date]
|
||||
**Phase 4 Complete:** [Date]
|
||||
**Phase 5 Begin:** [Date]
|
||||
**Phase 5 Complete:** [Date]
|
||||
|
||||
**Final Deployment:** [Date]
|
||||
|
||||
This plan will transform your platform into something that makes users think "this company is clearly successful and professional" - the kind of platform that commands premium pricing and builds instant credibility.
|
||||
@@ -8,6 +8,7 @@ import sentry from '@sentry/astro';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
integrations: [
|
||||
react(),
|
||||
sentry({
|
||||
|
||||
17
claude_desktop_config.json
Normal file
17
claude_desktop_config.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"stripe": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@stripe/mcp",
|
||||
"--tools=all",
|
||||
"--api-key",
|
||||
"STRIPE_SECRET_KEY"
|
||||
],
|
||||
"env": {
|
||||
"STRIPE_SECRET_KEY": "YOUR_STRIPE_SECRET_KEY_HERE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
eslint-output.json
Normal file
1
eslint-output.json
Normal file
File diff suppressed because one or more lines are too long
1912
package-lock.json
generated
1912
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -6,18 +6,27 @@
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"build": "NODE_OPTIONS='--max-old-space-size=8192' astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"typecheck": "astro check"
|
||||
"typecheck": "astro check",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"mcp:stripe": "npx @stripe/mcp --tools=all --api-key=$STRIPE_SECRET_KEY",
|
||||
"mcp:stripe:debug": "npx @modelcontextprotocol/inspector npx @stripe/mcp --tools=all --api-key=$STRIPE_SECRET_KEY"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/node": "^9.3.0",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@craftjs/core": "^0.2.12",
|
||||
"@craftjs/utils": "^0.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||
"@sentry/astro": "^9.35.0",
|
||||
"@sentry/node": "^9.35.0",
|
||||
"@stripe/connect-js": "^3.3.25",
|
||||
"@supabase/ssr": "^0.0.10",
|
||||
"@supabase/supabase-js": "^2.50.3",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
@@ -29,7 +38,9 @@
|
||||
"dotenv": "^17.1.0",
|
||||
"node-cron": "^4.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"ramda": "^0.31.3",
|
||||
"react": "^19.1.0",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-easy-crop": "^5.4.2",
|
||||
"resend": "^4.6.0",
|
||||
@@ -40,8 +51,12 @@
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
"eslint": "^9.31.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.36.0"
|
||||
}
|
||||
}
|
||||
|
||||
60
promote-to-admin.js
Normal file
60
promote-to-admin.js
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
config();
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL || process.env.PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
console.error('Missing required environment variables: SUPABASE_URL and SUPABASE_SERVICE_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
async function promoteUserToAdmin() {
|
||||
const email = process.argv[2];
|
||||
|
||||
if (!email) {
|
||||
console.error('Usage: node promote-to-admin.js <email>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Promoting ${email} to admin...`);
|
||||
|
||||
// First, find the user
|
||||
const { data: users, error: userError } = await supabase.auth.admin.listUsers();
|
||||
if (userError) throw userError;
|
||||
|
||||
const user = users.users.find(u => u.email === email);
|
||||
if (!user) {
|
||||
console.error(`User with email ${email} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Update user role to admin
|
||||
const { error: updateError } = await supabase
|
||||
.from('users')
|
||||
.upsert({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
console.log(`✅ Successfully promoted ${email} to admin!`);
|
||||
console.log(`User ID: ${user.id}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error promoting user to admin:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
promoteUserToAdmin();
|
||||
@@ -51,7 +51,23 @@ async function setupSchema() {
|
||||
'001_initial_schema.sql',
|
||||
'002_add_fee_structure.sql',
|
||||
'003_add_seating_and_ticket_types.sql',
|
||||
'004_add_admin_system.sql'
|
||||
'004_add_admin_system.sql',
|
||||
'005_add_fee_payment_model.sql',
|
||||
'006_standardize_bct_fees.sql',
|
||||
'007_add_premium_addons.sql',
|
||||
'008_add_featured_events_support.sql',
|
||||
'009_add_printed_tickets.sql',
|
||||
'010_add_scanner_lock.sql',
|
||||
'20250708_add_event_image_support.sql',
|
||||
'20250708_add_marketing_kit_support.sql',
|
||||
'20250708_add_referral_tracking.sql',
|
||||
'20250708_add_social_media_links.sql',
|
||||
'20250109_territory_manager_schema.sql',
|
||||
'20250109_onboarding_system.sql',
|
||||
'20250109_kiosk_pin_system.sql',
|
||||
'20250109_codereadr_integration.sql',
|
||||
'20250109_add_sample_territories.sql',
|
||||
'20250110_custom_sales_pages.sql'
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
|
||||
@@ -44,7 +44,7 @@ async function setupSuperAdmins() {
|
||||
console.log(`User ${email} not found. Creating user record...`);
|
||||
|
||||
// Create user record (they need to sign up first via Supabase Auth)
|
||||
const { data: newUser, error: createError } = await supabase
|
||||
const { error: createError } = await supabase
|
||||
.from('users')
|
||||
.insert({
|
||||
email: email,
|
||||
|
||||
199
src/components/AccountStatusBanner.tsx
Normal file
199
src/components/AccountStatusBanner.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface AccountStatus {
|
||||
account_status: string;
|
||||
stripe_onboarding_status: string;
|
||||
can_start_onboarding: boolean;
|
||||
details_submitted: boolean;
|
||||
charges_enabled: boolean;
|
||||
payouts_enabled: boolean;
|
||||
}
|
||||
|
||||
const AccountStatusBanner: React.FC = () => {
|
||||
const [status, setStatus] = useState<AccountStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkAccountStatus();
|
||||
}, []);
|
||||
|
||||
const checkAccountStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/stripe/account-status');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check account status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600 mr-3"></div>
|
||||
<span className="text-blue-800">Checking account status...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-600 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-red-800">Error checking account status: {error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={checkAccountStatus}
|
||||
className="text-red-600 hover:text-red-800 font-medium text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
// Account pending approval
|
||||
if (status.account_status === 'pending_approval') {
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-6 h-6 text-yellow-600 mt-1 mr-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">Application Under Review</h3>
|
||||
<p className="text-yellow-700 mb-4">
|
||||
Your organization application is being reviewed by our team. This typically takes 1-2 business days.
|
||||
</p>
|
||||
<div className="bg-yellow-100 rounded-lg p-3">
|
||||
<h4 className="font-medium text-yellow-800 mb-2">While you wait, you can:</h4>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
<li>• Explore our platform features and documentation</li>
|
||||
<li>• Plan your events and ticket types</li>
|
||||
<li>• Review our pricing and fee structure</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Account approved but Stripe onboarding needed
|
||||
if (status.account_status === 'approved' && status.stripe_onboarding_status !== 'completed') {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-6 h-6 text-blue-600 mt-1 mr-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">🎉 Account Approved!</h3>
|
||||
<p className="text-blue-700 mb-4">
|
||||
Congratulations! Your organization has been approved. Complete the secure payment setup to start accepting ticket payments.
|
||||
</p>
|
||||
<div className="bg-blue-100 rounded-lg p-3 mb-4">
|
||||
<h4 className="font-medium text-blue-800 mb-2">🔒 Secure Payment Setup</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
Our payment setup uses bank-level encryption through Stripe Connect. Your sensitive information is never stored on our servers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/onboarding/stripe"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors ml-4"
|
||||
>
|
||||
Complete Setup →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stripe onboarding in progress
|
||||
if (status.stripe_onboarding_status === 'in_progress' || status.stripe_onboarding_status === 'pending_review') {
|
||||
return (
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-6 h-6 text-indigo-600 mt-1 mr-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-indigo-800 mb-2">Payment Setup In Progress</h3>
|
||||
<p className="text-indigo-700 mb-3">
|
||||
{status.details_submitted
|
||||
? 'Your payment information is being reviewed by Stripe. This usually takes a few minutes.'
|
||||
: 'Continue your secure payment setup to start accepting payments.'
|
||||
}
|
||||
</p>
|
||||
{status.details_submitted && (
|
||||
<div className="bg-indigo-100 rounded-lg p-3">
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<span className={`w-2 h-2 rounded-full mr-2 ${status.details_submitted ? 'bg-green-500' : 'bg-gray-300'}`}></span>
|
||||
<span className="text-indigo-700">Information Submitted</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className={`w-2 h-2 rounded-full mr-2 ${status.charges_enabled ? 'bg-green-500' : 'bg-yellow-500'}`}></span>
|
||||
<span className="text-indigo-700">Payment Processing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!status.details_submitted && (
|
||||
<a
|
||||
href="/onboarding/stripe"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors ml-4"
|
||||
>
|
||||
Continue Setup →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fully active account
|
||||
if (status.account_status === 'active' && status.charges_enabled) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-green-600 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-green-800">Account Active</h3>
|
||||
<p className="text-sm text-green-700">Your account is fully set up and ready to accept payments.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AccountStatusBanner;
|
||||
@@ -105,27 +105,31 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
||||
</div>
|
||||
|
||||
<script define:vars={{ minimumAge, onVerified }}>
|
||||
// Variables passed from define:vars
|
||||
// minimumAge: number
|
||||
// onVerified: string | undefined
|
||||
|
||||
class AgeVerification {
|
||||
private modal: HTMLElement;
|
||||
private dateInput: HTMLInputElement;
|
||||
private confirmButton: HTMLButtonElement;
|
||||
private errorDiv: HTMLElement;
|
||||
private errorText: HTMLElement;
|
||||
private coppaNotice: HTMLElement;
|
||||
private isVerified: boolean = false;
|
||||
modal;
|
||||
dateInput;
|
||||
confirmButton;
|
||||
errorDiv;
|
||||
errorText;
|
||||
coppaNotice;
|
||||
isVerified = false;
|
||||
|
||||
constructor() {
|
||||
this.modal = document.getElementById('age-verification-modal')!;
|
||||
this.dateInput = document.getElementById('date-of-birth') as HTMLInputElement;
|
||||
this.confirmButton = document.getElementById('age-verification-confirm') as HTMLButtonElement;
|
||||
this.errorDiv = document.getElementById('age-verification-error')!;
|
||||
this.errorText = document.getElementById('age-verification-error-text')!;
|
||||
this.coppaNotice = document.getElementById('coppa-notice')!;
|
||||
this.modal = document.getElementById('age-verification-modal');
|
||||
this.dateInput = document.getElementById('date-of-birth');
|
||||
this.confirmButton = document.getElementById('age-verification-confirm');
|
||||
this.errorDiv = document.getElementById('age-verification-error');
|
||||
this.errorText = document.getElementById('age-verification-error-text');
|
||||
this.coppaNotice = document.getElementById('coppa-notice');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
bindEvents() {
|
||||
// Date input change
|
||||
this.dateInput.addEventListener('change', () => {
|
||||
this.validateAge();
|
||||
@@ -149,7 +153,7 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
||||
});
|
||||
}
|
||||
|
||||
private validateAge() {
|
||||
validateAge() {
|
||||
this.hideError();
|
||||
this.hideCoppaNotice();
|
||||
|
||||
@@ -187,7 +191,7 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
||||
this.confirmButton.disabled = false;
|
||||
}
|
||||
|
||||
private confirmAge() {
|
||||
confirmAge() {
|
||||
if (this.confirmButton.disabled) return;
|
||||
|
||||
// Mark as verified
|
||||
@@ -198,7 +202,7 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
||||
sessionStorage.setItem('age_verified_timestamp', Date.now().toString());
|
||||
|
||||
// Call the callback function if provided
|
||||
if (typeof window[onVerified] === 'function') {
|
||||
if (typeof onVerified === 'string' && typeof window[onVerified] === 'function') {
|
||||
window[onVerified]();
|
||||
}
|
||||
|
||||
@@ -211,24 +215,24 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
||||
}));
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
showError(message) {
|
||||
this.errorText.textContent = message;
|
||||
this.errorDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private hideError() {
|
||||
hideError() {
|
||||
this.errorDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
private showCoppaNotice() {
|
||||
showCoppaNotice() {
|
||||
this.coppaNotice.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private hideCoppaNotice() {
|
||||
hideCoppaNotice() {
|
||||
this.coppaNotice.classList.add('hidden');
|
||||
}
|
||||
|
||||
public show() {
|
||||
show() {
|
||||
// Check if already verified in this session
|
||||
const verified = sessionStorage.getItem('age_verified');
|
||||
const timestamp = sessionStorage.getItem('age_verified_timestamp');
|
||||
@@ -238,7 +242,7 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
||||
const verificationAge = Date.now() - parseInt(timestamp);
|
||||
if (verificationAge < 60 * 60 * 1000) { // 1 hour
|
||||
this.isVerified = true;
|
||||
if (typeof window[onVerified] === 'function') {
|
||||
if (typeof onVerified === 'string' && typeof window[onVerified] === 'function') {
|
||||
window[onVerified]();
|
||||
}
|
||||
return;
|
||||
@@ -254,20 +258,20 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
||||
}, 100);
|
||||
}
|
||||
|
||||
public hide() {
|
||||
hide() {
|
||||
this.modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
public isAgeVerified(): boolean {
|
||||
isAgeVerified() {
|
||||
return this.isVerified;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize and expose globally
|
||||
const ageVerification = new AgeVerification();
|
||||
(window as any).ageVerification = ageVerification;
|
||||
(window as any).showAgeVerification = () => ageVerification.show();
|
||||
window.ageVerification = ageVerification;
|
||||
window.showAgeVerification = () => ageVerification.show();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -78,8 +78,8 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
setNearbyEvents(nearby);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading location and trending:', error);
|
||||
} catch (_error) {
|
||||
console.error('Failed to load location and trending data:', _error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -142,17 +142,17 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="bg-white dark:bg-gray-900 shadow-lg rounded-lg overflow-hidden">
|
||||
{/* Calendar Header */}
|
||||
<div className="px-3 md:px-6 py-4 border-b border-gray-200">
|
||||
<div className="px-3 md:px-6 py-4 border-b border-gray-300 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 md:space-x-4">
|
||||
<h2 className="text-base md:text-lg font-semibold text-gray-900">
|
||||
<h2 className="text-base md:text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{isMobile ? `${monthNames[currentMonth].slice(0, 3)} ${currentYear}` : `${monthNames[currentMonth]} ${currentYear}`}
|
||||
</h2>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="text-xs md:text-sm text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
className="text-xs md:text-sm text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 font-semibold"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
@@ -166,7 +166,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium rounded-l-md border ${
|
||||
view === 'month'
|
||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{isMobile ? 'M' : 'Month'}
|
||||
@@ -176,7 +176,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium border-t border-r border-b ${
|
||||
view === 'week'
|
||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{isMobile ? 'W' : 'Week'}
|
||||
@@ -186,7 +186,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium rounded-r-md border-t border-r border-b ${
|
||||
view === 'list'
|
||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{isMobile ? 'L' : 'List'}
|
||||
@@ -199,7 +199,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
onClick={previousMonth}
|
||||
className="p-1 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<svg className="h-4 w-4 md:h-5 md:w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-4 w-4 md:h-5 md:w-5 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -207,7 +207,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
onClick={nextMonth}
|
||||
className="p-1 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<svg className="h-4 w-4 md:h-5 md:w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-4 w-4 md:h-5 md:w-5 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -221,8 +221,8 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
<div className="p-3 md:p-6">
|
||||
{/* Day Headers */}
|
||||
<div className="grid grid-cols-7 gap-px md:gap-1 mb-2">
|
||||
{(isMobile ? dayNamesShort : dayNames).map((day, index) => (
|
||||
<div key={day} className="text-center text-xs md:text-sm font-medium text-gray-500 py-2">
|
||||
{(isMobile ? dayNamesShort : dayNames).map((day, _index) => (
|
||||
<div key={day} className="text-center text-xs md:text-sm font-semibold text-gray-700 dark:text-gray-300 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
@@ -241,12 +241,12 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`aspect-square border rounded-lg p-1 hover:bg-gray-50 ${
|
||||
isCurrentDay ? 'bg-indigo-50 border-indigo-200' : 'border-gray-200'
|
||||
className={`aspect-square border-2 rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
||||
isCurrentDay ? 'bg-indigo-100 dark:bg-indigo-900 border-indigo-400 dark:border-indigo-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-xs md:text-sm font-medium mb-1 ${
|
||||
isCurrentDay ? 'text-indigo-700' : 'text-gray-900'
|
||||
<div className={`text-xs md:text-sm font-bold mb-1 ${
|
||||
isCurrentDay ? 'text-indigo-800 dark:text-indigo-200' : 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{day}
|
||||
</div>
|
||||
@@ -257,7 +257,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className="text-xs bg-indigo-100 text-indigo-800 rounded px-1 py-0.5 cursor-pointer hover:bg-indigo-200 truncate"
|
||||
className="text-xs bg-indigo-200 dark:bg-indigo-800 text-indigo-900 dark:text-indigo-200 font-medium rounded px-1 py-0.5 cursor-pointer hover:bg-indigo-300 dark:hover:bg-indigo-700 truncate"
|
||||
title={`${event.title} at ${event.venue}`}
|
||||
>
|
||||
{isMobile ? event.title.slice(0, 8) + (event.title.length > 8 ? '...' : '') : event.title}
|
||||
@@ -265,7 +265,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
))}
|
||||
|
||||
{dayEvents.length > (isMobile ? 1 : 2) && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium">
|
||||
+{dayEvents.length - (isMobile ? 1 : 2)} more
|
||||
</div>
|
||||
)}
|
||||
@@ -290,25 +290,25 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-gray-50 cursor-pointer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border-2 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm font-medium text-gray-900">{event.title}</div>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-gray-100">{event.title}</div>
|
||||
{event.is_featured && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-yellow-200 dark:bg-yellow-800 text-yellow-900 dark:text-yellow-200">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium mt-1">
|
||||
{event.venue}
|
||||
{event.distanceMiles && (
|
||||
<span className="ml-2">• {event.distanceMiles.toFixed(1)} miles</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-right">
|
||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium text-right">
|
||||
{eventDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -327,9 +327,9 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
{showTrending && trendingEvents.length > 0 && view !== 'list' && (
|
||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">🔥 What's Hot</h3>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">🔥 What's Hot</h3>
|
||||
{userLocation && (
|
||||
<span className="text-xs text-gray-500">Within 50 miles</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-400 font-medium">Within 50 miles</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@@ -341,9 +341,9 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{event.title}</div>
|
||||
<div className="text-sm font-bold text-gray-900 truncate">{event.title}</div>
|
||||
{event.isFeature && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold bg-yellow-200 text-yellow-900">
|
||||
⭐
|
||||
</span>
|
||||
)}
|
||||
@@ -354,7 +354,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
<span className="ml-2">• {event.distanceMiles.toFixed(1)} mi</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-orange-600 mt-1">
|
||||
<div className="text-xs text-orange-600 dark:text-orange-400 font-semibold mt-1">
|
||||
{event.ticketsSold} tickets sold
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,9 +374,9 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
{showLocationFeatures && nearbyEvents.length > 0 && view !== 'list' && (
|
||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">📍 Near You</h3>
|
||||
<h3 className="text-sm font-bold text-gray-900">📍 Near You</h3>
|
||||
{userLocation && (
|
||||
<span className="text-xs text-gray-500">Within 25 miles</span>
|
||||
<span className="text-xs text-gray-700 font-medium">Within 25 miles</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -407,7 +407,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
{/* Upcoming Events List */}
|
||||
{view !== 'list' && (
|
||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Upcoming Events</h3>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 mb-3">Upcoming Events</h3>
|
||||
<div className="space-y-2">
|
||||
{events
|
||||
.filter(event => new Date(event.start_time) >= today)
|
||||
@@ -419,11 +419,11 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{event.title}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{event.venue}</div>
|
||||
<div className="text-sm font-bold text-gray-900 truncate">{event.title}</div>
|
||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium truncate">{event.venue}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-right ml-2">
|
||||
{eventDate.toLocaleDateString('en-US', {
|
||||
@@ -439,7 +439,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
</div>
|
||||
|
||||
{events.filter(event => new Date(event.start_time) >= today).length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-400 font-medium text-center py-4">
|
||||
No upcoming events
|
||||
</div>
|
||||
)}
|
||||
@@ -451,7 +451,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
||||
<span className="ml-2 text-sm text-gray-600">Loading location-based events...</span>
|
||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-400 font-medium">Loading location-based events...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -66,8 +66,8 @@ const ChatWidget: React.FC = () => {
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
} catch (_error) {
|
||||
console.error('Chat API error:', _error);
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: 'I apologize, but I\'m having trouble connecting right now. Please try again later or email support@blackcanyontickets.com for assistance.',
|
||||
|
||||
@@ -4,27 +4,27 @@
|
||||
|
||||
<section class="relative py-16 lg:py-24 overflow-hidden">
|
||||
<!-- Background gradients -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-900/20 via-purple-900/20 to-blue-900/20"></div>
|
||||
<div class="absolute inset-0" style="background: var(--bg-gradient);"></div>
|
||||
<div class="absolute inset-0" style="background: linear-gradient(to right, var(--bg-orb-2), var(--bg-orb-1), var(--bg-orb-2));"></div>
|
||||
|
||||
<!-- Glassmorphism overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-transparent backdrop-blur-sm"></div>
|
||||
<div class="absolute inset-0 backdrop-blur-sm" style="background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1), transparent);"></div>
|
||||
|
||||
<div class="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-16">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 mb-6">
|
||||
<span class="text-blue-400 text-sm font-medium">Built by Event Professionals</span>
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full backdrop-blur-sm mb-6" style="background: var(--glass-bg); border: 1px solid var(--glass-border);">
|
||||
<span class="text-sm font-medium" style="color: var(--glass-text-accent);">Built by Event Professionals</span>
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold mb-6" style="color: var(--glass-text-primary);">
|
||||
Why We're Better Than
|
||||
<span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-400">
|
||||
<span class="text-transparent bg-clip-text" style="background-image: linear-gradient(to right, var(--glass-text-accent), var(--glass-text-accent));">
|
||||
The Other Guys
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p class="text-xl text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
<p class="text-xl max-w-3xl mx-auto leading-relaxed" style="color: var(--glass-text-secondary);">
|
||||
Built by people who've actually run gates — not just coded them.
|
||||
Experience real ticketing without the headaches.
|
||||
</p>
|
||||
@@ -36,21 +36,21 @@
|
||||
<!-- Built from Experience -->
|
||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||
<svg class="w-6 h-6" style="color: var(--success-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white">Built by Event Pros</h3>
|
||||
<h3 class="text-xl font-semibold" style="color: var(--glass-text-primary);">Built by Event Pros</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
||||
<span class="text-gray-300 text-sm">Created by actual event professionals who've worked ticket gates</span>
|
||||
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-secondary);">Created by actual event professionals who've worked ticket gates</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
||||
<span class="text-gray-400 text-sm">Built by disconnected tech teams who've never run an event</span>
|
||||
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-tertiary);">Built by disconnected tech teams who've never run an event</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,21 +58,21 @@
|
||||
<!-- Faster Payouts -->
|
||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||
<svg class="w-6 h-6" style="color: var(--success-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white">Instant Payouts</h3>
|
||||
<h3 class="text-xl font-semibold" style="color: var(--glass-text-primary);">Instant Payouts</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
||||
<span class="text-gray-300 text-sm">Stripe deposits go straight to you — no delays or fund holds</span>
|
||||
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-secondary);">Stripe deposits go straight to you — no delays or fund holds</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
||||
<span class="text-gray-400 text-sm">Hold your money for days or weeks before releasing funds</span>
|
||||
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-tertiary);">Hold your money for days or weeks before releasing funds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,21 +80,21 @@
|
||||
<!-- Transparent Fees -->
|
||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||
<svg class="w-6 h-6" style="color: var(--success-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white">No Hidden Fees</h3>
|
||||
<h3 class="text-xl font-semibold" style="color: var(--glass-text-primary);">No Hidden Fees</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
||||
<span class="text-gray-300 text-sm">Flat $1.50 + 2.5% per ticket. Stripe's fee is separate and visible</span>
|
||||
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-secondary);">Flat $1.50 + 2.5% per ticket. Stripe's fee is separate and visible</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
||||
<span class="text-gray-400 text-sm">Hidden platform fees, surprise charges, and confusing pricing</span>
|
||||
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-tertiary);">Hidden platform fees, surprise charges, and confusing pricing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,22 +102,22 @@
|
||||
<!-- Modern Platform -->
|
||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||
<svg class="w-6 h-6" style="color: var(--success-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white">Modern Technology</h3>
|
||||
<h3 class="text-xl font-semibold" style="color: var(--glass-text-primary);">Modern Technology</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
||||
<span class="text-gray-300 text-sm">Custom-built from scratch based on real-world event needs</span>
|
||||
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-secondary);">Custom-built from scratch based on real-world event needs</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
||||
<span class="text-gray-400 text-sm">Bloated, recycled platforms with outdated interfaces</span>
|
||||
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-tertiary);">Bloated, recycled platforms with outdated interfaces</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,21 +125,21 @@
|
||||
<!-- Hands-On Support -->
|
||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||
<svg class="w-6 h-6" style="color: var(--success-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white">Real Human Support</h3>
|
||||
<h3 class="text-xl font-semibold" style="color: var(--glass-text-primary);">Real Human Support</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
||||
<span class="text-gray-300 text-sm">Real humans help you before and during your event</span>
|
||||
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-secondary);">Real humans help you before and during your event</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
||||
<span class="text-gray-400 text-sm">Outsourced support desks and endless ticket systems</span>
|
||||
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-tertiary);">Outsourced support desks and endless ticket systems</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,21 +147,21 @@
|
||||
<!-- Performance & Reliability -->
|
||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||
<svg class="w-6 h-6" style="color: var(--success-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white">Rock-Solid Reliability</h3>
|
||||
<h3 class="text-xl font-semibold" style="color: var(--glass-text-primary);">Rock-Solid Reliability</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
||||
<span class="text-gray-300 text-sm">Built for upscale events with enterprise-grade performance</span>
|
||||
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-secondary);">Built for upscale events with enterprise-grade performance</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
||||
<span class="text-gray-400 text-sm">Crashes during sales rushes when you need them most</span>
|
||||
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||
<span class="text-sm" style="color: var(--glass-text-tertiary);">Crashes during sales rushes when you need them most</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,17 +172,17 @@
|
||||
<!-- Call to Action -->
|
||||
<div class="text-center">
|
||||
<div class="inline-flex flex-col sm:flex-row gap-4">
|
||||
<a href="/login" class="inline-flex items-center justify-center px-8 py-4 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl hover:from-blue-600 hover:to-purple-700 transition-all duration-300 transform hover:scale-105 hover:shadow-lg">
|
||||
<a href="/login" class="inline-flex items-center justify-center px-8 py-4 font-semibold rounded-xl transition-all duration-300 transform hover:scale-105 hover:shadow-lg" style="background: linear-gradient(to right, rgb(37, 99, 235), rgb(147, 51, 234)); color: var(--glass-text-primary);" onmouseenter="this.style.background='linear-gradient(to right, rgb(29, 78, 216), rgb(126, 34, 206))'" onmouseleave="this.style.background='linear-gradient(to right, rgb(37, 99, 235), rgb(147, 51, 234))'">
|
||||
<span>Switch to Black Canyon</span>
|
||||
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/pricing" class="inline-flex items-center justify-center px-8 py-4 bg-white/10 backdrop-blur-sm text-white font-semibold rounded-xl border border-white/20 hover:bg-white/20 transition-all duration-300">
|
||||
<a href="/pricing" class="inline-flex items-center justify-center px-8 py-4 backdrop-blur-sm font-semibold rounded-xl transition-all duration-300" style="background: var(--glass-bg); color: var(--glass-text-primary); border: 1px solid var(--glass-border);" onmouseenter="this.style.background='var(--glass-bg-lg)'" onmouseleave="this.style.background='var(--glass-bg)'">
|
||||
Compare Fees
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mt-4">
|
||||
<p class="text-sm mt-4" style="color: var(--glass-text-tertiary);">
|
||||
Ready to experience real ticketing? Join event professionals who've made the switch.
|
||||
</p>
|
||||
</div>
|
||||
@@ -191,10 +191,10 @@
|
||||
|
||||
<style>
|
||||
.glassmorphism {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 8px 32px var(--glass-shadow-lg);
|
||||
}
|
||||
</style>
|
||||
175
src/components/CustomPageRenderer.tsx
Normal file
175
src/components/CustomPageRenderer.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
import { Editor, Frame, Element } from '@craftjs/core';
|
||||
import {
|
||||
HeroSection,
|
||||
EventDetails,
|
||||
TicketSection,
|
||||
TextBlock,
|
||||
ImageBlock,
|
||||
ButtonBlock,
|
||||
SpacerBlock,
|
||||
TwoColumnLayout
|
||||
} from './craft/components';
|
||||
import TicketCheckout from './TicketCheckout';
|
||||
|
||||
interface CustomPageRendererProps {
|
||||
pageData: Record<string, unknown> | null;
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
venue: string;
|
||||
description?: string;
|
||||
image_url?: string;
|
||||
organizations: {
|
||||
name: string;
|
||||
logo?: string;
|
||||
};
|
||||
};
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
}
|
||||
|
||||
const CustomPageRenderer: React.FC<CustomPageRendererProps> = ({
|
||||
pageData,
|
||||
event,
|
||||
formattedDate,
|
||||
formattedTime
|
||||
}) => {
|
||||
// If no custom page data, render default layout
|
||||
if (!pageData || Object.keys(pageData).length === 0) {
|
||||
return (
|
||||
<div className="bg-white shadow-2xl rounded-2xl overflow-hidden border border-slate-200">
|
||||
{/* Default Event Image */}
|
||||
{event.image_url && (
|
||||
<div className="w-full h-48 sm:h-64 md:h-72 lg:h-80 overflow-hidden">
|
||||
<img
|
||||
src={event.image_url}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 sm:px-6 py-4 sm:py-6">
|
||||
{/* Default Header */}
|
||||
<div className="bg-gradient-to-r from-slate-800 to-slate-900 rounded-xl p-4 sm:p-6 mb-4 sm:mb-6 text-white">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-4 sm:space-y-0">
|
||||
<div className="flex items-center">
|
||||
{event.organizations.logo && (
|
||||
<img
|
||||
src={event.organizations.logo}
|
||||
alt={event.organizations.name}
|
||||
className="h-10 w-10 sm:h-12 sm:w-12 rounded-xl mr-3 sm:mr-4 shadow-lg border-2 border-white/20 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl sm:text-2xl font-light mb-1 tracking-wide truncate">{event.title}</h1>
|
||||
<p className="text-slate-200 text-sm font-medium truncate">Presented by {event.organizations.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-3 border border-white/20">
|
||||
<p className="text-xs text-slate-300 uppercase tracking-wide font-medium">Event Date</p>
|
||||
<p className="text-base sm:text-lg font-semibold text-white">{formattedDate}</p>
|
||||
<p className="text-slate-200 text-sm">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white rounded-xl p-4 sm:p-5 border border-slate-200 shadow-lg">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-slate-900 mb-3 sm:mb-4 flex items-center">
|
||||
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full mr-2"></div>
|
||||
Event Details
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-green-500 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-slate-900">Venue</p>
|
||||
<p className="text-slate-600 text-sm break-words">{event.venue}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-slate-900">Date & Time</p>
|
||||
<p className="text-slate-600 text-sm break-words">{formattedDate} at {formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<div className="mt-4 p-3 sm:p-4 bg-white rounded-lg border border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-2 flex items-center">
|
||||
<div className="w-1 h-1 bg-gradient-to-r from-amber-400 to-orange-500 rounded-full mr-2"></div>
|
||||
About This Event
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm whitespace-pre-line leading-relaxed break-words">{event.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white rounded-xl p-4 sm:p-5 border border-slate-200 shadow-lg lg:sticky lg:top-8">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-slate-900 mb-3 sm:mb-4 flex items-center">
|
||||
<div className="w-2 h-2 bg-gradient-to-r from-emerald-500 to-green-500 rounded-full mr-2"></div>
|
||||
Get Your Tickets
|
||||
</h2>
|
||||
<TicketCheckout event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render custom page with Craft.js
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Editor
|
||||
resolver={{
|
||||
HeroSection,
|
||||
EventDetails,
|
||||
TicketSection,
|
||||
TextBlock,
|
||||
ImageBlock,
|
||||
ButtonBlock,
|
||||
SpacerBlock,
|
||||
TwoColumnLayout
|
||||
}}
|
||||
enabled={false} // Read-only mode for public viewing
|
||||
>
|
||||
<Frame data={pageData}>
|
||||
<Element
|
||||
is="div"
|
||||
canvas
|
||||
className="w-full min-h-screen"
|
||||
custom={{
|
||||
event,
|
||||
formattedDate,
|
||||
formattedTime
|
||||
}}
|
||||
/>
|
||||
</Frame>
|
||||
</Editor>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPageRenderer;
|
||||
500
src/components/CustomPricingManager.tsx
Normal file
500
src/components/CustomPricingManager.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface CustomPricingProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
stripe_account_id?: string;
|
||||
use_personal_stripe: boolean;
|
||||
can_override_pricing: boolean;
|
||||
can_set_custom_fees: boolean;
|
||||
custom_platform_fee_type?: 'percentage' | 'fixed' | 'none';
|
||||
custom_platform_fee_percentage?: number;
|
||||
custom_platform_fee_fixed?: number;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
start_time: string;
|
||||
organizations: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EventPricingOverride {
|
||||
id: string;
|
||||
event_id: string;
|
||||
use_custom_stripe_account: boolean;
|
||||
override_platform_fees: boolean;
|
||||
platform_fee_type?: 'percentage' | 'fixed' | 'none';
|
||||
platform_fee_percentage?: number;
|
||||
platform_fee_fixed?: number;
|
||||
}
|
||||
|
||||
interface CustomPricingManagerProps {
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const CustomPricingManager: React.FC<CustomPricingManagerProps> = ({
|
||||
userId,
|
||||
isAdmin
|
||||
}) => {
|
||||
const [profile, setProfile] = useState<CustomPricingProfile | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventOverrides, setEventOverrides] = useState<EventPricingOverride[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'events'>('profile');
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
loadData();
|
||||
}
|
||||
}, [userId, isAdmin]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [profileRes, eventsRes, overridesRes] = await Promise.all([
|
||||
fetch(`/api/custom-pricing/profile/${userId}`),
|
||||
fetch(`/api/events?user_id=${userId}`),
|
||||
fetch(`/api/custom-pricing/overrides?user_id=${userId}`)
|
||||
]);
|
||||
|
||||
const profileData = await profileRes.json();
|
||||
const eventsData = await eventsRes.json();
|
||||
const overridesData = await overridesRes.json();
|
||||
|
||||
if (profileData.success) setProfile(profileData.profile);
|
||||
if (eventsData.success) setEvents(eventsData.events);
|
||||
if (overridesData.success) setEventOverrides(overridesData.overrides);
|
||||
} catch (_error) {
|
||||
console.error('Failed to load custom pricing data:', _error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (updates: Partial<CustomPricingProfile>) => {
|
||||
try {
|
||||
const response = await fetch(`/api/custom-pricing/profile/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProfile(data.profile);
|
||||
alert('Profile updated successfully!');
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error('Failed to update profile:', _error);
|
||||
alert('Failed to update profile. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEventOverride = async (eventId: string, overrideData: Partial<EventPricingOverride>) => {
|
||||
try {
|
||||
const response = await fetch('/api/custom-pricing/overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_id: eventId,
|
||||
custom_pricing_profile_id: profile?.id,
|
||||
...overrideData
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEventOverrides([...eventOverrides, data.override]);
|
||||
setShowEventModal(false);
|
||||
setSelectedEvent(null);
|
||||
alert('Event pricing override created successfully!');
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error('Failed to create event override:', _error);
|
||||
alert('Failed to create event override. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOverride = async (overrideId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this pricing override?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/custom-pricing/overrides/${overrideId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setEventOverrides(eventOverrides.filter(o => o.id !== overrideId));
|
||||
alert('Override removed successfully!');
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error('Failed to delete override:', _error);
|
||||
alert('Failed to delete override. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🔒</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Access Denied</h3>
|
||||
<p className="text-gray-600">This feature is only available to superusers.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Custom Pricing Management</h1>
|
||||
<p className="text-gray-600">Manage your custom pricing settings and event-specific overrides.</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'profile'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Profile Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('events')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'events'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Event Overrides
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Profile Settings Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium mb-4">Stripe Account Settings</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profile?.use_personal_stripe || false}
|
||||
onChange={(e) => handleUpdateProfile({ use_personal_stripe: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Use Personal Stripe Account
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
When enabled, payments will go directly to your personal Stripe account instead of the platform account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{profile?.use_personal_stripe && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Personal Stripe Account ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile?.stripe_account_id || ''}
|
||||
onChange={(e) => handleUpdateProfile({ stripe_account_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="acct_xxxxxxxxxxxxx"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Your Stripe Connect account ID. This can be found in your Stripe dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium mb-4">Platform Fee Settings</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profile?.can_override_pricing || false}
|
||||
onChange={(e) => handleUpdateProfile({ can_override_pricing: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Can Override Platform Fees
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profile?.can_set_custom_fees || false}
|
||||
onChange={(e) => handleUpdateProfile({ can_set_custom_fees: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Can Set Custom Fee Structure
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{profile?.can_set_custom_fees && (
|
||||
<div className="pl-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Default Fee Type
|
||||
</label>
|
||||
<select
|
||||
value={profile?.custom_platform_fee_type || 'percentage'}
|
||||
onChange={(e) => handleUpdateProfile({
|
||||
custom_platform_fee_type: e.target.value as 'percentage' | 'fixed' | 'none'
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="percentage">Percentage</option>
|
||||
<option value="fixed">Fixed Amount</option>
|
||||
<option value="none">No Fees</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{profile?.custom_platform_fee_type === 'percentage' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Percentage (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={profile?.custom_platform_fee_percentage || 0}
|
||||
onChange={(e) => handleUpdateProfile({
|
||||
custom_platform_fee_percentage: parseFloat(e.target.value)
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile?.custom_platform_fee_type === 'fixed' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fixed Amount (cents)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={profile?.custom_platform_fee_fixed || 0}
|
||||
onChange={(e) => handleUpdateProfile({
|
||||
custom_platform_fee_fixed: parseInt(e.target.value)
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Overrides Tab */}
|
||||
{activeTab === 'events' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-medium">Event-Specific Overrides</h2>
|
||||
<button
|
||||
onClick={() => setShowEventModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{eventOverrides.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">⚙️</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Event Overrides</h3>
|
||||
<p className="text-gray-600 mb-4">Create event-specific pricing overrides to customize fees per event.</p>
|
||||
<button
|
||||
onClick={() => setShowEventModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Create First Override
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Event
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Custom Stripe
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fee Override
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{eventOverrides.map((override) => {
|
||||
const event = events.find(e => e.id === override.event_id);
|
||||
return (
|
||||
<tr key={override.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{event?.title || 'Unknown Event'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{event?.organizations?.name}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
override.use_custom_stripe_account
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{override.use_custom_stripe_account ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{override.override_platform_fees ? (
|
||||
<div className="text-sm text-gray-900">
|
||||
{override.platform_fee_type === 'percentage' &&
|
||||
`${override.platform_fee_percentage}%`}
|
||||
{override.platform_fee_type === 'fixed' &&
|
||||
`$${(override.platform_fee_fixed || 0) / 100}`}
|
||||
{override.platform_fee_type === 'none' && 'No Fees'}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Default</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleDeleteOverride(override.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Event Override Modal */}
|
||||
{showEventModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Add Event Override</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Select Event
|
||||
</label>
|
||||
<select
|
||||
value={selectedEvent?.id || ''}
|
||||
onChange={(e) => {
|
||||
const event = events.find(ev => ev.id === e.target.value);
|
||||
setSelectedEvent(event || null);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Choose an event...</option>
|
||||
{events.filter(e => !eventOverrides.some(o => o.event_id === e.id)).map(event => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} - {event.organizations.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedEvent && (
|
||||
<>
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Use Custom Stripe Account for this event
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Override Platform Fees for this event
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEventModal(false);
|
||||
setSelectedEvent(null);
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedEvent) {
|
||||
handleCreateEventOverride(selectedEvent.id, {
|
||||
use_custom_stripe_account: false,
|
||||
override_platform_fees: false
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPricingManager;
|
||||
@@ -6,74 +6,113 @@ interface Props {
|
||||
const { eventId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl mb-8 overflow-hidden">
|
||||
<div class="px-8 py-12 text-white">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h1 id="event-title" class="text-4xl font-light mb-3 tracking-wide">Loading...</h1>
|
||||
<div class="flex items-center space-x-6 text-slate-200 mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="backdrop-blur-xl rounded-3xl shadow-2xl mb-8 overflow-hidden ring-1 transition-all duration-200 hover:shadow-3xl"
|
||||
style="background: var(--glass-bg-lg); border: 1px solid var(--glass-border-subtle); ring-color: var(--glass-ring-dark);"
|
||||
data-theme-card="true">
|
||||
<div class="px-8 py-12" style="color: var(--glass-text-primary);">
|
||||
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center">
|
||||
<div class="flex-1 mb-6 lg:mb-0">
|
||||
<h1 id="event-title" class="text-3xl font-light mb-2 tracking-wide" style="color: var(--glass-text-primary);">Loading...</h1>
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm mb-3" style="color: var(--glass-text-secondary);">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span id="event-venue">--</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span id="event-date">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<p id="event-description" class="text-slate-300 max-w-2xl leading-relaxed">Loading event details...</p>
|
||||
<div class="max-w-2xl">
|
||||
<p id="event-description" class="text-sm leading-relaxed transition-all duration-300 overflow-hidden body-text" style="color: var(--glass-text-tertiary);">Loading event details...</p>
|
||||
<button id="description-toggle" class="text-xs mt-1 hidden transition-colors hover:opacity-80" style="color: var(--glass-text-accent);">Show more</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end space-y-3">
|
||||
<div class="flex space-x-3">
|
||||
|
||||
<div class="flex flex-col items-end space-y-4">
|
||||
<!-- Revenue Display -->
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-semibold" id="total-revenue" style="color: var(--glass-text-primary);">$0</div>
|
||||
<div class="text-xs" style="color: var(--glass-text-secondary);">Total Revenue</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Button Grid -->
|
||||
<div class="grid grid-cols-3 gap-2 lg:gap-3">
|
||||
<!-- Top Row: Quick Actions -->
|
||||
<a
|
||||
id="preview-link"
|
||||
href="#"
|
||||
target="_blank"
|
||||
class="bg-white/10 hover:bg-white/20 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 backdrop-blur-sm flex items-center gap-2"
|
||||
class="px-3 py-2 rounded-lg font-medium transition-all duration-200 ease-in-out backdrop-blur-sm flex items-center justify-center gap-1 text-xs hover:scale-[1.02] hover:shadow-lg ring-1 hover:ring-2"
|
||||
style="background: var(--glass-bg-button); color: var(--glass-text-primary); border: 1px solid var(--glass-border); ring-color: var(--glass-ring-dark);"
|
||||
title="Preview Page"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
Preview Page
|
||||
Preview
|
||||
</a>
|
||||
<button
|
||||
id="embed-code-btn"
|
||||
class="bg-white/10 hover:bg-white/20 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 backdrop-blur-sm flex items-center gap-2"
|
||||
class="px-3 py-2 rounded-lg font-medium transition-all duration-200 ease-in-out backdrop-blur-sm flex items-center justify-center gap-1 text-xs hover:scale-[1.02] hover:shadow-lg ring-1 hover:ring-2"
|
||||
style="background: var(--glass-bg-button); color: var(--glass-text-primary); border: 1px solid var(--glass-border); ring-color: var(--glass-ring-dark);"
|
||||
title="Get Embed Code"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
Get Embed Code
|
||||
Embed
|
||||
</button>
|
||||
<button
|
||||
id="edit-event-btn"
|
||||
class="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-3 py-2 rounded-lg font-medium transition-all duration-200 ease-in-out flex items-center justify-center gap-1 text-xs hover:scale-[1.02] hover:shadow-lg"
|
||||
title="Edit Event"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
|
||||
<!-- Bottom Row: Tools -->
|
||||
<a
|
||||
href="/scan"
|
||||
class="bg-white/10 hover:bg-white/20 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 backdrop-blur-sm flex items-center gap-2"
|
||||
class="bg-white/10 hover:bg-white/20 text-white px-3 py-2 rounded-lg font-medium transition-all duration-200 backdrop-blur-sm flex items-center justify-center gap-1 text-xs"
|
||||
title="Scanner"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h4m-6 0h-2v4m0-11v3m0 0h.01M12 12h.01M16 8h4m-6 0h-2v4m0-11v3m0 0h.01M12 12h.01"></path>
|
||||
</svg>
|
||||
Scanner
|
||||
</a>
|
||||
<button
|
||||
id="edit-event-btn"
|
||||
class="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 flex items-center gap-2"
|
||||
<a
|
||||
id="kiosk-link"
|
||||
href="#"
|
||||
class="bg-white/10 hover:bg-white/20 text-white px-3 py-2 rounded-lg font-medium transition-all duration-200 backdrop-blur-sm flex items-center justify-center gap-1 text-xs"
|
||||
title="Sales Kiosk"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Edit Event
|
||||
Kiosk
|
||||
</a>
|
||||
<button
|
||||
id="generate-kiosk-pin-btn"
|
||||
class="bg-white/10 hover:bg-white/20 text-white px-3 py-2 rounded-lg font-medium transition-all duration-200 backdrop-blur-sm flex items-center justify-center gap-1 text-xs"
|
||||
title="Generate Kiosk PIN"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
PIN
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-semibold" id="total-revenue">$0</div>
|
||||
<div class="text-sm text-slate-300">Total Revenue</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,50 +126,141 @@ const { eventId } = Astro.props;
|
||||
|
||||
async function loadEventHeader() {
|
||||
try {
|
||||
const { createClient } = await import('@supabase/supabase-js');
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
const { api } = await import('/src/lib/api-router.js');
|
||||
|
||||
// Load event data
|
||||
const { data: event, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('id', eventId)
|
||||
.single();
|
||||
// Load event details and stats using the new API system
|
||||
const result = await api.loadEventPage(eventId);
|
||||
|
||||
if (error) throw error;
|
||||
if (!result.event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
document.getElementById('event-title').textContent = event.title;
|
||||
document.getElementById('event-venue').textContent = event.venue;
|
||||
document.getElementById('event-date').textContent = new Date(event.date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
document.getElementById('event-description').textContent = event.description;
|
||||
document.getElementById('preview-link').href = `/e/${event.slug}`;
|
||||
// Update event details
|
||||
document.getElementById('event-title').textContent = result.event.title;
|
||||
document.getElementById('event-venue').textContent = result.event.venue;
|
||||
|
||||
// Load stats
|
||||
const { data: tickets } = await supabase
|
||||
.from('tickets')
|
||||
.select('price_paid')
|
||||
.eq('event_id', eventId)
|
||||
.eq('status', 'confirmed');
|
||||
// Use start_time from database
|
||||
document.getElementById('event-date').textContent = api.formatDate(result.event.start_time);
|
||||
|
||||
const totalRevenue = tickets?.reduce((sum, ticket) => sum + ticket.price_paid, 0) || 0;
|
||||
document.getElementById('total-revenue').textContent = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(totalRevenue / 100);
|
||||
// Handle description truncation
|
||||
const descriptionEl = document.getElementById('event-description');
|
||||
const toggleBtn = document.getElementById('description-toggle');
|
||||
const fullDescription = result.event.description;
|
||||
const maxLength = 120; // Show about one line
|
||||
|
||||
if (fullDescription && fullDescription.length > maxLength) {
|
||||
// Set initial truncated text
|
||||
descriptionEl.textContent = fullDescription.substring(0, maxLength) + '...';
|
||||
descriptionEl.dataset.fullText = fullDescription;
|
||||
descriptionEl.dataset.truncatedText = fullDescription.substring(0, maxLength) + '...';
|
||||
descriptionEl.dataset.expanded = 'false';
|
||||
|
||||
// Show toggle button
|
||||
toggleBtn.classList.remove('hidden');
|
||||
|
||||
// Add click handler
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const isExpanded = descriptionEl.dataset.expanded === 'true';
|
||||
|
||||
if (isExpanded) {
|
||||
descriptionEl.textContent = descriptionEl.dataset.truncatedText;
|
||||
toggleBtn.textContent = 'Show more';
|
||||
descriptionEl.dataset.expanded = 'false';
|
||||
} else {
|
||||
descriptionEl.textContent = descriptionEl.dataset.fullText;
|
||||
toggleBtn.textContent = 'Show less';
|
||||
descriptionEl.dataset.expanded = 'true';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Description is short enough, show full text
|
||||
descriptionEl.textContent = fullDescription;
|
||||
}
|
||||
|
||||
document.getElementById('preview-link').href = `/e/${result.event.slug}`;
|
||||
document.getElementById('kiosk-link').href = `/kiosk/${result.event.slug}`;
|
||||
|
||||
// Update revenue from stats
|
||||
if (result.stats) {
|
||||
document.getElementById('total-revenue').textContent = api.formatCurrency(result.stats.totalRevenue);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading event header:', error);
|
||||
// Error loading event header
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Kiosk PIN functionality
|
||||
document.getElementById('generate-kiosk-pin-btn').addEventListener('click', async () => {
|
||||
if (!confirm('Generate a new PIN for the sales kiosk? This will invalidate any existing PIN.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('generate-kiosk-pin-btn');
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
btn.innerHTML = '<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> ...';
|
||||
btn.disabled = true;
|
||||
|
||||
const { api } = await import('/src/lib/api-router.js');
|
||||
const { supabase } = await import('/src/lib/supabase.js');
|
||||
|
||||
// Get auth token
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// Generate PIN
|
||||
const response = await fetch('/api/kiosk/generate-pin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.access_token}`
|
||||
},
|
||||
body: JSON.stringify({ eventId })
|
||||
});
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await response.json();
|
||||
} catch (parseError) {
|
||||
throw new Error('Server returned invalid response. Please try again.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to generate PIN');
|
||||
}
|
||||
|
||||
// Send PIN email
|
||||
const emailResponse = await fetch('/api/kiosk/send-pin-email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event: result.event,
|
||||
pin: result.pin,
|
||||
email: result.userEmail
|
||||
})
|
||||
});
|
||||
|
||||
const emailResult = await emailResponse.json();
|
||||
|
||||
if (!emailResponse.ok) {
|
||||
alert(`PIN Generated: ${result.pin}\n\nEmail delivery failed. Please note this PIN manually.`);
|
||||
} else {
|
||||
alert(`PIN generated successfully!\n\nA new 4-digit PIN has been sent to ${result.userEmail}.\n\nThe PIN expires in 24 hours.`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
alert('Failed to generate PIN: ' + error.message);
|
||||
} finally {
|
||||
// Restore button
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,127 +1,189 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import TabNavigation from './manage/TabNavigation';
|
||||
import TicketsTab from './manage/TicketsTab';
|
||||
import VenueTab from './manage/VenueTab';
|
||||
import TicketingAccessTab from './manage/TicketingAccessTab';
|
||||
import OrdersTab from './manage/OrdersTab';
|
||||
import AttendeesTab from './manage/AttendeesTab';
|
||||
import PresaleTab from './manage/PresaleTab';
|
||||
import DiscountTab from './manage/DiscountTab';
|
||||
import AddonsTab from './manage/AddonsTab';
|
||||
import PrintedTab from './manage/PrintedTab';
|
||||
import SettingsTab from './manage/SettingsTab';
|
||||
import MarketingTab from './manage/MarketingTab';
|
||||
import PromotionsTab from './manage/PromotionsTab';
|
||||
import EmbedCodeModal from './modals/EmbedCodeModal';
|
||||
import EventSettingsTab from './manage/EventSettingsTab';
|
||||
import CustomPageTab from './manage/CustomPageTab';
|
||||
import { api } from '../lib/api-router.js';
|
||||
import type { EventData } from '../lib/event-management.js';
|
||||
|
||||
interface EventManagementProps {
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
eventSlug: string;
|
||||
organizationId?: string;
|
||||
eventSlug?: string;
|
||||
}
|
||||
|
||||
export default function EventManagement({ eventId, organizationId, eventSlug }: EventManagementProps) {
|
||||
const [activeTab, setActiveTab] = useState('tickets');
|
||||
const [showEmbedModal, setShowEmbedModal] = useState(false);
|
||||
export default function EventManagement({ eventId, _organizationId, eventSlug }: EventManagementProps) {
|
||||
const [activeTab, setActiveTab] = useState('ticketing');
|
||||
const [eventData, setEventData] = useState<EventData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_user, setUser] = useState<unknown>(null);
|
||||
const [userOrganizationId, setUserOrganizationId] = useState<string | null>(null);
|
||||
const [actualEventSlug, setActualEventSlug] = useState<string | null>(eventSlug || null);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'tickets',
|
||||
name: 'Tickets & Pricing',
|
||||
icon: '🎫',
|
||||
component: TicketsTab
|
||||
id: 'ticketing',
|
||||
name: 'Ticketing & Access',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
),
|
||||
component: TicketingAccessTab
|
||||
},
|
||||
{
|
||||
id: 'venue',
|
||||
name: 'Venue & Seating',
|
||||
icon: '🏛️',
|
||||
component: VenueTab
|
||||
id: 'custom-pages',
|
||||
name: 'Custom Pages',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
),
|
||||
component: CustomPageTab
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
name: 'Orders & Sales',
|
||||
icon: '📊',
|
||||
id: 'sales',
|
||||
name: 'Sales',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
component: OrdersTab
|
||||
},
|
||||
{
|
||||
id: 'attendees',
|
||||
name: 'Attendees & Check-in',
|
||||
icon: '👥',
|
||||
name: 'Attendees',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
component: AttendeesTab
|
||||
},
|
||||
{
|
||||
id: 'presale',
|
||||
name: 'Presale Codes',
|
||||
icon: '🏷️',
|
||||
component: PresaleTab
|
||||
},
|
||||
{
|
||||
id: 'discount',
|
||||
name: 'Discount Codes',
|
||||
icon: '🎟️',
|
||||
component: DiscountTab
|
||||
},
|
||||
{
|
||||
id: 'addons',
|
||||
name: 'Add-ons & Extras',
|
||||
icon: '📦',
|
||||
component: AddonsTab
|
||||
},
|
||||
{
|
||||
id: 'printed',
|
||||
name: 'Printed Tickets',
|
||||
icon: '🖨️',
|
||||
component: PrintedTab
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Event Settings',
|
||||
icon: '⚙️',
|
||||
component: SettingsTab
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing Kit',
|
||||
icon: '📈',
|
||||
component: MarketingTab
|
||||
},
|
||||
{
|
||||
id: 'promotions',
|
||||
name: 'Promotions',
|
||||
icon: '🎯',
|
||||
component: PromotionsTab
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
component: EventSettingsTab
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Set up embed code button listener
|
||||
const embedBtn = document.getElementById('embed-code-btn');
|
||||
if (embedBtn) {
|
||||
embedBtn.addEventListener('click', () => setShowEmbedModal(true));
|
||||
}
|
||||
// Check authentication and load data
|
||||
const initializeComponent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
return () => {
|
||||
if (embedBtn) {
|
||||
embedBtn.removeEventListener('click', () => setShowEmbedModal(true));
|
||||
// Check authentication status
|
||||
const authStatus = await api.checkAuth();
|
||||
|
||||
if (!authStatus.authenticated) {
|
||||
// Give a bit more time for auth to load on page refresh
|
||||
// Auth check failed, retrying in 1 second...
|
||||
setTimeout(async () => {
|
||||
const retryAuthStatus = await api.checkAuth();
|
||||
if (!retryAuthStatus.authenticated) {
|
||||
// Still not authenticated, redirect to login
|
||||
// Auth retry failed, redirecting to login
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
// Retry loading with successful auth
|
||||
// Auth retry succeeded, reinitializing component
|
||||
initializeComponent();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(authStatus.user);
|
||||
|
||||
if (!authStatus.organizationId) {
|
||||
setError('User not associated with any organization');
|
||||
return;
|
||||
}
|
||||
|
||||
setUserOrganizationId(authStatus.organizationId);
|
||||
|
||||
// Load event data using centralized API
|
||||
const data = await api.loadEventData(eventId);
|
||||
if (data) {
|
||||
setEventData(data);
|
||||
setActualEventSlug(data.slug);
|
||||
} else {
|
||||
setError(`Event not found or access denied. Event ID: ${eventId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('EventManagement initialization error:', err);
|
||||
setError('Failed to load event data. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
initializeComponent();
|
||||
}, [eventId]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 mx-auto mb-4" style={{borderColor: 'var(--glass-text-primary)', borderTopColor: 'transparent'}}></div>
|
||||
<p style={{color: 'var(--glass-text-secondary)'}}>Loading event data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center mx-auto mb-4" style={{background: 'var(--error-bg)'}}>
|
||||
<svg className="w-6 h-6" style={{color: 'var(--error-color)'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium mb-2" style={{color: 'var(--error-color)'}}>Error Loading Event</p>
|
||||
<p className="text-sm mb-4" style={{color: 'var(--glass-text-secondary)'}}>{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 border rounded-lg text-sm transition-colors backdrop-blur-lg"
|
||||
style={{
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
borderColor: 'var(--ui-border-primary)',
|
||||
color: 'var(--ui-text-primary)'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = 'var(--ui-bg-secondary)'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'var(--ui-bg-elevated)'}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
eventId={eventId}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
|
||||
<EmbedCodeModal
|
||||
isOpen={showEmbedModal}
|
||||
onClose={() => setShowEmbedModal(false)}
|
||||
eventId={eventId}
|
||||
eventSlug={eventSlug}
|
||||
/>
|
||||
</>
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
eventId={eventId}
|
||||
organizationId={userOrganizationId || ''}
|
||||
eventData={eventData}
|
||||
eventSlug={actualEventSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ interface ImageUploadCropperProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface CropData {
|
||||
interface _CropData {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -108,7 +108,7 @@ export default function ImageUploadCropper({
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
// Handle keyboard shortcuts and scroll locking
|
||||
useEffect(() => {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (showCropper && e.key === 'Escape' && !isUploading) {
|
||||
@@ -117,8 +117,15 @@ export default function ImageUploadCropper({
|
||||
};
|
||||
|
||||
if (showCropper) {
|
||||
// Lock body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => document.removeEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
// Restore body scroll when modal closes
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}
|
||||
}, [showCropper, isUploading]);
|
||||
|
||||
@@ -164,9 +171,7 @@ export default function ImageUploadCropper({
|
||||
throw new Error(`Crop area too small. Minimum size: ${MIN_CROP_WIDTH}×${MIN_CROP_HEIGHT}px`);
|
||||
}
|
||||
|
||||
console.log('Starting crop and upload process...');
|
||||
const { file, dataUrl } = await getCroppedImg(imageSrc, croppedAreaPixels, originalFileName);
|
||||
console.log('Cropped image created, size:', file.size, 'bytes');
|
||||
const { file, dataUrl: _dataUrl } = await getCroppedImg(imageSrc, croppedAreaPixels, originalFileName);
|
||||
|
||||
// Validate final file size
|
||||
if (file.size > MAX_FINAL_SIZE) {
|
||||
@@ -184,7 +189,6 @@ export default function ImageUploadCropper({
|
||||
throw new Error('Authentication required. Please sign in again.');
|
||||
}
|
||||
|
||||
console.log('Uploading to server...');
|
||||
const response = await fetch('/api/upload-event-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -193,15 +197,12 @@ export default function ImageUploadCropper({
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Upload response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Upload failed' }));
|
||||
throw new Error(errorData.error || `Upload failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const { imageUrl } = await response.json();
|
||||
console.log('Upload successful, image URL:', imageUrl);
|
||||
|
||||
onImageChange(imageUrl);
|
||||
setShowCropper(false);
|
||||
@@ -211,7 +212,6 @@ export default function ImageUploadCropper({
|
||||
setCroppedAreaPixels(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
@@ -260,7 +260,7 @@ export default function ImageUploadCropper({
|
||||
|
||||
{/* File Input */}
|
||||
{!currentImageUrl && !showCropper && (
|
||||
<div className="border-2 border-dashed border-gray-600 rounded-lg p-6 text-center">
|
||||
<div className="border-2 border-dashed border-white/20 rounded-2xl p-6 text-center bg-white/5 hover:bg-white/10 transition-colors duration-200">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -273,11 +273,11 @@ export default function ImageUploadCropper({
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
Upload Image
|
||||
</button>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
<p className="text-sm text-white/60 mt-2">
|
||||
JPG, PNG, or WebP • Max 10MB • Recommended: 1200×628px
|
||||
</p>
|
||||
</div>
|
||||
@@ -289,7 +289,7 @@ export default function ImageUploadCropper({
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
Replace Image
|
||||
</button>
|
||||
@@ -297,8 +297,8 @@ export default function ImageUploadCropper({
|
||||
|
||||
{/* Cropper Modal */}
|
||||
{showCropper && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-xl flex items-center justify-center z-50">
|
||||
<div className="bg-gradient-to-br from-indigo-900/90 via-purple-900/90 to-slate-900/90 backdrop-blur-xl border border-white/20 rounded-3xl p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Crop Your Image</h3>
|
||||
<button
|
||||
@@ -314,7 +314,7 @@ export default function ImageUploadCropper({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative h-96 bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="relative h-96 bg-black/50 rounded-2xl overflow-hidden border border-white/10">
|
||||
{imageSrc && (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
@@ -332,7 +332,7 @@ export default function ImageUploadCropper({
|
||||
|
||||
{/* Zoom Control */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium mb-2 text-white">Zoom</label>
|
||||
<label className="block text-sm font-medium mb-2 text-white/90">Zoom</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
@@ -340,7 +340,7 @@ export default function ImageUploadCropper({
|
||||
step={0.1}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="w-full"
|
||||
className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider-thumb"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
@@ -351,7 +351,7 @@ export default function ImageUploadCropper({
|
||||
type="button"
|
||||
onClick={handleCropCancel}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||
className="px-6 py-3 border border-white/20 text-white/80 rounded-xl hover:bg-white/5 font-medium transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -359,7 +359,7 @@ export default function ImageUploadCropper({
|
||||
type="button"
|
||||
onClick={handleCropSave}
|
||||
disabled={isUploading || !croppedAreaPixels}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
className="px-8 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Save & Upload'}
|
||||
</button>
|
||||
@@ -370,7 +370,7 @@ export default function ImageUploadCropper({
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-900 border border-red-600 text-red-100 px-4 py-3 rounded">
|
||||
<div className="bg-red-500/10 border border-red-400/20 text-red-400 px-4 py-3 rounded-xl">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,46 +12,262 @@ const {
|
||||
backLinkUrl = "/dashboard",
|
||||
backLinkText = "← Back"
|
||||
} = Astro.props;
|
||||
|
||||
// Determine if we should show theme toggle based on auth context
|
||||
// Hide theme toggle on forced dark mode pages
|
||||
const forceDarkModePages = ['/', '/login', '/calendar-enhanced'];
|
||||
const isAuthPage = Astro.url.pathname.includes('/dashboard') ||
|
||||
Astro.url.pathname.includes('/events') ||
|
||||
Astro.url.pathname.includes('/scan') ||
|
||||
Astro.url.pathname.includes('/admin') ||
|
||||
(Astro.url.pathname.includes('/calendar') && !forceDarkModePages.includes(Astro.url.pathname)) ||
|
||||
Astro.url.pathname.includes('/templates') ||
|
||||
Astro.url.pathname.includes('/custom-pricing');
|
||||
|
||||
// Check if current page should have theme toggle hidden
|
||||
const shouldHideThemeToggle = forceDarkModePages.includes(Astro.url.pathname);
|
||||
|
||||
import ThemeToggle from './ThemeToggle.tsx';
|
||||
---
|
||||
|
||||
<!-- Unified Navigation -->
|
||||
<nav class="sticky top-0 z-50 bg-white/90 backdrop-blur-lg shadow-xl border-b border-slate-200/50">
|
||||
<!-- White Navigation Bar - Consistent across all pages -->
|
||||
<nav id="main-nav" class="sticky top-0 z-50 transition-all duration-300 border-b bg-white shadow-sm"
|
||||
style="border-color: rgba(0, 0, 0, 0.1);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<div class="flex justify-between h-20 transition-all duration-300" id="nav-content">
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/dashboard" class="flex items-center">
|
||||
<span class="text-xl font-light text-gray-900">
|
||||
<span class="font-bold">P</span>ortal
|
||||
</span>
|
||||
<!-- Brand -->
|
||||
<a href="/dashboard" class="flex items-center space-x-3 group">
|
||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shadow-lg hover:rotate-3 hover:scale-110 transition-all duration-300 bg-blue-600 text-white">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<span class="text-xl font-semibold transition-colors duration-200 group-hover:opacity-80 text-gray-900">BCT</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Navigation Items -->
|
||||
<div class="hidden md:flex items-center space-x-6">
|
||||
{showBackLink && (
|
||||
<div class="flex items-center space-x-3">
|
||||
<a
|
||||
href={backLinkUrl}
|
||||
class="text-slate-600 hover:text-slate-900 font-medium transition-colors duration-200"
|
||||
class="font-medium transition-colors duration-200 flex items-center space-x-1 hover:opacity-80 text-gray-700"
|
||||
>
|
||||
{backLinkText}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>{backLinkText.replace('← ', '')}</span>
|
||||
</a>
|
||||
<span class="text-slate-400">|</span>
|
||||
<span class="text-gray-300">|</span>
|
||||
</div>
|
||||
)}
|
||||
<span class="text-slate-900 font-semibold">{title}</span>
|
||||
<span class="font-semibold text-gray-900">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a
|
||||
id="admin-dashboard-link"
|
||||
href="/admin/dashboard"
|
||||
class="hidden bg-slate-800 hover:bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
Admin Dashboard
|
||||
<div class="flex items-center space-x-3">
|
||||
{isAuthPage && !shouldHideThemeToggle && (
|
||||
<div class="mr-1">
|
||||
<ThemeToggle client:load />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button id="mobile-menu-btn" class="md:hidden p-2 rounded-lg transition-colors duration-200 hover:bg-gray-100 bg-gray-50 text-gray-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Premium User Menu -->
|
||||
<div class="hidden md:flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<button
|
||||
id="user-menu-btn"
|
||||
class="flex items-center space-x-3 p-2 rounded-xl transition-all duration-200 group hover:bg-gray-50 bg-gray-50"
|
||||
>
|
||||
<div id="user-avatar" class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold shadow-lg group-hover:shadow-xl transition-all duration-200 ring-1 bg-blue-600 text-white border-blue-700"
|
||||
data-theme-aware="true">
|
||||
T
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex flex-col items-start">
|
||||
<span id="user-name-text" class="text-sm font-semibold text-gray-900"></span>
|
||||
<span id="admin-badge" class="hidden text-xs font-medium text-blue-600">Admin</span>
|
||||
</div>
|
||||
<svg class="w-4 h-4 transition-colors text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Premium Dropdown Menu -->
|
||||
<div id="user-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-2xl shadow-xl py-2 z-50 transform opacity-0 scale-95 transition-all duration-200 border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div id="dropdown-avatar" class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ring-1 transition-all duration-200 bg-blue-600 text-white border-blue-700"
|
||||
data-theme-aware="true">
|
||||
T
|
||||
</div>
|
||||
<div>
|
||||
<p id="dropdown-name" class="text-sm font-semibold text-gray-900"></p>
|
||||
<p id="dropdown-email" class="text-xs text-gray-600"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<a href="/dashboard" class="flex items-center px-4 py-2 text-sm transition-colors hover:bg-gray-50 text-gray-900">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a href="/events/new" class="flex items-center px-4 py-2 text-sm transition-colors hover:bg-gray-50 text-gray-900">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Event
|
||||
</a>
|
||||
|
||||
<a href="/calendar" class="flex items-center px-4 py-2 text-sm transition-colors hover:bg-gray-50 text-gray-900">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Calendar
|
||||
</a>
|
||||
|
||||
<a href="/scan" class="flex items-center px-4 py-2 text-sm transition-colors hover:bg-gray-50 text-gray-900">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
QR Scanner
|
||||
</a>
|
||||
|
||||
<a href="/templates" class="flex items-center px-4 py-2 text-sm transition-colors hover:bg-gray-50 text-gray-900">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
Page Templates
|
||||
</a>
|
||||
|
||||
<div id="admin-menu-item" class="hidden">
|
||||
<a href="/custom-pricing" class="flex items-center px-4 py-2 text-sm transition-colors hover:bg-gray-50 text-gray-900">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
Custom Pricing
|
||||
</a>
|
||||
|
||||
<a href="/admin/dashboard" class="flex items-center px-4 py-2 text-sm transition-colors hover:bg-gray-50 text-gray-900">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Admin Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-2 border-gray-200">
|
||||
<button
|
||||
id="logout-btn"
|
||||
class="flex items-center w-full px-4 py-2 text-sm transition-colors hover:bg-red-50 text-red-600"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-3 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium Mobile Menu Drawer -->
|
||||
<div id="mobile-menu" class="hidden md:hidden bg-white border-b shadow-lg border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div id="mobile-user-avatar" class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold shadow-lg ring-1 transition-all duration-200 bg-blue-600 text-white border-blue-700"
|
||||
data-theme-aware="true">
|
||||
T
|
||||
</div>
|
||||
<div>
|
||||
<p id="mobile-user-name-text" class="text-sm font-semibold text-gray-900"></p>
|
||||
<span id="mobile-admin-badge" class="hidden text-xs font-medium text-blue-600">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<a href="/dashboard" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 text-gray-900 bg-gray-50">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<span id="user-name" class="text-sm text-slate-700 font-medium"></span>
|
||||
|
||||
<a href="/events/new" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 text-gray-900 bg-gray-50">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Event
|
||||
</a>
|
||||
|
||||
<a href="/calendar" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 text-gray-900 bg-gray-50">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Calendar
|
||||
</a>
|
||||
|
||||
<a href="/scan" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 text-gray-900 bg-gray-50">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
QR Scanner
|
||||
</a>
|
||||
|
||||
<a href="/templates" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 text-gray-900 bg-gray-50">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
Page Templates
|
||||
</a>
|
||||
|
||||
<div id="mobile-admin-menu-item" class="hidden">
|
||||
<a href="/custom-pricing" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 text-gray-900 bg-gray-50">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
Custom Pricing
|
||||
</a>
|
||||
|
||||
<a href="/admin/dashboard" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 text-gray-900 bg-gray-50">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Admin Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-3 border-gray-200">
|
||||
<button
|
||||
id="logout-btn"
|
||||
class="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 hover:shadow-md"
|
||||
id="mobile-logout-btn"
|
||||
class="flex items-center w-full px-3 py-2 text-sm rounded-lg transition-colors hover:bg-red-50 text-red-600 bg-gray-50"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-3 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
@@ -63,9 +279,23 @@ const {
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Initialize navigation functionality
|
||||
const userNameSpan = document.getElementById('user-name');
|
||||
const userNameText = document.getElementById('user-name-text');
|
||||
const mobileUserNameText = document.getElementById('mobile-user-name-text');
|
||||
const userAvatar = document.getElementById('user-avatar');
|
||||
const mobileUserAvatar = document.getElementById('mobile-user-avatar');
|
||||
const dropdownAvatar = document.getElementById('dropdown-avatar');
|
||||
const dropdownName = document.getElementById('dropdown-name');
|
||||
const dropdownEmail = document.getElementById('dropdown-email');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const adminDashboardLink = document.getElementById('admin-dashboard-link');
|
||||
const mobileLogoutBtn = document.getElementById('mobile-logout-btn');
|
||||
const adminBadge = document.getElementById('admin-badge');
|
||||
const mobileAdminBadge = document.getElementById('mobile-admin-badge');
|
||||
const adminMenuItem = document.getElementById('admin-menu-item');
|
||||
const mobileAdminMenuItem = document.getElementById('mobile-admin-menu-item');
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const userMenuBtn = document.getElementById('user-menu-btn');
|
||||
const userDropdown = document.getElementById('user-dropdown');
|
||||
|
||||
// Check authentication and load user info
|
||||
async function initializeNavigation() {
|
||||
@@ -78,9 +308,22 @@ const {
|
||||
// Load user info
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
userNameSpan.textContent = user.user_metadata.name || user.email;
|
||||
const userName = user.user_metadata.name || user.email;
|
||||
const userEmail = user.email;
|
||||
|
||||
// Check if user is admin and show admin dashboard link
|
||||
// Update all name displays
|
||||
if (userNameText) userNameText.textContent = userName;
|
||||
if (mobileUserNameText) mobileUserNameText.textContent = userName;
|
||||
if (dropdownName) dropdownName.textContent = userName;
|
||||
if (dropdownEmail) dropdownEmail.textContent = userEmail;
|
||||
|
||||
// Generate user initials
|
||||
const initials = userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
if (userAvatar) userAvatar.textContent = initials;
|
||||
if (mobileUserAvatar) mobileUserAvatar.textContent = initials;
|
||||
if (dropdownAvatar) dropdownAvatar.textContent = initials;
|
||||
|
||||
// Check if user is admin and show admin badge/menu items
|
||||
const { data: userProfile } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
@@ -88,17 +331,82 @@ const {
|
||||
.single();
|
||||
|
||||
if (userProfile?.role === 'admin') {
|
||||
adminDashboardLink.classList.remove('hidden');
|
||||
if (adminBadge) adminBadge.classList.remove('hidden');
|
||||
if (mobileAdminBadge) mobileAdminBadge.classList.remove('hidden');
|
||||
if (adminMenuItem) adminMenuItem.classList.remove('hidden');
|
||||
if (mobileAdminMenuItem) mobileAdminMenuItem.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logout functionality
|
||||
logoutBtn?.addEventListener('click', async () => {
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
logoutBtn?.addEventListener('click', handleLogout);
|
||||
mobileLogoutBtn?.addEventListener('click', handleLogout);
|
||||
|
||||
// User dropdown toggle
|
||||
userMenuBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (userDropdown) {
|
||||
const isHidden = userDropdown.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
userDropdown.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
userDropdown.classList.remove('opacity-0', 'scale-95');
|
||||
userDropdown.classList.add('opacity-100', 'scale-100');
|
||||
}, 10);
|
||||
} else {
|
||||
userDropdown.classList.add('opacity-0', 'scale-95');
|
||||
userDropdown.classList.remove('opacity-100', 'scale-100');
|
||||
setTimeout(() => {
|
||||
userDropdown.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (userDropdown && !userDropdown.contains(e.target) && !userMenuBtn?.contains(e.target)) {
|
||||
userDropdown.classList.add('opacity-0', 'scale-95');
|
||||
userDropdown.classList.remove('opacity-100', 'scale-100');
|
||||
setTimeout(() => {
|
||||
userDropdown.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
mobileMenuBtn?.addEventListener('click', () => {
|
||||
if (mobileMenu) {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize when the page loads
|
||||
initializeNavigation();
|
||||
|
||||
// No need for theme change listeners since navbar is always white
|
||||
|
||||
// Scroll-shrink behavior
|
||||
const nav = document.getElementById('main-nav');
|
||||
const navContent = document.getElementById('nav-content');
|
||||
|
||||
function handleScroll() {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
if (scrollTop > 50) {
|
||||
nav?.classList.add('nav-shrunk');
|
||||
if (navContent) navContent.style.height = '60px';
|
||||
} else {
|
||||
nav?.classList.remove('nav-shrunk');
|
||||
if (navContent) navContent.style.height = '80px';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
</script>
|
||||
303
src/components/PageBuilder.tsx
Normal file
303
src/components/PageBuilder.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Editor, Frame, Element, useEditor } from '@craftjs/core';
|
||||
import {
|
||||
HeroSection,
|
||||
EventDetails,
|
||||
TicketSection,
|
||||
TextBlock,
|
||||
ImageBlock,
|
||||
ButtonBlock,
|
||||
SpacerBlock,
|
||||
TwoColumnLayout
|
||||
} from './craft/components';
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(_error: Error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('PageBuilder Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorWrapperProps {
|
||||
pageData: Record<string, unknown> | null;
|
||||
enabled: boolean;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
// Component Panel with proper Craft.js integration
|
||||
const ComponentPanel = () => {
|
||||
const { connectors } = useEditor();
|
||||
|
||||
const components = [
|
||||
{ name: 'Hero Section', component: HeroSection, icon: '🎭' },
|
||||
{ name: 'Event Details', component: EventDetails, icon: '📅' },
|
||||
{ name: 'Ticket Section', component: TicketSection, icon: '🎫' },
|
||||
{ name: 'Text Block', component: TextBlock, icon: '📝' },
|
||||
{ name: 'Image Block', component: ImageBlock, icon: '🖼️' },
|
||||
{ name: 'Button Block', component: ButtonBlock, icon: '🔘' },
|
||||
{ name: 'Spacer Block', component: SpacerBlock, icon: '↕️' },
|
||||
{ name: 'Two Column Layout', component: TwoColumnLayout, icon: '📱' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{components.map((comp) => (
|
||||
<div
|
||||
key={comp.name}
|
||||
ref={(ref) => connectors.create(ref, comp.component)}
|
||||
className="flex items-center p-3 bg-gray-50 rounded-lg border border-gray-200 cursor-move hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="text-lg mr-3">{comp.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-700">{comp.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EditorWrapper: React.FC<EditorWrapperProps> = ({ pageData, enabled, eventId }) => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="bg-white rounded-lg shadow-lg min-h-96 border border-gray-200 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<h3 className="text-lg font-medium mb-2">Editor Error</h3>
|
||||
<p className="text-sm mb-4">There was an issue loading the page editor.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
|
||||
>
|
||||
Reload Editor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
resolver={{
|
||||
HeroSection,
|
||||
EventDetails,
|
||||
TicketSection,
|
||||
TextBlock,
|
||||
ImageBlock,
|
||||
ButtonBlock,
|
||||
SpacerBlock,
|
||||
TwoColumnLayout
|
||||
}}
|
||||
enabled={enabled}
|
||||
onRender={(_render) => {
|
||||
// Custom render logic if needed
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* Component Panel */}
|
||||
<div className="w-64 bg-white shadow-lg border-r border-gray-200 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900">Components</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ComponentPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Editor */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg min-h-96 border border-gray-200">
|
||||
<Frame data={pageData && Object.keys(pageData).length > 0 ? pageData : undefined}>
|
||||
<Element
|
||||
is="div"
|
||||
canvas
|
||||
className="w-full min-h-96 p-4"
|
||||
custom={{
|
||||
eventId,
|
||||
// Event data will be passed from parent component
|
||||
}}
|
||||
>
|
||||
{/* Default content - always show for empty canvas */}
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<div className="text-4xl mb-4">🎨</div>
|
||||
<h3 className="text-lg font-medium mb-2">Start Building Your Page</h3>
|
||||
<p className="text-sm">Drag components from the left panel to get started</p>
|
||||
</div>
|
||||
</Element>
|
||||
</Frame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Editor>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageBuilderProps {
|
||||
eventId: string;
|
||||
templateId?: string;
|
||||
pageId?: string;
|
||||
onSave?: (pageData: Record<string, unknown>) => void;
|
||||
onPreview?: (pageData: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const PageBuilder: React.FC<PageBuilderProps> = ({
|
||||
eventId,
|
||||
templateId,
|
||||
pageId,
|
||||
onSave,
|
||||
onPreview
|
||||
}) => {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [pageData, setPageData] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (templateId || pageId) {
|
||||
loadPageData();
|
||||
}
|
||||
}, [templateId, pageId]);
|
||||
|
||||
const loadPageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const endpoint = templateId
|
||||
? `/api/templates/${templateId}`
|
||||
: `/api/custom-pages/${pageId}`;
|
||||
|
||||
// Get auth token for API calls
|
||||
const { supabase } = await import('../lib/supabase');
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (session?.access_token) {
|
||||
headers['Authorization'] = `Bearer ${session.access_token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, { headers });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Safely handle page data that might have invalid component references
|
||||
const pageData = data.pageData;
|
||||
if (pageData && typeof pageData === 'object' && Object.keys(pageData).length > 0) {
|
||||
console.log('Loading existing page data:', pageData);
|
||||
setPageData(pageData);
|
||||
} else {
|
||||
console.log('No page data found, starting with empty canvas');
|
||||
setPageData(null);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to load page data:', data);
|
||||
setPageData(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading page data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
|
||||
const editorState = (document.querySelector('[data-craftjs="editor"]') as HTMLElement & { __craft?: { actions?: { serialize(): Record<string, unknown> } } })?.__craft?.actions?.serialize();
|
||||
if (editorState) {
|
||||
await onSave(editorState);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
if (!onPreview) return;
|
||||
|
||||
const editorState = (document.querySelector('[data-craftjs="editor"]') as HTMLElement & { __craft?: { actions?: { serialize(): Record<string, unknown> } } })?.__craft?.actions?.serialize();
|
||||
if (editorState) {
|
||||
onPreview(editorState);
|
||||
}
|
||||
};
|
||||
|
||||
const _components = [
|
||||
{ name: 'Hero Section', component: HeroSection, icon: '🎭' },
|
||||
{ name: 'Event Details', component: EventDetails, icon: '📅' },
|
||||
{ name: 'Ticket Section', component: TicketSection, icon: '🎫' },
|
||||
{ name: 'Text Block', component: TextBlock, icon: '📝' },
|
||||
{ name: 'Image Block', component: ImageBlock, icon: '🖼️' },
|
||||
{ name: 'Button Block', component: ButtonBlock, icon: '🔘' },
|
||||
{ name: 'Spacer Block', component: SpacerBlock, icon: '↕️' },
|
||||
{ name: 'Two Column Layout', component: TwoColumnLayout, icon: '📱' }
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-100 flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white border-b border-gray-200 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">Edit Mode:</span>
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`px-3 py-1 text-xs rounded-full ${
|
||||
enabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{enabled ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<EditorWrapper
|
||||
pageData={pageData}
|
||||
enabled={enabled}
|
||||
eventId={eventId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBuilder;
|
||||
@@ -1,30 +1,5 @@
|
||||
---
|
||||
// Server-side auth check for protected routes
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// This is a basic server-side auth check
|
||||
// In production, you'd want more sophisticated session management
|
||||
const cookies = Astro.request.headers.get('cookie');
|
||||
let isAuthenticated = false;
|
||||
let userSession = null;
|
||||
|
||||
if (cookies) {
|
||||
// Try to extract auth token from cookies
|
||||
// This is a simplified check - in production you'd validate the token
|
||||
const authCookie = cookies.split(';')
|
||||
.find(c => c.trim().startsWith('sb-access-token=') || c.trim().startsWith('supabase-auth-token='));
|
||||
|
||||
if (authCookie) {
|
||||
isAuthenticated = true;
|
||||
// You would verify the token here in production
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated && Astro.url.pathname !== '/') {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
// Simple protected route component
|
||||
export interface Props {
|
||||
title?: string;
|
||||
requireAdmin?: boolean;
|
||||
@@ -33,51 +8,132 @@ export interface Props {
|
||||
const { title = "Protected Page", requireAdmin = false } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="auth-wrapper">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Client-side auth verification as backup
|
||||
async function verifyAuth() {
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
// State tracking to prevent loops
|
||||
let authVerificationInProgress = false;
|
||||
let redirectInProgress = false;
|
||||
|
||||
if (error || !session) {
|
||||
console.warn('Authentication verification failed');
|
||||
window.location.pathname = '/';
|
||||
console.log('[PROTECTED] ProtectedRoute mounted on:', window.location.pathname);
|
||||
|
||||
// Safe redirect with loop prevention
|
||||
function safeRedirectToLogin() {
|
||||
if (redirectInProgress) {
|
||||
console.log('[PROTECTED] Redirect already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store auth token for API calls
|
||||
const authToken = session.access_token;
|
||||
if (authToken) {
|
||||
// Set default authorization header for fetch requests
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options = {}) {
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
// Don't redirect if we're already on login page
|
||||
if (window.location.pathname === '/login') {
|
||||
console.log('[PROTECTED] Already on login page, not redirecting');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add auth header to API calls
|
||||
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||
options.headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
redirectInProgress = true;
|
||||
console.log('[PROTECTED] Redirecting to login...');
|
||||
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
setTimeout(() => {
|
||||
const returnTo = encodeURIComponent(window.location.pathname);
|
||||
window.location.href = `/login?returnTo=${returnTo}`;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Enhanced auth verification with timeout and retry logic
|
||||
async function verifyAuth() {
|
||||
if (authVerificationInProgress) {
|
||||
console.log('[PROTECTED] Auth verification already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
authVerificationInProgress = true;
|
||||
console.log('[PROTECTED] Starting auth verification...');
|
||||
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
const authPromise = supabase.auth.getSession();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Auth verification timeout')), 8000)
|
||||
);
|
||||
|
||||
const { data: { session }, error } = await Promise.race([authPromise, timeoutPromise]) as any;
|
||||
|
||||
if (error) {
|
||||
console.warn('[PROTECTED] Auth verification failed:', error.message);
|
||||
safeRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
console.warn('[PROTECTED] No session found, redirecting to login');
|
||||
safeRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PROTECTED] Auth verification successful');
|
||||
|
||||
// Store auth token for API calls
|
||||
const authToken = session.access_token;
|
||||
if (authToken) {
|
||||
// Set default authorization header for fetch requests
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options = {}) {
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
|
||||
// Add auth header and credentials to API calls
|
||||
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||
options.headers['Authorization'] = `Bearer ${authToken}`;
|
||||
options.credentials = 'include';
|
||||
}
|
||||
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
}
|
||||
|
||||
authVerificationInProgress = false;
|
||||
} catch (error) {
|
||||
console.error('[PROTECTED] Auth verification error:', error);
|
||||
authVerificationInProgress = false;
|
||||
safeRedirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// Verify authentication on page load
|
||||
verifyAuth();
|
||||
// Delayed auth verification to prevent race conditions
|
||||
setTimeout(() => {
|
||||
verifyAuth();
|
||||
}, 200);
|
||||
|
||||
// Listen for auth state changes
|
||||
// Listen for auth state changes with debouncing
|
||||
let authChangeTimeout: number | null = null;
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_OUT' || !session) {
|
||||
window.location.pathname = '/';
|
||||
console.log('[PROTECTED] Auth state change:', event);
|
||||
|
||||
// Clear previous timeout
|
||||
if (authChangeTimeout) {
|
||||
clearTimeout(authChangeTimeout);
|
||||
}
|
||||
|
||||
// Debounce auth state changes
|
||||
authChangeTimeout = setTimeout(() => {
|
||||
if (event === 'SIGNED_OUT' || !session) {
|
||||
console.log('[PROTECTED] User signed out, redirecting to login');
|
||||
safeRedirectToLogin();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.auth-wrapper {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Add loading state styles */
|
||||
.auth-loading {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -7,38 +7,36 @@ export interface Props {
|
||||
const { showCalendarNav = false } = Astro.props;
|
||||
---
|
||||
|
||||
<header class="absolute top-0 left-0 right-0 z-10 bg-transparent">
|
||||
<header class="absolute top-0 left-0 right-0 z-10 backdrop-blur-xl" style="background: var(--glass-bg); border-bottom: 1px solid var(--glass-border);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<!-- Logo and Branding -->
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/" class="flex items-center space-x-2">
|
||||
<img src="/images/logo.png" alt="Black Canyon Tickets" class="h-8 drop-shadow-lg" style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));" />
|
||||
<span class="text-xl font-light text-white">
|
||||
<span class="text-xl font-light" style="color: var(--glass-text-primary);">
|
||||
<span class="font-bold">Black Canyon</span> Tickets
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Clean Navigation -->
|
||||
{showCalendarNav && (
|
||||
<nav class="hidden md:flex items-center space-x-1">
|
||||
<div class="flex items-center space-x-1 bg-slate-50 rounded-xl p-1">
|
||||
<a href="/calendar" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
All Events
|
||||
</a>
|
||||
<a href="/calendar?featured=true" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Featured
|
||||
</a>
|
||||
<a href="/calendar?category=music" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Music
|
||||
</a>
|
||||
<a href="/calendar?category=arts" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Arts
|
||||
</a>
|
||||
<a href="/calendar?category=community" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Community
|
||||
</a>
|
||||
</div>
|
||||
<nav class="hidden md:flex items-center space-x-6">
|
||||
<a href="/calendar" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-primary);">
|
||||
All Events
|
||||
</a>
|
||||
<a href="/calendar?featured=true" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||
Featured
|
||||
</a>
|
||||
<a href="/calendar?category=music" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||
Music
|
||||
</a>
|
||||
<a href="/calendar?category=arts" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||
Arts
|
||||
</a>
|
||||
<a href="/calendar?category=community" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||
Community
|
||||
</a>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
@@ -48,7 +46,8 @@ const { showCalendarNav = false } = Astro.props;
|
||||
<!-- Mobile menu button -->
|
||||
{showCalendarNav && (
|
||||
<button
|
||||
class="md:hidden p-2 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
class="md:hidden p-2 rounded-md transition-all duration-200"
|
||||
style="color: var(--glass-text-secondary); background: var(--glass-bg-button);"
|
||||
onclick="toggleMobileMenu()"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -58,10 +57,10 @@ const { showCalendarNav = false } = Astro.props;
|
||||
)}
|
||||
|
||||
<!-- Clean Action buttons -->
|
||||
<a href="/login" class="text-white/80 hover:text-white text-sm font-medium transition-colors duration-200">
|
||||
<a href="/login" class="text-sm font-medium transition-colors duration-200" style="color: var(--glass-text-secondary);">
|
||||
Login
|
||||
</a>
|
||||
<a href="/login" class="bg-white/10 backdrop-blur-lg hover:bg-white/20 text-white px-6 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 border border-white/20">
|
||||
<a href="/login" class="backdrop-blur-lg px-6 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200" style="background: var(--glass-bg-button); color: var(--glass-text-primary); border: 1px solid var(--glass-border);">
|
||||
Create Events
|
||||
</a>
|
||||
</div>
|
||||
@@ -69,28 +68,28 @@ const { showCalendarNav = false } = Astro.props;
|
||||
|
||||
<!-- Clean Mobile Navigation -->
|
||||
{showCalendarNav && (
|
||||
<div id="mobile-menu" class="hidden md:hidden border-t border-slate-200 py-4">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<a href="/calendar" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
<div id="mobile-menu" class="hidden md:hidden py-4" style="border-top: 1px solid var(--glass-border);">
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<a href="/calendar" class="px-4 py-3 font-medium transition-all duration-200" style="color: var(--glass-text-primary);">
|
||||
All Events
|
||||
</a>
|
||||
<a href="/calendar?featured=true" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
<a href="/calendar?featured=true" class="px-4 py-3 font-medium transition-all duration-200" style="color: var(--glass-text-secondary);">
|
||||
Featured Events
|
||||
</a>
|
||||
<a href="/calendar?category=music" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
<a href="/calendar?category=music" class="px-4 py-3 font-medium transition-all duration-200" style="color: var(--glass-text-secondary);">
|
||||
Music
|
||||
</a>
|
||||
<a href="/calendar?category=arts" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
<a href="/calendar?category=arts" class="px-4 py-3 font-medium transition-all duration-200" style="color: var(--glass-text-secondary);">
|
||||
Arts
|
||||
</a>
|
||||
<a href="/calendar?category=community" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
<a href="/calendar?category=community" class="px-4 py-3 font-medium transition-all duration-200" style="color: var(--glass-text-secondary);">
|
||||
Community
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Login -->
|
||||
<div class="mt-4 pt-4 border-t border-slate-200">
|
||||
<a href="/login" class="block text-center px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
<div class="mt-4 pt-4" style="border-top: 1px solid var(--glass-border);">
|
||||
<a href="/login" class="block text-center px-4 py-3 font-medium transition-all duration-200" style="color: var(--glass-text-secondary);">
|
||||
Organizer Login
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -6,57 +6,113 @@ interface Props {
|
||||
const { eventId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Tickets Sold Card -->
|
||||
<div class="rounded-2xl p-6 shadow-lg premium-hover cursor-pointer group ring-1 transition-all duration-200 hover:shadow-xl hover:ring-2"
|
||||
style="background: var(--success-bg); border: 1px solid var(--success-border);">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Tickets Sold</p>
|
||||
<p id="tickets-sold" class="text-3xl font-light text-white mt-1">0</p>
|
||||
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--success-color);">Tickets Sold</p>
|
||||
<div class="flex items-center mt-2">
|
||||
<p id="tickets-sold" class="text-3xl font-light animate-countUp" style="color: var(--success-color);">0</p>
|
||||
<div id="tickets-sold-loading" class="skeleton w-16 h-8 ml-2 hidden"></div>
|
||||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<span id="tickets-sold-trend" class="text-xs flex items-center" style="color: var(--success-color); opacity: 0.8;">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
+12% from last week
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-emerald-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
style="background: var(--success-bg); backdrop-filter: blur(8px);">
|
||||
<svg class="w-7 h-7" style="color: var(--success-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200">
|
||||
<!-- Available Tickets Card -->
|
||||
<div class="rounded-2xl p-6 shadow-lg premium-hover cursor-pointer group ring-1 transition-all duration-200 hover:shadow-xl hover:ring-2"
|
||||
style="background: var(--glass-bg-lg); border: 1px solid var(--glass-border);">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Available</p>
|
||||
<p id="tickets-available" class="text-3xl font-light text-white mt-1">--</p>
|
||||
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--glass-text-secondary);">Available</p>
|
||||
<div class="flex items-center mt-2">
|
||||
<p id="tickets-available" class="text-3xl font-light animate-countUp" style="color: var(--glass-text-primary);">--</p>
|
||||
<div id="tickets-available-loading" class="skeleton w-16 h-8 ml-2 hidden"></div>
|
||||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<span id="tickets-available-trend" class="text-xs flex items-center" style="color: var(--glass-text-tertiary);">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
Ready to sell
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
style="background: var(--glass-bg-elevated); backdrop-filter: blur(8px);">
|
||||
<svg class="w-7 h-7" style="color: var(--glass-text-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200">
|
||||
<!-- Check-ins Card -->
|
||||
<div class="rounded-2xl p-6 shadow-lg premium-hover cursor-pointer group ring-1 transition-all duration-200 hover:shadow-xl hover:ring-2"
|
||||
style="background: var(--warning-bg); border: 1px solid var(--warning-border);">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Check-ins</p>
|
||||
<p id="checked-in" class="text-3xl font-light text-white mt-1">0</p>
|
||||
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--warning-color);">Check-ins</p>
|
||||
<div class="flex items-center mt-2">
|
||||
<p id="checked-in" class="text-3xl font-light animate-countUp" style="color: var(--warning-color);">0</p>
|
||||
<div id="checked-in-loading" class="skeleton w-16 h-8 ml-2 hidden"></div>
|
||||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<span id="checked-in-trend" class="text-xs flex items-center" style="color: var(--warning-color); opacity: 0.8;">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
85% attendance rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
style="background: var(--warning-bg); backdrop-filter: blur(8px);">
|
||||
<svg class="w-7 h-7" style="color: var(--warning-color);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200">
|
||||
<!-- Net Revenue Card -->
|
||||
<div class="rounded-2xl p-6 shadow-lg premium-hover cursor-pointer group ring-1 transition-all duration-200 hover:shadow-xl hover:ring-2"
|
||||
style="background: var(--glass-bg-button); border: 1px solid var(--glass-border);">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Net Revenue</p>
|
||||
<p id="net-revenue" class="text-3xl font-light text-white mt-1">$0</p>
|
||||
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--glass-text-accent);">Net Revenue</p>
|
||||
<div class="flex items-center mt-2">
|
||||
<p id="net-revenue" class="text-3xl font-light animate-countUp" style="color: var(--glass-text-accent);">$0</p>
|
||||
<div id="net-revenue-loading" class="skeleton w-20 h-8 ml-2 hidden"></div>
|
||||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<span id="net-revenue-trend" class="text-xs flex items-center" style="color: var(--glass-text-accent); opacity: 0.8;">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
+24% this month
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-amber-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
style="background: var(--glass-bg-elevated); backdrop-filter: blur(8px);">
|
||||
<svg class="w-7 h-7" style="color: var(--glass-text-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -70,55 +126,114 @@ const { eventId } = Astro.props;
|
||||
await loadQuickStats();
|
||||
});
|
||||
|
||||
// Count-up animation function
|
||||
function animateCountUp(element, finalValue, duration = 1000, isRevenue = false) {
|
||||
const startValue = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
function updateCount() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Use easing function for smooth animation
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const currentValue = Math.floor(startValue + (finalValue - startValue) * easeOutQuart);
|
||||
|
||||
if (isRevenue) {
|
||||
element.textContent = formatCurrency(currentValue);
|
||||
} else {
|
||||
element.textContent = currentValue.toString();
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCount);
|
||||
} else {
|
||||
element.textContent = isRevenue ? formatCurrency(finalValue) : finalValue.toString();
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCount);
|
||||
}
|
||||
|
||||
// Simple currency formatter
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Show skeleton loading
|
||||
function showSkeleton(statType) {
|
||||
const loadingElement = document.getElementById(`${statType}-loading`);
|
||||
const valueElement = document.getElementById(statType);
|
||||
|
||||
if (loadingElement && valueElement) {
|
||||
valueElement.classList.add('hidden');
|
||||
loadingElement.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Hide skeleton loading
|
||||
function hideSkeleton(statType) {
|
||||
const loadingElement = document.getElementById(`${statType}-loading`);
|
||||
const valueElement = document.getElementById(statType);
|
||||
|
||||
if (loadingElement && valueElement) {
|
||||
loadingElement.classList.add('hidden');
|
||||
valueElement.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQuickStats() {
|
||||
try {
|
||||
const { createClient } = await import('@supabase/supabase-js');
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
// Load ticket sales data
|
||||
const { data: tickets } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
id,
|
||||
price_paid,
|
||||
checked_in,
|
||||
ticket_types (
|
||||
id,
|
||||
quantity
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
.eq('status', 'confirmed');
|
||||
// Show skeleton loading states
|
||||
showSkeleton('tickets-sold');
|
||||
showSkeleton('tickets-available');
|
||||
showSkeleton('checked-in');
|
||||
showSkeleton('net-revenue');
|
||||
|
||||
// Load ticket types for capacity calculation
|
||||
const { data: ticketTypes } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('id, quantity')
|
||||
.eq('event_id', eventId)
|
||||
.eq('is_active', true);
|
||||
const { api } = await import('/src/lib/api-router.js');
|
||||
|
||||
// Calculate stats
|
||||
const ticketsSold = tickets?.length || 0;
|
||||
const totalRevenue = tickets?.reduce((sum, ticket) => sum + ticket.price_paid, 0) || 0;
|
||||
const netRevenue = totalRevenue * 0.97; // Assuming 3% platform fee
|
||||
const checkedIn = tickets?.filter(ticket => ticket.checked_in).length || 0;
|
||||
const totalCapacity = ticketTypes?.reduce((sum, type) => sum + type.quantity, 0) || 0;
|
||||
const ticketsAvailable = totalCapacity - ticketsSold;
|
||||
// Load event statistics using the new API system
|
||||
const stats = await api.loadEventStats(eventId);
|
||||
|
||||
// Update UI
|
||||
document.getElementById('tickets-sold').textContent = ticketsSold.toString();
|
||||
document.getElementById('tickets-available').textContent = ticketsAvailable.toString();
|
||||
document.getElementById('checked-in').textContent = checkedIn.toString();
|
||||
document.getElementById('net-revenue').textContent = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(netRevenue / 100);
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide skeleton loading and animate values
|
||||
setTimeout(() => {
|
||||
hideSkeleton('tickets-sold');
|
||||
animateCountUp(document.getElementById('tickets-sold'), stats.ticketsSold, 1200);
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
hideSkeleton('tickets-available');
|
||||
animateCountUp(document.getElementById('tickets-available'), stats.ticketsAvailable, 1000);
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => {
|
||||
hideSkeleton('checked-in');
|
||||
animateCountUp(document.getElementById('checked-in'), stats.checkedIn, 1400);
|
||||
}, 700);
|
||||
|
||||
setTimeout(() => {
|
||||
hideSkeleton('net-revenue');
|
||||
// Convert cents to dollars for display
|
||||
const revenueInDollars = Math.round(stats.netRevenue / 100);
|
||||
animateCountUp(document.getElementById('net-revenue'), revenueInDollars, 1600, true);
|
||||
}, 900);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading quick stats:', error);
|
||||
// Hide all skeleton states on error
|
||||
hideSkeleton('tickets-sold');
|
||||
hideSkeleton('tickets-available');
|
||||
hideSkeleton('checked-in');
|
||||
hideSkeleton('net-revenue');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -66,8 +66,8 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
if (activeTicketTypes.length > 0) {
|
||||
setSelectedTicketType(activeTicketTypes[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading ticket types:', err);
|
||||
} catch (_err) {
|
||||
console.error('Failed to load ticket options:', _err);
|
||||
setError('Failed to load ticket options');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -109,14 +109,17 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
const availableQuantity = selectedTicket ? getAvailableQuantity(selectedTicket) : 0;
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 ${className}`}>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-screen overflow-y-auto">
|
||||
<div className={`fixed inset-0 flex items-center justify-center p-4 z-50 backdrop-blur-sm ${className}`} style={{background: 'var(--ui-bg-overlay)'}}>
|
||||
<div className="rounded-lg shadow-xl max-w-md w-full max-h-screen overflow-y-auto backdrop-blur-xl" style={{background: 'var(--ui-bg-elevated)', border: '1px solid var(--ui-border-primary)'}}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Quick Purchase</h2>
|
||||
<div className="flex items-center justify-between p-6" style={{borderBottom: '1px solid var(--ui-border-secondary)'}}>
|
||||
<h2 className="text-xl font-semibold" style={{color: 'var(--ui-text-primary)'}}>Quick Purchase</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="transition-colors"
|
||||
style={{color: 'var(--ui-text-muted)'}}
|
||||
onMouseEnter={(e) => e.target.style.color = 'var(--ui-text-secondary)'}
|
||||
onMouseLeave={(e) => e.target.style.color = 'var(--ui-text-muted)'}
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -125,9 +128,9 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Event Info */}
|
||||
<div className="p-6 border-b bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">{event.title}</h3>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div className="p-6 backdrop-blur-lg" style={{borderBottom: '1px solid var(--ui-border-secondary)', background: 'var(--ui-bg-secondary)'}}>
|
||||
<h3 className="text-lg font-medium mb-2" style={{color: 'var(--ui-text-primary)'}}>{event.title}</h3>
|
||||
<div className="space-y-1 text-sm" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
@@ -148,18 +151,18 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
<div className="p-6">
|
||||
{isLoading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-600">Loading ticket options...</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 mx-auto" style={{borderColor: 'var(--glass-text-accent)'}}></div>
|
||||
<p className="mt-2" style={{color: 'var(--ui-text-secondary)'}}>Loading ticket options...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div className="rounded-lg p-4 mb-4 backdrop-blur-lg" style={{background: 'var(--error-bg)', border: '1px solid var(--error-border)'}}>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-5 w-5 text-red-600 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-5 w-5 mr-2" style={{color: 'var(--error-color)'}} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span className="text-red-800">{error}</span>
|
||||
<span style={{color: 'var(--error-color)'}}>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -168,11 +171,11 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
<>
|
||||
{ticketTypes.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="mx-auto h-12 w-12" style={{color: 'var(--ui-text-muted)'}} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No tickets available</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<h3 className="mt-2 text-sm font-medium" style={{color: 'var(--ui-text-primary)'}}>No tickets available</h3>
|
||||
<p className="mt-1 text-sm" style={{color: 'var(--ui-text-tertiary)'}}>
|
||||
This event is currently sold out or tickets are not yet on sale.
|
||||
</p>
|
||||
</div>
|
||||
@@ -180,7 +183,7 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
<>
|
||||
{/* Ticket Type Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
<label className="block text-sm font-medium mb-3" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
Select Ticket Type
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
@@ -191,13 +194,31 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={ticketType.id}
|
||||
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
|
||||
selectedTicketType === ticketType.id
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
className="border rounded-lg p-3 cursor-pointer transition-colors backdrop-blur-lg"
|
||||
style={{
|
||||
borderColor: selectedTicketType === ticketType.id
|
||||
? 'var(--glass-border-focus)'
|
||||
: isUnavailable
|
||||
? 'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
? 'var(--ui-border-secondary)'
|
||||
: 'var(--ui-border-primary)',
|
||||
background: selectedTicketType === ticketType.id
|
||||
? 'var(--glass-bg-lg)'
|
||||
: isUnavailable
|
||||
? 'var(--ui-bg-secondary)'
|
||||
: 'var(--ui-bg-elevated)',
|
||||
opacity: isUnavailable ? 0.5 : 1,
|
||||
cursor: isUnavailable ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isUnavailable && selectedTicketType !== ticketType.id) {
|
||||
e.target.style.borderColor = 'var(--glass-border-focus)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isUnavailable && selectedTicketType !== ticketType.id) {
|
||||
e.target.style.borderColor = 'var(--ui-border-primary)';
|
||||
}
|
||||
}}
|
||||
onClick={() => !isUnavailable && setSelectedTicketType(ticketType.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -210,17 +231,17 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
className="mr-3"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{ticketType.name}</div>
|
||||
<div className="font-medium" style={{color: 'var(--ui-text-primary)'}}>{ticketType.name}</div>
|
||||
{ticketType.description && (
|
||||
<div className="text-sm text-gray-600">{ticketType.description}</div>
|
||||
<div className="text-sm" style={{color: 'var(--ui-text-secondary)'}}>{ticketType.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-gray-900">
|
||||
<div className="font-semibold" style={{color: 'var(--ui-text-primary)'}}>
|
||||
{formatPrice(ticketType.price)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm" style={{color: 'var(--ui-text-tertiary)'}}>
|
||||
{isUnavailable ? 'Sold Out' :
|
||||
available < 10 ? `${available} left` : 'Available'}
|
||||
</div>
|
||||
@@ -235,31 +256,59 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
{/* Quantity Selection */}
|
||||
{selectedTicket && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium mb-2" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
disabled={quantity <= 1}
|
||||
className="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-8 h-8 rounded-full border flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed transition-colors backdrop-blur-lg"
|
||||
style={{
|
||||
borderColor: 'var(--ui-border-primary)',
|
||||
background: 'var(--ui-bg-elevated)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!e.target.disabled) {
|
||||
e.target.style.background = 'var(--ui-bg-secondary)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!e.target.disabled) {
|
||||
e.target.style.background = 'var(--ui-bg-elevated)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="w-8 text-center font-medium">{quantity}</span>
|
||||
<span className="w-8 text-center font-medium" style={{color: 'var(--ui-text-primary)'}}>{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(Math.min(availableQuantity, quantity + 1))}
|
||||
disabled={quantity >= availableQuantity}
|
||||
className="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-8 h-8 rounded-full border flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed transition-colors backdrop-blur-lg"
|
||||
style={{
|
||||
borderColor: 'var(--ui-border-primary)',
|
||||
background: 'var(--ui-bg-elevated)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!e.target.disabled) {
|
||||
e.target.style.background = 'var(--ui-bg-secondary)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!e.target.disabled) {
|
||||
e.target.style.background = 'var(--ui-bg-elevated)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm mt-1" style={{color: 'var(--ui-text-tertiary)'}}>
|
||||
Max {availableQuantity} tickets available
|
||||
</p>
|
||||
</div>
|
||||
@@ -267,14 +316,14 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
|
||||
{/* Total */}
|
||||
{selectedTicket && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="mb-6 p-4 rounded-lg backdrop-blur-lg" style={{background: 'var(--ui-bg-secondary)'}}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium text-gray-900">Total</span>
|
||||
<span className="text-xl font-bold text-gray-900">
|
||||
<span className="font-medium" style={{color: 'var(--ui-text-primary)'}}>Total</span>
|
||||
<span className="text-xl font-bold" style={{color: 'var(--ui-text-primary)'}}>
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<div className="text-sm mt-1" style={{color: 'var(--ui-text-tertiary)'}}>
|
||||
{quantity} × {selectedTicket.name} @ {formatPrice(selectedTicket.price)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,17 +336,38 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
||||
|
||||
{/* Footer */}
|
||||
{!isLoading && !error && ticketTypes.length > 0 && (
|
||||
<div className="px-6 py-4 border-t bg-gray-50 flex space-x-3">
|
||||
<div className="px-6 py-4 flex space-x-3 backdrop-blur-lg" style={{borderTop: '1px solid var(--ui-border-secondary)', background: 'var(--ui-bg-secondary)'}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
className="flex-1 px-4 py-2 border rounded-md text-sm font-medium transition-colors backdrop-blur-lg"
|
||||
style={{
|
||||
borderColor: 'var(--ui-border-primary)',
|
||||
color: 'var(--ui-text-secondary)',
|
||||
background: 'var(--ui-bg-elevated)'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = 'var(--ui-bg-secondary)'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'var(--ui-bg-elevated)'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
disabled={!selectedTicketType || availableQuantity <= 0}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="flex-1 px-4 py-2 rounded-md text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, var(--glass-text-accent), var(--premium-gold))',
|
||||
color: 'var(--glass-text-primary)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!e.target.disabled) {
|
||||
e.target.style.background = 'linear-gradient(to right, var(--glass-border-focus), var(--premium-gold-border))'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!e.target.disabled) {
|
||||
e.target.style.background = 'linear-gradient(to right, var(--glass-text-accent), var(--premium-gold))'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue to Checkout
|
||||
</button>
|
||||
|
||||
373
src/components/StripeEmbeddedOnboarding.tsx
Normal file
373
src/components/StripeEmbeddedOnboarding.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { loadConnectAndInitialize } from '@stripe/connect-js';
|
||||
|
||||
interface StripeEmbeddedOnboardingProps {
|
||||
accountId: string;
|
||||
clientSecret: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface OnboardingStep {
|
||||
id: number;
|
||||
title: string;
|
||||
icon: string;
|
||||
status: 'completed' | 'current' | 'pending';
|
||||
description: string;
|
||||
securityNote: string;
|
||||
estimatedTime: string;
|
||||
}
|
||||
|
||||
const onboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Business Information",
|
||||
icon: "🏢",
|
||||
status: "current",
|
||||
description: "Basic details about your organization",
|
||||
securityNote: "All information is encrypted in transit and at rest",
|
||||
estimatedTime: "2-3 minutes"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Identity Verification",
|
||||
icon: "🔐",
|
||||
status: "pending",
|
||||
description: "Secure verification required by financial regulations",
|
||||
securityNote: "Documents are processed by Stripe's secure systems",
|
||||
estimatedTime: "5-10 minutes"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Bank Account Setup",
|
||||
icon: "🏦",
|
||||
status: "pending",
|
||||
description: "Connect your bank account for automated payouts",
|
||||
securityNote: "Bank details are never stored on our servers",
|
||||
estimatedTime: "2-3 minutes"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Final Review",
|
||||
icon: "✅",
|
||||
status: "pending",
|
||||
description: "Review and complete your secure account setup",
|
||||
securityNote: "Your account will be activated immediately",
|
||||
estimatedTime: "1-2 minutes"
|
||||
}
|
||||
];
|
||||
|
||||
const StripeEmbeddedOnboarding: React.FC<StripeEmbeddedOnboardingProps> = ({
|
||||
accountId,
|
||||
clientSecret,
|
||||
onComplete,
|
||||
onError
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_currentStep, setCurrentStep] = useState(1);
|
||||
const [steps, setSteps] = useState(onboardingSteps);
|
||||
const stripeConnectInstance = useRef<typeof loadConnectAndInitialize | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeStripe = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Initialize Stripe Connect
|
||||
const publishableKey = import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!publishableKey) {
|
||||
throw new Error('Stripe publishable key not configured');
|
||||
}
|
||||
|
||||
stripeConnectInstance.current = loadConnectAndInitialize({
|
||||
publishableKey,
|
||||
clientSecret,
|
||||
appearance: {
|
||||
variables: {
|
||||
colorPrimary: '#1f2937',
|
||||
colorBackground: '#ffffff',
|
||||
colorText: '#1f2937',
|
||||
colorDanger: '#ef4444',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '8px',
|
||||
fontSizeBase: '14px',
|
||||
fontWeightNormal: '400',
|
||||
fontWeightBold: '600'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create and mount the onboarding component
|
||||
const component = stripeConnectInstance.current.create('account-onboarding');
|
||||
|
||||
// Set up event listeners before mounting
|
||||
component.setOnExit((e: { reason: string }) => {
|
||||
|
||||
if (e.reason === 'account_onboarding_completed') {
|
||||
updateStepStatus(4, 'completed');
|
||||
onComplete?.();
|
||||
} else if (e.reason === 'account_onboarding_closed') {
|
||||
// User closed the onboarding flow
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
component.setOnLoadError((_e: unknown) => {
|
||||
onError?.(new Error('Failed to load onboarding component'));
|
||||
});
|
||||
|
||||
// Mount the component
|
||||
if (containerRef.current) {
|
||||
containerRef.current.appendChild(component);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
// Stripe initialization error
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize Stripe Connect';
|
||||
setError(errorMessage);
|
||||
onError?.(error as Error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (accountId && clientSecret) {
|
||||
initializeStripe();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup if needed
|
||||
if (stripeConnectInstance.current) {
|
||||
stripeConnectInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, [accountId, clientSecret, onComplete, onError]);
|
||||
|
||||
const updateStepStatus = (stepId: number, status: 'completed' | 'current' | 'pending') => {
|
||||
setSteps(prev => prev.map(step =>
|
||||
step.id === stepId ? { ...step, status } : step
|
||||
));
|
||||
setCurrentStep(stepId);
|
||||
};
|
||||
|
||||
const getStepStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 border-green-500 text-green-700';
|
||||
case 'current':
|
||||
return 'bg-blue-100 border-blue-500 text-blue-700';
|
||||
case 'pending':
|
||||
return 'bg-gray-100 border-gray-300 text-gray-500';
|
||||
default:
|
||||
return 'bg-gray-100 border-gray-300 text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'current':
|
||||
return (
|
||||
<div className="w-5 h-5 border-2 border-blue-600 rounded-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="w-5 h-5 border-2 border-gray-300 rounded-full"></div>;
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Setup Error</h2>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md font-medium transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<a href="/dashboard" className="block w-full bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-md font-medium transition-colors">
|
||||
Return to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Initializing Secure Setup</h2>
|
||||
<p className="text-gray-600">Preparing your encrypted onboarding experience...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Security Header */}
|
||||
<header className="bg-gray-900 border-b border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">BCT</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white">Secure Account Setup</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-400">
|
||||
<span className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
256-bit SSL
|
||||
</span>
|
||||
<span className="text-gray-500">•</span>
|
||||
<span>Powered by Stripe</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Account Setup Progress</h2>
|
||||
<p className="text-sm text-gray-600">Complete each step to activate your payment processing account</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{steps.map((step, _index) => (
|
||||
<div key={step.id} className={`flex items-start p-4 rounded-lg border-2 ${getStepStatusColor(step.status)}`}>
|
||||
<div className="flex-shrink-0 mr-4">
|
||||
{getStepIcon(step.status)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="font-medium">{step.title}</h3>
|
||||
<span className="text-xs font-medium">{step.estimatedTime}</span>
|
||||
</div>
|
||||
<p className="text-sm mb-2">{step.description}</p>
|
||||
<div className="flex items-center text-xs">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{step.securityNote}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Onboarding Container */}
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-center space-x-2 text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-green-600 font-medium">Secure Connection</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>Stripe Connect</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>PCI DSS Compliant</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Payment Account Setup</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Complete your secure payment processing setup to start accepting payments.
|
||||
All information is encrypted and processed by Stripe's bank-level security infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="mb-8 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center justify-center space-x-8 text-sm">
|
||||
<div className="flex items-center text-blue-700">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Bank-level Security
|
||||
</div>
|
||||
<div className="flex items-center text-blue-700">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
PCI DSS Level 1
|
||||
</div>
|
||||
<div className="flex items-center text-blue-700">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Trusted by Millions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe Embedded Component Container */}
|
||||
<div className="stripe-onboarding-container">
|
||||
<div ref={containerRef} className="min-h-[400px]" />
|
||||
</div>
|
||||
|
||||
{/* Security Footer */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p className="mb-2">
|
||||
🔒 Your data is protected by industry-leading security measures
|
||||
</p>
|
||||
<p>
|
||||
Questions? Contact our support team at{' '}
|
||||
<a href="mailto:support@blackcanyontickets.com" className="text-blue-600 hover:text-blue-800">
|
||||
support@blackcanyontickets.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeEmbeddedOnboarding;
|
||||
268
src/components/SuperAdminDashboard.tsx
Normal file
268
src/components/SuperAdminDashboard.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import SuperAdminTabNavigation from './admin/SuperAdminTabNavigation';
|
||||
import AnalyticsTab from './admin/AnalyticsTab';
|
||||
import RevenueTab from './admin/RevenueTab';
|
||||
import EventsTab from './admin/EventsTab';
|
||||
import OrganizersTab from './admin/OrganizersTab';
|
||||
import ManagementTab from './admin/ManagementTab';
|
||||
import { makeAuthenticatedRequest } from '../lib/api-client';
|
||||
|
||||
interface SuperAdminDashboardProps {
|
||||
// No props needed for now - keeping interface for future expansion
|
||||
readonly _placeholder?: never;
|
||||
}
|
||||
|
||||
interface PlatformMetrics {
|
||||
totalRevenue: number;
|
||||
totalFees: number;
|
||||
activeOrganizers: number;
|
||||
totalTickets: number;
|
||||
revenueChange: number;
|
||||
feesChange: number;
|
||||
organizersChange: number;
|
||||
ticketsChange: number;
|
||||
}
|
||||
|
||||
export default function SuperAdminDashboard(_props: SuperAdminDashboardProps) {
|
||||
const [activeTab, setActiveTab] = useState('analytics');
|
||||
const [platformMetrics, setPlatformMetrics] = useState<PlatformMetrics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<{ id: string; email: string } | null>(null);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'analytics',
|
||||
name: 'Analytics',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2z" />
|
||||
</svg>
|
||||
),
|
||||
component: AnalyticsTab
|
||||
},
|
||||
{
|
||||
id: 'revenue',
|
||||
name: 'Revenue & Performance',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
),
|
||||
component: RevenueTab
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
name: 'Events & Tickets',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
),
|
||||
component: EventsTab
|
||||
},
|
||||
{
|
||||
id: 'organizers',
|
||||
name: 'Organizers',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
component: OrganizersTab
|
||||
},
|
||||
{
|
||||
id: 'management',
|
||||
name: 'Management',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
component: ManagementTab
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
initializeComponent();
|
||||
}, []);
|
||||
|
||||
const initializeComponent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check super admin authentication
|
||||
const authResult = await checkSuperAdminAuth();
|
||||
if (!authResult) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(authResult.user);
|
||||
|
||||
// Load platform metrics
|
||||
await loadPlatformMetrics();
|
||||
} catch (err) {
|
||||
console.error('SuperAdminDashboard initialization error:', err);
|
||||
setError('Failed to load dashboard data. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkSuperAdminAuth = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/check-super-admin');
|
||||
if (result.success && result.data.isSuperAdmin) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Super admin auth check error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlatformMetrics = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=platform_overview');
|
||||
|
||||
if (result.success) {
|
||||
const data = result.data.summary;
|
||||
setPlatformMetrics({
|
||||
totalRevenue: data.totalRevenue || 0,
|
||||
totalFees: data.totalPlatformFees || 0,
|
||||
activeOrganizers: data.activeOrganizers || 0,
|
||||
totalTickets: data.totalTickets || 0,
|
||||
revenueChange: data.revenueGrowth || 0,
|
||||
feesChange: data.feesGrowth || 0,
|
||||
organizersChange: data.organizersThisMonth || 0,
|
||||
ticketsChange: data.ticketsThisMonth || 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Platform metrics loading error:', error);
|
||||
// Silently handle error - metrics are non-critical
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent mx-auto mb-4"></div>
|
||||
<p className="text-white/80">Loading super admin dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-red-500/20 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-red-400 font-medium mb-2">Error Loading Dashboard</p>
|
||||
<p className="text-white/70 text-sm mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-4xl md:text-5xl font-light text-white tracking-wide">Business Intelligence</h2>
|
||||
<p className="text-white/80 mt-2 text-lg font-light">Platform-wide analytics and performance insights</p>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics Dashboard */}
|
||||
{platformMetrics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-white/80">Platform Revenue</p>
|
||||
<p className="text-2xl font-light text-white">${platformMetrics.totalRevenue.toLocaleString()}</p>
|
||||
<p className="text-sm text-green-400">+{platformMetrics.revenueChange}% vs last month</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-white/80">Platform Fees</p>
|
||||
<p className="text-2xl font-light text-white">${platformMetrics.totalFees.toLocaleString()}</p>
|
||||
<p className="text-sm text-purple-400">+{platformMetrics.feesChange}% vs last month</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-white/80">Active Organizers</p>
|
||||
<p className="text-2xl font-light text-white">{platformMetrics.activeOrganizers}</p>
|
||||
<p className="text-sm text-blue-400">+{platformMetrics.organizersChange} this month</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-yellow-500/20 to-orange-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-white/80">Tickets Sold</p>
|
||||
<p className="text-2xl font-light text-white">{platformMetrics.totalTickets}</p>
|
||||
<p className="text-sm text-yellow-400">+{platformMetrics.ticketsChange} this month</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SuperAdminTabNavigation
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
platformMetrics={platformMetrics}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
src/components/TemplateManager.tsx
Normal file
336
src/components/TemplateManager.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PageBuilder from './PageBuilder';
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preview_image_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface TemplateManagerProps {
|
||||
organizationId: string;
|
||||
onTemplateSelect?: (template: Template) => void;
|
||||
}
|
||||
|
||||
const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
organizationId,
|
||||
onTemplateSelect
|
||||
}) => {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showBuilder, setShowBuilder] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newTemplate, setNewTemplate] = useState({ name: '', description: '' });
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [organizationId]);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/templates?organization_id=${organizationId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTemplates(data.templates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Templates loading error:', error);
|
||||
// Silently handle error - user will see empty state
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTemplate = async () => {
|
||||
if (!newTemplate.name.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newTemplate.name,
|
||||
description: newTemplate.description,
|
||||
organization_id: organizationId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTemplates([...templates, data.template]);
|
||||
setShowCreateModal(false);
|
||||
setNewTemplate({ name: '', description: '' });
|
||||
setSelectedTemplate(data.template);
|
||||
setShowBuilder(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Template creation error:', error);
|
||||
// Error creating template - user will see modal remain open
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTemplate = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setShowBuilder(true);
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (templateId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this template?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTemplates(templates.filter(t => t.id !== templateId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Template deletion error:', error);
|
||||
// Error deleting template - user will see no change
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async (pageData: Record<string, unknown>) => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${selectedTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
page_data: pageData,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Template saved successfully!');
|
||||
loadTemplates();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Template save error:', error);
|
||||
alert('Failed to save template. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = (_pageData: Record<string, unknown>) => {
|
||||
// Open preview in new window
|
||||
const previewWindow = window.open('', '_blank', 'width=1200,height=800');
|
||||
if (previewWindow) {
|
||||
previewWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Template Preview</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="p-8">
|
||||
<div class="max-w-4xl mx-auto bg-white rounded-lg shadow-lg">
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold mb-4">Template Preview</h1>
|
||||
<div id="preview-content">
|
||||
<!-- Preview content would be rendered here -->
|
||||
<p class="text-gray-600">Preview functionality coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showBuilder) {
|
||||
return (
|
||||
<div className="h-screen">
|
||||
<div className="bg-white border-b border-gray-200 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setShowBuilder(false)}
|
||||
className="flex items-center px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
← Back to Templates
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{selectedTemplate ? `Editing: ${selectedTemplate.name}` : 'New Template'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageBuilder
|
||||
eventId="" // Templates don't have specific event IDs
|
||||
templateId={selectedTemplate?.id}
|
||||
onSave={handleSaveTemplate}
|
||||
onPreview={handlePreviewTemplate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Page Templates</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Create New Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Templates Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white rounded-lg shadow-md border border-gray-200 overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{/* Template Preview */}
|
||||
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||
{template.preview_image_url ? (
|
||||
<img
|
||||
src={template.preview_image_url}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400">
|
||||
<div className="text-4xl mb-2">🎨</div>
|
||||
<p className="text-sm">No Preview</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{template.name}</h3>
|
||||
{template.description && (
|
||||
<p className="text-sm text-gray-600 mb-3">{template.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-3">
|
||||
<span>Updated {new Date(template.updated_at).toLocaleDateString()}</span>
|
||||
<span className={`px-2 py-1 rounded-full ${
|
||||
template.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{template.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditTemplate(template)}
|
||||
className="flex-1 px-3 py-2 text-sm text-blue-600 hover:text-blue-800 border border-blue-300 rounded-md hover:bg-blue-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTemplateSelect?.(template)}
|
||||
className="flex-1 px-3 py-2 text-sm text-green-600 hover:text-green-800 border border-green-300 rounded-md hover:bg-green-50"
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTemplate(template.id)}
|
||||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 border border-red-300 rounded-md hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{templates.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🎨</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Templates Yet</h3>
|
||||
<p className="text-gray-600 mb-4">Create your first template to get started</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Create Your First Template
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Template Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Create New Template</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Template Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTemplate.name}
|
||||
onChange={(e) => setNewTemplate({ ...newTemplate, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter template name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={newTemplate.description}
|
||||
onChange={(e) => setNewTemplate({ ...newTemplate, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="Describe your template"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTemplate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Create Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateManager;
|
||||
89
src/components/ThemeToggle.tsx
Normal file
89
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getCurrentTheme, toggleTheme as toggleThemeUtil } from '../lib/theme';
|
||||
|
||||
// Extend Window interface for theme events
|
||||
declare global {
|
||||
interface Window {
|
||||
__INITIAL_THEME__?: 'light' | 'dark';
|
||||
}
|
||||
interface WindowEventMap {
|
||||
'themeChanged': CustomEvent<{ theme: 'light' | 'dark' }>;
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ThemeToggle({ className = '' }: ThemeToggleProps) {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Get current theme - use the initial theme if available
|
||||
const currentTheme = window.__INITIAL_THEME__ || getCurrentTheme();
|
||||
setTheme(currentTheme);
|
||||
|
||||
// Listen for theme changes
|
||||
const handleThemeChange = (e: CustomEvent<{ theme: 'light' | 'dark' }>) => {
|
||||
setTheme(e.detail.theme);
|
||||
};
|
||||
|
||||
window.addEventListener('themeChanged', handleThemeChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('themeChanged', handleThemeChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
const newTheme = toggleThemeUtil();
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
// Don't render until mounted to avoid hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className={`px-3 py-2 rounded-lg cursor-not-allowed opacity-50 ${className}`}
|
||||
style={{
|
||||
background: 'var(--glass-bg-button)',
|
||||
color: 'var(--glass-text-tertiary)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`px-3 py-2 rounded-lg transition-all duration-200 hover:scale-105 ${className}`}
|
||||
style={{
|
||||
background: 'var(--glass-bg-button)',
|
||||
color: 'var(--glass-text-primary)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
}}
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { inventoryManager } from '../lib/inventory';
|
||||
import { calculateFeeBreakdown } from '../lib/stripe';
|
||||
import {
|
||||
@@ -43,13 +43,36 @@ interface EventData {
|
||||
};
|
||||
}
|
||||
|
||||
interface SelectedTicket {
|
||||
quantity: number;
|
||||
price: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Reservation {
|
||||
id: string;
|
||||
ticketTypeId: string;
|
||||
quantity: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
interface PresaleCodeData {
|
||||
id: string;
|
||||
code: string;
|
||||
discount_percentage?: number;
|
||||
discount_fixed?: number;
|
||||
usage_limit?: number;
|
||||
usage_count?: number;
|
||||
accessible_ticket_types?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
event: EventData;
|
||||
}
|
||||
|
||||
export default function TicketCheckout({ event }: Props) {
|
||||
const [selectedTickets, setSelectedTickets] = useState<Map<string, any>>(new Map());
|
||||
const [currentReservations, setCurrentReservations] = useState<Map<string, any>>(new Map());
|
||||
const [selectedTickets, setSelectedTickets] = useState<Map<string, SelectedTicket>>(new Map());
|
||||
const [currentReservations, setCurrentReservations] = useState<Map<string, Reservation>>(new Map());
|
||||
const [availability, setAvailability] = useState<Map<string, AvailabilityInfo>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||
@@ -57,7 +80,7 @@ export default function TicketCheckout({ event }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [presaleCode, setPresaleCode] = useState('');
|
||||
const [presaleCodeValidated, setPresaleCodeValidated] = useState(false);
|
||||
const [presaleCodeData, setPresaleCodeData] = useState<any>(null);
|
||||
const [presaleCodeData, setPresaleCodeData] = useState<PresaleCodeData | null>(null);
|
||||
const [presaleCodeError, setPresaleCodeError] = useState('');
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -110,7 +133,7 @@ export default function TicketCheckout({ event }: Props) {
|
||||
const avail = await inventoryManager.getAvailability(ticketType.id);
|
||||
availabilityMap.set(ticketType.id, avail);
|
||||
} catch (error) {
|
||||
console.error('Error loading availability for', ticketType.id, error);
|
||||
console.error('Availability check error for ticket type:', ticketType.id, error);
|
||||
availabilityMap.set(ticketType.id, { is_available: false, error: true });
|
||||
}
|
||||
}
|
||||
@@ -147,28 +170,54 @@ export default function TicketCheckout({ event }: Props) {
|
||||
return () => clearInterval(timer);
|
||||
}, [currentReservations]);
|
||||
|
||||
// Cleanup effect - release reservations when component unmounts or on page unload
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
// Release all active reservations
|
||||
currentReservations.forEach(async (reservation) => {
|
||||
try {
|
||||
await inventoryManager.releaseReservation(reservation.id);
|
||||
} catch (error) {
|
||||
console.error('Reservation release error during unload:', error);
|
||||
// Silently fail - page is unloading
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
// Also release reservations on component unmount
|
||||
handleBeforeUnload();
|
||||
};
|
||||
}, [currentReservations]);
|
||||
|
||||
const handleQuantityChange = async (ticketTypeId: string, newQuantity: number) => {
|
||||
const currentQuantity = selectedTickets.get(ticketTypeId)?.quantity || 0;
|
||||
|
||||
if (newQuantity === currentQuantity) return;
|
||||
|
||||
console.log('Quantity change:', { ticketTypeId, currentQuantity, newQuantity });
|
||||
|
||||
try {
|
||||
// Release existing reservation if any
|
||||
if (currentReservations.has(ticketTypeId)) {
|
||||
console.log('Releasing existing reservation...');
|
||||
await inventoryManager.releaseReservation(currentReservations.get(ticketTypeId).id);
|
||||
const existingReservation = currentReservations.get(ticketTypeId);
|
||||
try {
|
||||
await inventoryManager.releaseReservation(existingReservation.id);
|
||||
} catch (releaseError) {
|
||||
console.error('Reservation release error:', releaseError);
|
||||
// Continue anyway - the reservation might have already expired
|
||||
}
|
||||
|
||||
// Always remove from local state regardless of API result
|
||||
const newReservations = new Map(currentReservations);
|
||||
newReservations.delete(ticketTypeId);
|
||||
setCurrentReservations(newReservations);
|
||||
}
|
||||
|
||||
if (newQuantity > 0) {
|
||||
console.log('Reserving tickets:', { ticketTypeId, quantity: newQuantity });
|
||||
// Reserve new tickets
|
||||
const reservation = await inventoryManager.reserveTickets(ticketTypeId, newQuantity, 15);
|
||||
console.log('Reservation successful:', reservation);
|
||||
|
||||
const newReservations = new Map(currentReservations);
|
||||
newReservations.set(ticketTypeId, reservation);
|
||||
@@ -191,9 +240,28 @@ export default function TicketCheckout({ event }: Props) {
|
||||
setSelectedTickets(newSelected);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
console.error('Error details:', error);
|
||||
alert(error.message || 'Error reserving tickets. Please try again.');
|
||||
// If it's a reservation error, still update the UI but show a warning
|
||||
if (error.message && error.message.includes('Reservation not found')) {
|
||||
|
||||
// Update selected tickets even if reservation failed
|
||||
if (newQuantity > 0) {
|
||||
const ticketType = event.ticket_types?.find(tt => tt.id === ticketTypeId);
|
||||
const newSelected = new Map(selectedTickets);
|
||||
newSelected.set(ticketTypeId, {
|
||||
quantity: newQuantity,
|
||||
price: typeof ticketType?.price === 'string' ? Math.round(parseFloat(ticketType.price) * 100) : ticketType?.price,
|
||||
name: ticketType?.name,
|
||||
reservation_id: null // No reservation ID if it failed
|
||||
});
|
||||
setSelectedTickets(newSelected);
|
||||
} else {
|
||||
const newSelected = new Map(selectedTickets);
|
||||
newSelected.delete(ticketTypeId);
|
||||
setSelectedTickets(newSelected);
|
||||
}
|
||||
} else {
|
||||
alert(error.message || 'Error reserving tickets. Please try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,7 +303,7 @@ export default function TicketCheckout({ event }: Props) {
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
const purchaseAttempt = await inventoryManager.createPurchaseAttempt(
|
||||
const _purchaseAttempt = await inventoryManager.createPurchaseAttempt(
|
||||
event.id,
|
||||
email,
|
||||
name,
|
||||
@@ -244,11 +312,10 @@ export default function TicketCheckout({ event }: Props) {
|
||||
);
|
||||
|
||||
alert('Checkout integration coming soon! Your tickets are reserved.');
|
||||
console.log('Purchase attempt created:', purchaseAttempt);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating purchase:', error);
|
||||
alert(error.message || 'Error processing purchase. Please try again.');
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error processing purchase. Please try again.';
|
||||
alert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -286,7 +353,7 @@ export default function TicketCheckout({ event }: Props) {
|
||||
setPresaleCodeError(data.error || 'Invalid presale code');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating presale code:', error);
|
||||
console.error('Presale code validation error:', error);
|
||||
setPresaleCodeError('Error validating code. Please try again.');
|
||||
}
|
||||
};
|
||||
@@ -309,7 +376,7 @@ export default function TicketCheckout({ event }: Props) {
|
||||
const totals = calculateTotals();
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Loading ticket availability...</div>;
|
||||
return <div className="text-center py-8" style={{color: 'var(--ui-text-secondary)'}}>Loading ticket availability...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -318,10 +385,10 @@ export default function TicketCheckout({ event }: Props) {
|
||||
|
||||
{/* Presale Code Entry - Only show if presale is active */}
|
||||
{hasActivePresale && !presaleCodeValidated && (
|
||||
<div className="mb-6 p-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-2xl">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="mb-6 p-4 sm:p-6 rounded-2xl backdrop-blur-lg" style={{background: 'linear-gradient(to bottom right, var(--glass-bg), var(--glass-bg-lg))', border: '2px solid var(--glass-border)'}}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="presale-code" className="block text-sm font-semibold text-blue-900 mb-2">
|
||||
<label htmlFor="presale-code" className="block text-sm font-semibold mb-2" style={{color: 'var(--glass-text-accent)'}}>
|
||||
Presale Code Required
|
||||
</label>
|
||||
<input
|
||||
@@ -333,16 +400,46 @@ export default function TicketCheckout({ event }: Props) {
|
||||
setPresaleCodeError('');
|
||||
}}
|
||||
placeholder="Enter your presale code"
|
||||
className="w-full px-4 py-3 border-2 border-blue-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 text-slate-900 placeholder-blue-400 bg-white hover:border-blue-400"
|
||||
className="w-full px-4 py-3 border-2 rounded-xl focus:ring-2 transition-all duration-200 backdrop-blur-lg"
|
||||
style={{
|
||||
borderColor: 'var(--glass-border)',
|
||||
focusRingColor: 'var(--glass-border-focus)',
|
||||
focusBorderColor: 'var(--glass-border-focus)',
|
||||
color: 'var(--ui-text-primary)',
|
||||
background: 'var(--glass-bg-input)',
|
||||
'::placeholder': { color: 'var(--glass-placeholder)' }
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = 'var(--glass-border-focus)';
|
||||
e.target.style.boxShadow = '0 0 0 2px var(--glass-border-focus-shadow)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'var(--glass-border)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
onMouseEnter={(e) => (e.target as HTMLElement).style.borderColor = 'var(--glass-border-focus)'}
|
||||
onMouseLeave={(e) => e.target.style.borderColor = 'var(--glass-border)'}
|
||||
/>
|
||||
{presaleCodeError && (
|
||||
<p className="text-red-600 text-sm mt-2 font-medium">{presaleCodeError}</p>
|
||||
<p className="text-sm mt-2 font-medium" style={{color: 'var(--error-color)'}}>{presaleCodeError}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={validatePresaleCode}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl hover:from-blue-700 hover:to-indigo-700 font-semibold text-sm whitespace-nowrap transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
className="w-full sm:w-auto px-6 py-3 rounded-xl font-semibold text-sm whitespace-nowrap transition-all duration-200 shadow-lg hover:shadow-xl touch-manipulation min-h-[44px]"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, var(--glass-text-accent), var(--premium-gold))',
|
||||
color: 'var(--glass-text-primary)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = 'linear-gradient(to right, var(--glass-border-focus), var(--premium-gold-border))';
|
||||
e.target.style.transform = 'scale(1.02)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'linear-gradient(to right, var(--glass-text-accent), var(--premium-gold))';
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
Apply Code
|
||||
</button>
|
||||
@@ -352,13 +449,13 @@ export default function TicketCheckout({ event }: Props) {
|
||||
|
||||
{/* Presale Code Success - Compact version */}
|
||||
{presaleCodeValidated && presaleCodeData && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="mb-4 p-3 rounded-lg backdrop-blur-lg" style={{background: 'var(--success-bg)', border: '1px solid var(--success-border)'}}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg className="w-4 h-4" style={{color: 'var(--success-color)'}} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-900">
|
||||
<span className="text-sm font-medium" style={{color: 'var(--success-color)'}}>
|
||||
Presale access granted
|
||||
</span>
|
||||
</div>
|
||||
@@ -369,7 +466,10 @@ export default function TicketCheckout({ event }: Props) {
|
||||
setPresaleCodeData(null);
|
||||
setPresaleCode('');
|
||||
}}
|
||||
className="text-green-600 hover:text-green-800 text-sm font-medium"
|
||||
className="text-sm font-medium transition-colors"
|
||||
style={{color: 'var(--success-color)'}}
|
||||
onMouseEnter={(e) => (e.target as HTMLElement).style.color = 'var(--ui-text-primary)'}
|
||||
onMouseLeave={(e) => (e.target as HTMLElement).style.color = 'var(--success-color)'}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@@ -394,7 +494,7 @@ export default function TicketCheckout({ event }: Props) {
|
||||
}
|
||||
// Check if presale code gives access to this ticket type
|
||||
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
||||
(accessibleType: any) => accessibleType.id === ticketType.id
|
||||
(accessibleType: { id: string; name: string }) => accessibleType.id === ticketType.id
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
@@ -410,34 +510,59 @@ export default function TicketCheckout({ event }: Props) {
|
||||
// Get formatted availability display
|
||||
const availabilityDisplay = avail
|
||||
? formatAvailabilityDisplay(avail, availabilitySettings)
|
||||
: { text: 'Loading...', className: 'text-gray-500', showExactCount: false, isLowStock: false, isSoldOut: false };
|
||||
: { text: 'Loading...', className: '', showExactCount: false, isLowStock: false, isSoldOut: false };
|
||||
|
||||
return (
|
||||
<div key={ticketType.id} className={`border-2 rounded-2xl p-6 transition-all duration-200 ${
|
||||
availabilityDisplay.isSoldOut
|
||||
? 'bg-slate-50 opacity-75 border-slate-200'
|
||||
: selectedQuantity > 0
|
||||
? 'bg-gradient-to-br from-emerald-50 to-green-50 border-emerald-300 shadow-lg'
|
||||
: 'bg-white border-slate-200 hover:border-slate-300 hover:shadow-md'
|
||||
}`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div
|
||||
key={ticketType.id}
|
||||
className="border-2 rounded-2xl p-4 sm:p-6 transition-all duration-200 backdrop-blur-lg"
|
||||
style={{
|
||||
background: availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-bg-secondary)'
|
||||
: selectedQuantity > 0
|
||||
? 'linear-gradient(to bottom right, var(--success-bg), var(--glass-bg-lg))'
|
||||
: 'var(--ui-bg-elevated)',
|
||||
borderColor: availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-border-secondary)'
|
||||
: selectedQuantity > 0
|
||||
? 'var(--success-border)'
|
||||
: 'var(--ui-border-primary)',
|
||||
opacity: availabilityDisplay.isSoldOut ? 0.75 : 1,
|
||||
boxShadow: selectedQuantity > 0 ? '0 10px 25px var(--ui-shadow)' : undefined
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!availabilityDisplay.isSoldOut && selectedQuantity === 0) {
|
||||
e.target.style.borderColor = 'var(--glass-border-focus)';
|
||||
e.target.style.boxShadow = '0 4px 12px var(--ui-shadow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!availabilityDisplay.isSoldOut && selectedQuantity === 0) {
|
||||
e.target.style.borderColor = 'var(--ui-border-primary)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="text-xl font-semibold text-slate-900">{ticketType.name}</h3>
|
||||
{availabilityDisplay.isLowStock && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-orange-400 to-amber-400 text-white">
|
||||
Low Stock
|
||||
</span>
|
||||
)}
|
||||
{selectedQuantity > 0 && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-400 to-green-400 text-white">
|
||||
{selectedQuantity} Selected
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-3">
|
||||
<h3 className="text-lg sm:text-xl font-semibold" style={{color: 'var(--ui-text-primary)'}}>{ticketType.name}</h3>
|
||||
<div className="flex gap-2">
|
||||
{availabilityDisplay.isLowStock && (
|
||||
<span className="inline-flex items-center px-2 sm:px-3 py-1 rounded-full text-xs font-semibold" style={{background: 'linear-gradient(to right, var(--warning-color), var(--premium-gold))', color: 'var(--glass-text-primary)'}}>
|
||||
Low Stock
|
||||
</span>
|
||||
)}
|
||||
{selectedQuantity > 0 && (
|
||||
<span className="inline-flex items-center px-2 sm:px-3 py-1 rounded-full text-xs font-semibold" style={{background: 'linear-gradient(to right, var(--success-color), var(--success-color))', color: 'var(--glass-text-primary)'}}>
|
||||
{selectedQuantity} Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ticketType.description && (
|
||||
<div className="mb-4 p-3 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<p className="text-sm text-slate-700 leading-relaxed whitespace-pre-line">
|
||||
<div className="mb-4 p-3 rounded-xl backdrop-blur-lg" style={{background: 'var(--ui-bg-secondary)', border: '1px solid var(--ui-border-secondary)'}}>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-line" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
{expandedDescriptions.has(ticketType.id)
|
||||
? ticketType.description
|
||||
: truncateDescription(ticketType.description)
|
||||
@@ -447,52 +572,99 @@ export default function TicketCheckout({ event }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDescription(ticketType.id)}
|
||||
className="mt-2 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||
className="mt-2 text-xs font-medium transition-colors"
|
||||
style={{color: 'var(--glass-text-accent)'}}
|
||||
onMouseEnter={(e) => (e.target as HTMLElement).style.color = 'var(--ui-text-primary)'}
|
||||
onMouseLeave={(e) => e.target.style.color = 'var(--glass-text-accent)'}
|
||||
>
|
||||
{expandedDescriptions.has(ticketType.id) ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<span className="text-2xl font-bold text-slate-900">
|
||||
<span className="text-xl sm:text-2xl font-bold" style={{color: 'var(--ui-text-primary)'}}>
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-sm ml-3 font-medium ${availabilityDisplay.className}`}>
|
||||
<span className="text-sm block sm:inline sm:ml-3 font-medium" style={{color: availabilityDisplay.isSoldOut ? 'var(--error-color)' : availabilityDisplay.isLowStock ? 'var(--warning-color)' : 'var(--ui-text-secondary)'}}>
|
||||
{availabilityDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center justify-center sm:justify-start space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuantityChange(ticketType.id, Math.max(0, selectedQuantity - 1))}
|
||||
disabled={selectedQuantity <= 0 || availabilityDisplay.isSoldOut}
|
||||
className={`w-10 h-10 rounded-xl border-2 font-bold text-lg transition-all duration-200 ${
|
||||
selectedQuantity <= 0 || availabilityDisplay.isSoldOut
|
||||
? 'border-slate-200 text-slate-300 cursor-not-allowed bg-slate-50'
|
||||
: 'border-slate-300 text-slate-600 hover:border-red-400 hover:text-red-600 hover:bg-red-50 active:scale-95'
|
||||
}`}
|
||||
className="w-12 h-12 rounded-xl border-2 font-bold text-lg transition-all duration-200 touch-manipulation"
|
||||
style={{
|
||||
borderColor: selectedQuantity <= 0 || availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-border-secondary)'
|
||||
: 'var(--ui-border-primary)',
|
||||
color: selectedQuantity <= 0 || availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-text-muted)'
|
||||
: 'var(--ui-text-secondary)',
|
||||
background: selectedQuantity <= 0 || availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-bg-secondary)'
|
||||
: 'var(--ui-bg-elevated)',
|
||||
cursor: selectedQuantity <= 0 || availabilityDisplay.isSoldOut ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!(selectedQuantity <= 0 || availabilityDisplay.isSoldOut)) {
|
||||
e.target.style.borderColor = 'var(--error-border)';
|
||||
e.target.style.color = 'var(--error-color)';
|
||||
e.target.style.background = 'var(--error-bg)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!(selectedQuantity <= 0 || availabilityDisplay.isSoldOut)) {
|
||||
e.target.style.borderColor = 'var(--ui-border-primary)';
|
||||
e.target.style.color = 'var(--ui-text-secondary)';
|
||||
e.target.style.background = 'var(--ui-bg-elevated)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
|
||||
<div className="w-12 text-center">
|
||||
<span className="text-lg font-semibold text-slate-900">{selectedQuantity}</span>
|
||||
<span className="text-lg font-semibold" style={{color: 'var(--ui-text-primary)'}}>{selectedQuantity}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuantityChange(ticketType.id, selectedQuantity + 1)}
|
||||
disabled={selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut}
|
||||
className={`w-10 h-10 rounded-xl border-2 font-bold text-lg transition-all duration-200 ${
|
||||
selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut
|
||||
? 'border-slate-200 text-slate-300 cursor-not-allowed bg-slate-50'
|
||||
: 'border-slate-300 text-slate-600 hover:border-green-400 hover:text-green-600 hover:bg-green-50 active:scale-95'
|
||||
}`}
|
||||
className="w-12 h-12 rounded-xl border-2 font-bold text-lg transition-all duration-200 touch-manipulation"
|
||||
style={{
|
||||
borderColor: selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-border-secondary)'
|
||||
: 'var(--ui-border-primary)',
|
||||
color: selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-text-muted)'
|
||||
: 'var(--ui-text-secondary)',
|
||||
background: selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut
|
||||
? 'var(--ui-bg-secondary)'
|
||||
: 'var(--ui-bg-elevated)',
|
||||
cursor: selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!(selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut)) {
|
||||
e.target.style.borderColor = 'var(--success-border)';
|
||||
e.target.style.color = 'var(--success-color)';
|
||||
e.target.style.background = 'var(--success-bg)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!(selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut)) {
|
||||
e.target.style.borderColor = 'var(--ui-border-primary)';
|
||||
e.target.style.color = 'var(--ui-text-secondary)';
|
||||
e.target.style.background = 'var(--ui-bg-elevated)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -516,7 +688,7 @@ export default function TicketCheckout({ event }: Props) {
|
||||
return false;
|
||||
}
|
||||
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
||||
(accessibleType: any) => accessibleType.id === ticketType.id
|
||||
(accessibleType: { id: string; name: string }) => accessibleType.id === ticketType.id
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
@@ -524,14 +696,14 @@ export default function TicketCheckout({ event }: Props) {
|
||||
}
|
||||
return true;
|
||||
}).length === 0 && (
|
||||
<div className="text-center py-6 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="w-12 h-12 mx-auto text-yellow-400 mb-3">
|
||||
<div className="text-center py-6 rounded-lg backdrop-blur-lg" style={{background: 'var(--warning-bg)', border: '1px solid var(--warning-border)'}}>
|
||||
<div className="w-12 h-12 mx-auto mb-3" style={{color: 'var(--warning-color)'}}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-yellow-900 mb-2">Presale Access Required</h3>
|
||||
<p className="text-yellow-700 text-sm">
|
||||
<h3 className="text-lg font-medium mb-2" style={{color: 'var(--warning-color)'}}>Presale Access Required</h3>
|
||||
<p className="text-sm" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
This event is currently in presale. Enter your presale code above to access tickets.
|
||||
</p>
|
||||
</div>
|
||||
@@ -540,12 +712,12 @@ export default function TicketCheckout({ event }: Props) {
|
||||
|
||||
{/* Reservation Timer */}
|
||||
{currentReservations.size > 0 && (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-200 rounded-2xl p-4">
|
||||
<div className="rounded-2xl p-4 backdrop-blur-lg" style={{background: 'linear-gradient(to right, var(--warning-bg), var(--premium-gold-bg))', border: '2px solid var(--warning-border)'}}>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-6 w-6 text-amber-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg className="h-6 w-6 mr-3" style={{color: 'var(--warning-color)'}} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-amber-800">
|
||||
<span className="text-sm font-semibold" style={{color: 'var(--warning-color)'}}>
|
||||
Tickets reserved for {timeRemaining}
|
||||
</span>
|
||||
</div>
|
||||
@@ -554,39 +726,39 @@ export default function TicketCheckout({ event }: Props) {
|
||||
|
||||
{/* Order Summary */}
|
||||
{selectedTickets.size > 0 && (
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white border-2 border-slate-200 rounded-2xl p-6 shadow-lg">
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-4 flex items-center">
|
||||
<div className="w-3 h-3 bg-gradient-to-r from-emerald-500 to-green-500 rounded-full mr-3"></div>
|
||||
<div className="rounded-2xl p-4 sm:p-6 shadow-lg backdrop-blur-lg" style={{background: 'linear-gradient(to bottom right, var(--ui-bg-secondary), var(--ui-bg-elevated))', border: '2px solid var(--ui-border-primary)'}}>
|
||||
<h3 className="text-lg sm:text-xl font-semibold mb-4 flex items-center" style={{color: 'var(--ui-text-primary)'}}>
|
||||
<div className="w-3 h-3 rounded-full mr-3" style={{background: 'linear-gradient(to right, var(--success-color), var(--success-color))'}}></div>
|
||||
Order Summary
|
||||
</h3>
|
||||
<div className="space-y-3 mb-4">
|
||||
{Array.from(selectedTickets.entries()).map(([ticketTypeId, ticket]) => (
|
||||
<div key={ticketTypeId} className="flex justify-between items-center p-3 bg-white rounded-xl border border-slate-200">
|
||||
<span className="font-medium text-slate-900">{ticket.quantity}x {ticket.name}</span>
|
||||
<span className="font-semibold text-slate-900">${((ticket.quantity * ticket.price) / 100).toFixed(2)}</span>
|
||||
<div key={ticketTypeId} className="flex justify-between items-center p-3 rounded-xl backdrop-blur-lg" style={{background: 'var(--ui-bg-elevated)', border: '1px solid var(--ui-border-secondary)'}}>
|
||||
<span className="font-medium truncate mr-2" style={{color: 'var(--ui-text-primary)'}}>{ticket.quantity}x {ticket.name}</span>
|
||||
<span className="font-semibold whitespace-nowrap" style={{color: 'var(--ui-text-primary)'}}>${((ticket.quantity * ticket.price) / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-2 border-slate-200 pt-4">
|
||||
<div className="flex justify-between text-slate-600 mb-2">
|
||||
<div className="pt-4" style={{borderTop: '2px solid var(--ui-border-secondary)'}}>
|
||||
<div className="flex justify-between mb-2" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
<span>Subtotal:</span>
|
||||
<span>${(totals.subtotal / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600 mb-3">
|
||||
<div className="flex justify-between mb-3" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
<span>Platform fee:</span>
|
||||
<span>${(totals.platformFee / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold text-slate-900 pt-3 border-t border-slate-200">
|
||||
<div className="flex justify-between text-lg sm:text-xl font-bold pt-3" style={{color: 'var(--ui-text-primary)', borderTop: '1px solid var(--ui-border-secondary)'}}>
|
||||
<span>Total:</span>
|
||||
<span>${(totals.total / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Information - Only show when tickets are selected */}
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="mt-4 sm:mt-6 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
<label htmlFor="email" className="block text-sm font-semibold mb-2" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
@@ -595,13 +767,29 @@ export default function TicketCheckout({ event }: Props) {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="block w-full px-4 py-3 border-2 border-slate-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 text-slate-900 placeholder-slate-400 bg-white hover:border-slate-300"
|
||||
className="block w-full px-4 py-3 border-2 rounded-xl shadow-sm focus:ring-2 transition-all duration-200 backdrop-blur-lg"
|
||||
style={{
|
||||
borderColor: 'var(--ui-border-primary)',
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
color: 'var(--ui-text-primary)',
|
||||
'::placeholder': { color: 'var(--glass-placeholder)' }
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = 'var(--glass-border-focus)';
|
||||
e.target.style.boxShadow = '0 0 0 2px var(--glass-border-focus-shadow)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'var(--ui-border-primary)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
onMouseEnter={(e) => (e.target as HTMLElement).style.borderColor = 'var(--glass-border-focus)'}
|
||||
onMouseLeave={(e) => e.target.style.borderColor = 'var(--ui-border-primary)'}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
<label htmlFor="name" className="block text-sm font-semibold mb-2" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
@@ -610,7 +798,23 @@ export default function TicketCheckout({ event }: Props) {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="block w-full px-4 py-3 border-2 border-slate-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 text-slate-900 placeholder-slate-400 bg-white hover:border-slate-300"
|
||||
className="block w-full px-4 py-3 border-2 rounded-xl shadow-sm focus:ring-2 transition-all duration-200 backdrop-blur-lg"
|
||||
style={{
|
||||
borderColor: 'var(--ui-border-primary)',
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
color: 'var(--ui-text-primary)',
|
||||
'::placeholder': { color: 'var(--glass-placeholder)' }
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = 'var(--glass-border-focus)';
|
||||
e.target.style.boxShadow = '0 0 0 2px var(--glass-border-focus-shadow)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'var(--ui-border-primary)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
onMouseEnter={(e) => (e.target as HTMLElement).style.borderColor = 'var(--glass-border-focus)'}
|
||||
onMouseLeave={(e) => e.target.style.borderColor = 'var(--ui-border-primary)'}
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
@@ -618,7 +822,19 @@ export default function TicketCheckout({ event }: Props) {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 px-6 rounded-2xl font-semibold text-lg transition-all duration-200 bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-700 hover:to-green-700 text-white shadow-xl hover:shadow-2xl transform hover:scale-[1.02]"
|
||||
className="w-full py-4 px-6 rounded-2xl font-semibold text-base sm:text-lg transition-all duration-200 shadow-xl hover:shadow-2xl transform hover:scale-[1.02] touch-manipulation min-h-[44px]"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, var(--success-color), var(--success-color))',
|
||||
color: 'var(--glass-text-primary)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = 'linear-gradient(to right, var(--success-color), var(--success-border))';
|
||||
e.target.style.transform = 'scale(1.02)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'linear-gradient(to right, var(--success-color), var(--success-color))';
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
Complete Purchase
|
||||
</button>
|
||||
@@ -628,19 +844,19 @@ export default function TicketCheckout({ event }: Props) {
|
||||
|
||||
{/* Call to Action - Show when no tickets selected */}
|
||||
{selectedTickets.size === 0 && (
|
||||
<div className="text-center py-8 px-6 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-dashed border-slate-300">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-slate-400 to-slate-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="text-center py-8 px-6 rounded-2xl border-2 border-dashed backdrop-blur-lg" style={{background: 'linear-gradient(to bottom right, var(--ui-bg-secondary), var(--ui-bg-elevated))', borderColor: 'var(--ui-border-primary)'}}>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" style={{background: 'linear-gradient(to bottom right, var(--ui-text-muted), var(--ui-text-secondary))'}}>
|
||||
<svg className="w-8 h-8" style={{color: 'var(--glass-text-primary)'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">Select Your Tickets</h3>
|
||||
<p className="text-slate-500">Choose your preferred seating and quantity above to continue</p>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{color: 'var(--ui-text-secondary)'}}>Select Your Tickets</h3>
|
||||
<p style={{color: 'var(--ui-text-tertiary)'}}>Choose your preferred seating and quantity above to continue</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs" style={{color: 'var(--ui-text-tertiary)'}}>
|
||||
Secure checkout powered by Stripe • Tickets reserved for 15 minutes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +51,9 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
|
||||
setTrendingEvents(trending);
|
||||
} catch (err) {
|
||||
console.error('Trending events loading error:', err);
|
||||
setError('Failed to load trending events');
|
||||
console.error('Error loading trending events:', err);
|
||||
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -101,18 +102,18 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
};
|
||||
|
||||
const getPopularityBadge = (score: number) => {
|
||||
if (score >= 100) return { text: 'Super Hot', color: 'bg-red-500' };
|
||||
if (score >= 50) return { text: 'Hot', color: 'bg-orange-500' };
|
||||
if (score >= 25) return { text: 'Trending', color: 'bg-yellow-500' };
|
||||
return { text: 'Popular', color: 'bg-blue-500' };
|
||||
if (score >= 100) return { text: 'Super Hot', color: 'var(--error-color)' };
|
||||
if (score >= 50) return { text: 'Hot', color: 'var(--warning-color)' };
|
||||
if (score >= 25) return { text: 'Trending', color: 'var(--premium-gold)' };
|
||||
return { text: 'Popular', color: 'var(--glass-text-accent)' };
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className={`rounded-lg shadow-md p-6 ${className}`} style={{background: 'var(--ui-bg-elevated)'}}>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<span className="ml-3 text-gray-600">Loading hot events...</span>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{borderColor: 'var(--glass-text-accent)'}}></div>
|
||||
<span className="ml-3" style={{color: 'var(--ui-text-secondary)'}}>Loading hot events...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -120,16 +121,19 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className={`rounded-lg shadow-md p-6 ${className}`} style={{background: 'var(--ui-bg-elevated)'}}>
|
||||
<div className="text-center">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="mx-auto h-12 w-12" style={{color: 'var(--ui-text-muted)'}} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Unable to load events</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">{error}</p>
|
||||
<h3 className="mt-2 text-sm font-medium" style={{color: 'var(--ui-text-primary)'}}>Unable to load events</h3>
|
||||
<p className="mt-1 text-sm" style={{color: 'var(--ui-text-secondary)'}}>{error}</p>
|
||||
<button
|
||||
onClick={loadTrendingEvents}
|
||||
className="mt-2 text-sm text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
className="mt-2 text-sm font-medium transition-colors"
|
||||
style={{color: 'var(--glass-text-accent)'}}
|
||||
onMouseEnter={(e) => e.target.style.color = 'var(--premium-primary)'}
|
||||
onMouseLeave={(e) => e.target.style.color = 'var(--glass-text-accent)'}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
@@ -140,13 +144,13 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
|
||||
if (trendingEvents.length === 0) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className={`rounded-lg shadow-md p-6 ${className}`} style={{background: 'var(--ui-bg-elevated)'}}>
|
||||
<div className="text-center">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="mx-auto h-12 w-12" style={{color: 'var(--ui-text-muted)'}} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3a4 4 0 118 0v4m-4 12v-6m-4 0h8m-8 0v6a4 4 0 108 0v-6" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No trending events found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<h3 className="mt-2 text-sm font-medium" style={{color: 'var(--ui-text-primary)'}}>No trending events found</h3>
|
||||
<p className="mt-1 text-sm" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
Try expanding your search radius or check back later
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,16 +159,16 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md overflow-hidden ${className}`}>
|
||||
<div className={`rounded-lg shadow-md overflow-hidden ${className}`} style={{background: 'var(--ui-bg-elevated)'}}>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-orange-400 to-red-500 px-6 py-4">
|
||||
<div className="px-6 py-4" style={{background: 'linear-gradient(to right, var(--warning-color), var(--error-color))'}}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-2xl">🔥</div>
|
||||
<h2 className="text-xl font-bold text-white">What's Hot</h2>
|
||||
<h2 className="text-xl font-bold" style={{color: 'var(--glass-text-primary)'}}>What's Hot</h2>
|
||||
</div>
|
||||
{userLocation && (
|
||||
<span className="text-orange-100 text-sm">
|
||||
<span className="text-sm" style={{color: 'var(--glass-text-secondary)'}}>
|
||||
Within {radius} miles
|
||||
</span>
|
||||
)}
|
||||
@@ -174,22 +178,34 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
{/* Events Grid */}
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{trendingEvents.map((event, index) => {
|
||||
{trendingEvents.map((event, _index) => {
|
||||
const popularityBadge = getPopularityBadge(event.popularityScore);
|
||||
return (
|
||||
<div
|
||||
key={event.eventId}
|
||||
onClick={() => handleEventClick(event)}
|
||||
className="group cursor-pointer bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors duration-200 border border-gray-200 hover:border-gray-300 relative overflow-hidden"
|
||||
className="group cursor-pointer rounded-lg p-4 transition-colors duration-200 border relative overflow-hidden backdrop-blur-lg"
|
||||
style={{
|
||||
background: 'var(--ui-bg-secondary)',
|
||||
borderColor: 'var(--ui-border-primary)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = 'var(--ui-bg-elevated)';
|
||||
e.target.style.borderColor = 'var(--ui-border-secondary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'var(--ui-bg-secondary)';
|
||||
e.target.style.borderColor = 'var(--ui-border-primary)';
|
||||
}}
|
||||
>
|
||||
{/* Popularity Badge */}
|
||||
<div className={`absolute top-2 right-2 px-2 py-1 rounded-full text-xs font-medium text-white ${popularityBadge.color}`}>
|
||||
<div className={`absolute top-2 right-2 px-2 py-1 rounded-full text-xs font-medium`} style={{color: 'var(--glass-text-primary)', backgroundColor: popularityBadge.color}}>
|
||||
{popularityBadge.text}
|
||||
</div>
|
||||
|
||||
{/* Event Image */}
|
||||
{event.imageUrl && (
|
||||
<div className="w-full h-32 bg-gray-200 rounded-lg mb-3 overflow-hidden">
|
||||
<div className="w-full h-32 rounded-lg mb-3 overflow-hidden" style={{background: 'var(--ui-bg-muted)'}}>
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt={event.title}
|
||||
@@ -201,12 +217,12 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
{/* Event Content */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 line-clamp-2 pr-8">
|
||||
<h3 className="text-sm font-semibold line-clamp-2 pr-8" style={{color: 'var(--ui-text-primary)'}}>
|
||||
{event.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div className="text-xs space-y-1" style={{color: 'var(--ui-text-secondary)'}}>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
@@ -233,7 +249,7 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Event Stats */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-xs pt-2 border-t" style={{color: 'var(--ui-text-tertiary)', borderColor: 'var(--ui-border-secondary)'}}>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -252,10 +268,10 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
|
||||
{event.isFeature && (
|
||||
<div className="flex items-center">
|
||||
<svg className="h-3 w-3 mr-1 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg className="h-3 w-3 mr-1" style={{color: 'var(--premium-gold)'}} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span className="text-yellow-600 font-medium">Featured</span>
|
||||
<span className="font-medium" style={{color: 'var(--premium-gold)'}}>Featured</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -269,7 +285,13 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => window.location.href = '/calendar'}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gradient-to-r from-orange-400 to-red-500 hover:from-orange-500 hover:to-red-600 transition-colors duration-200"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md transition-colors duration-200"
|
||||
style={{
|
||||
color: 'var(--glass-text-primary)',
|
||||
background: 'linear-gradient(to right, var(--warning-color), var(--error-color))'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = 'linear-gradient(to right, var(--warning-color-dark), var(--error-color-dark))'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'linear-gradient(to right, var(--warning-color), var(--error-color))'}
|
||||
>
|
||||
View All Events
|
||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
210
src/components/admin/AnalyticsTab.tsx
Normal file
210
src/components/admin/AnalyticsTab.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { makeAuthenticatedRequest } from '../../lib/api-client';
|
||||
|
||||
interface AnalyticsTabProps {
|
||||
platformMetrics: Record<string, unknown> | null;
|
||||
user: { id: string; email: string } | null;
|
||||
}
|
||||
|
||||
export default function AnalyticsTab({ _platformMetrics, _user }: AnalyticsTabProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [revenueChartData, setRevenueChartData] = useState<Record<string, unknown> | null>(null);
|
||||
const [topOrganizers, setTopOrganizers] = useState<Record<string, unknown>[]>([]);
|
||||
const [recentActivity, setRecentActivity] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalyticsData();
|
||||
}, []);
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadRevenueChart(),
|
||||
loadTopOrganizers(),
|
||||
loadRecentActivity()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Analytics data loading error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevenueChart = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=sales_trends');
|
||||
if (result.success) {
|
||||
setRevenueChartData(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Revenue chart loading error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTopOrganizers = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=organizer_performance&limit=5');
|
||||
if (result.success) {
|
||||
setTopOrganizers(result.data.organizers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Top organizers loading error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentActivity = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=recent_activity');
|
||||
if (result.success) {
|
||||
setRecentActivity(result.data.activities || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recent activity loading error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatActivity = (activity: Record<string, unknown>) => {
|
||||
let icon = '📊';
|
||||
let title = '';
|
||||
let subtitle = '';
|
||||
|
||||
switch (activity.type) {
|
||||
case 'ticket_sale':
|
||||
icon = '🎫';
|
||||
title = `New ticket sale: $${activity.amount}`;
|
||||
subtitle = `${activity.event_name} by ${activity.organization_name}`;
|
||||
break;
|
||||
case 'event_created':
|
||||
icon = '📅';
|
||||
title = `New event: ${activity.name}`;
|
||||
subtitle = `Created by ${activity.organization_name}`;
|
||||
break;
|
||||
case 'organizer_joined':
|
||||
icon = '🏢';
|
||||
title = `New organizer: ${activity.name}`;
|
||||
subtitle = `Organization registered`;
|
||||
break;
|
||||
default:
|
||||
title = activity.description || 'Platform activity';
|
||||
subtitle = activity.details || '';
|
||||
}
|
||||
|
||||
return { icon, title, subtitle };
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-2xl font-light text-white">Platform Analytics</h3>
|
||||
<button
|
||||
onClick={loadAnalyticsData}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
{/* Revenue Trends Chart */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<h4 className="text-base md:text-lg font-medium text-white mb-3 md:mb-4">Revenue Trends</h4>
|
||||
{revenueChartData ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Total Revenue</span>
|
||||
<span className="text-white font-bold">
|
||||
${revenueChartData.summary?.totalRevenue?.toLocaleString() || '0'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Average Monthly</span>
|
||||
<span className="text-white font-bold">
|
||||
${revenueChartData.summary?.averageMonthlyRevenue?.toFixed(2) || '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Growth Rate</span>
|
||||
<span className={`font-bold ${
|
||||
(revenueChartData.summary?.growth || 0) > 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{(revenueChartData.summary?.growth || 0) > 0 ? '+' : ''}
|
||||
{revenueChartData.summary?.growth?.toFixed(1) || '0.0'}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-center py-8">No revenue data available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Organizers */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<h4 className="text-base md:text-lg font-medium text-white mb-3 md:mb-4">Top Organizers</h4>
|
||||
{topOrganizers.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{topOrganizers.slice(0, 5).map((organizer, index) => (
|
||||
<div key={organizer.id} className="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">#{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{organizer.name}</div>
|
||||
<div className="text-white/60 text-sm">{organizer.eventCount} events</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-white font-bold">${organizer.totalRevenue?.toLocaleString() || '0'}</div>
|
||||
<div className="text-white/60 text-sm">{organizer.ticketsSold || 0} tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-center py-8">No organizer data available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-6 shadow-lg lg:col-span-2">
|
||||
<h4 className="text-base md:text-lg font-medium text-white mb-3 md:mb-4">Recent Activity</h4>
|
||||
{recentActivity.length > 0 ? (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{recentActivity.slice(0, 10).map((activity, index) => {
|
||||
const { icon, title, subtitle } = formatActivity(activity);
|
||||
return (
|
||||
<div key={index} className="flex items-start space-x-3 p-3 bg-white/5 rounded-lg">
|
||||
<div className="text-lg">{icon}</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium text-sm">{title}</p>
|
||||
<p className="text-white/60 text-xs">{subtitle}</p>
|
||||
<p className="text-white/40 text-xs mt-1">
|
||||
{new Date(activity.date || activity.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-center py-8">No recent activity</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
src/components/admin/EventsTab.tsx
Normal file
291
src/components/admin/EventsTab.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { makeAuthenticatedRequest } from '../../lib/api-client';
|
||||
|
||||
interface EventsTabProps {
|
||||
platformMetrics: Record<string, unknown> | null;
|
||||
user: { id: string; email: string } | null;
|
||||
}
|
||||
|
||||
export default function EventsTab({ _platformMetrics, _user }: EventsTabProps) {
|
||||
const [activeSection, setActiveSection] = useState('events');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [events, setEvents] = useState<Record<string, unknown>[]>([]);
|
||||
const [tickets, setTickets] = useState<Record<string, unknown>[]>([]);
|
||||
const [ticketStats, setTicketStats] = useState<Record<string, unknown> | null>(null);
|
||||
const [_filters, _setFilters] = useState<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === 'events') {
|
||||
loadEvents();
|
||||
} else {
|
||||
loadTickets();
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=event_analytics');
|
||||
if (result.success) {
|
||||
setEvents(result.data.events || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Events loading error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTickets = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=ticket_analytics&limit=50');
|
||||
if (result.success) {
|
||||
setTickets(result.data.tickets || []);
|
||||
setTicketStats(result.data.stats || {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Tickets loading error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editEvent = (eventId: string) => {
|
||||
window.open(`/events/${eventId}/manage`, '_blank');
|
||||
};
|
||||
|
||||
const viewOrganizerDashboard = (organizationId: string) => {
|
||||
window.open(`/admin/organizer-view/${organizationId}`, '_blank');
|
||||
};
|
||||
|
||||
const _deleteEvent = (_eventId: string) => {
|
||||
if (confirm('Are you sure you want to delete this event? This action cannot be undone.')) {
|
||||
|
||||
// Implement event deletion API call
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'status-pill status-success';
|
||||
case 'used': return 'status-pill status-info';
|
||||
case 'refunded': return 'status-pill status-error';
|
||||
case 'cancelled': return 'status-pill status-neutral';
|
||||
default: return 'status-pill status-neutral';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<h3 className="text-xl md:text-2xl font-light text-white">Events & Tickets Management</h3>
|
||||
|
||||
{/* Section Toggle */}
|
||||
<div className="flex items-center bg-white/10 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveSection('events')}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||
activeSection === 'events'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Events
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('tickets')}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||
activeSection === 'tickets'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Tickets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSection === 'events' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Events Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => window.open('/events/new', '_blank')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white rounded-lg text-sm font-medium transition-all"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Event
|
||||
</button>
|
||||
<button
|
||||
onClick={loadEvents}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Events Table */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[700px]">
|
||||
<thead className="bg-white/10 backdrop-blur-xl">
|
||||
<tr>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Event</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm hidden md:table-cell">Organizer</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Date</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Revenue</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Tickets</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{events.map(event => {
|
||||
const eventDate = new Date(event.startTime);
|
||||
const _isPast = eventDate < new Date();
|
||||
|
||||
return (
|
||||
<tr key={event.id} className="hover:bg-white/10">
|
||||
<td className="p-3 md:p-4">
|
||||
<div className="text-white font-medium text-sm">{event.title}</div>
|
||||
<div className="text-white/60 text-xs">{event.sellThroughRate?.toFixed(1) || 0}% sold</div>
|
||||
<div className="md:hidden text-white/60 text-xs mt-1">{event.organizerName || 'Unknown'}</div>
|
||||
</td>
|
||||
<td className="p-3 md:p-4 text-white/80 text-sm hidden md:table-cell">{event.organizerName || 'Unknown'}</td>
|
||||
<td className="p-3 md:p-4 text-white/80 text-sm">
|
||||
<div>{eventDate.toLocaleDateString()}</div>
|
||||
<div className="text-xs text-white/60">{eventDate.toLocaleTimeString()}</div>
|
||||
</td>
|
||||
<td className="p-3 md:p-4 text-white/80 text-sm">${(event.totalRevenue || 0).toLocaleString()}</td>
|
||||
<td className="p-3 md:p-4 text-white/80 text-sm">{event.ticketsSold || 0}</td>
|
||||
<td className="p-3 md:p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-1">
|
||||
<button
|
||||
onClick={() => editEvent(event.id)}
|
||||
className="bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => viewOrganizerDashboard(event.organizationId)}
|
||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
Org
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{events.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-4 text-white/60 text-center">No events found</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Ticket Stats */}
|
||||
{ticketStats && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-3 md:p-4">
|
||||
<div className="text-xs md:text-sm text-white/60">Total</div>
|
||||
<div className="text-lg md:text-2xl font-bold text-white">{ticketStats.total || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-3 md:p-4">
|
||||
<div className="text-xs md:text-sm text-white/60">Active</div>
|
||||
<div className="text-lg md:text-2xl font-bold" style={{ color: 'var(--success-color)' }}>{ticketStats.active || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-3 md:p-4">
|
||||
<div className="text-xs md:text-sm text-white/60">Used</div>
|
||||
<div className="text-lg md:text-2xl font-bold" style={{ color: 'var(--glass-text-accent)' }}>{ticketStats.used || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-3 md:p-4">
|
||||
<div className="text-xs md:text-sm text-white/60">Refunded</div>
|
||||
<div className="text-lg md:text-2xl font-bold" style={{ color: 'var(--error-color)' }}>{ticketStats.refunded || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tickets Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={loadTickets}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh Tickets
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tickets Table */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-white font-medium text-sm">Customer</th>
|
||||
<th className="text-left p-3 text-white font-medium text-sm hidden md:table-cell">Event</th>
|
||||
<th className="text-left p-3 text-white font-medium text-sm">Price</th>
|
||||
<th className="text-left p-3 text-white font-medium text-sm">Status</th>
|
||||
<th className="text-left p-3 text-white font-medium text-sm">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{tickets.map((ticket, index) => (
|
||||
<tr key={ticket.id || index} className="hover:bg-white/10">
|
||||
<td className="p-3">
|
||||
<div className="text-white font-medium text-sm">{ticket.customerName || 'N/A'}</div>
|
||||
<div className="text-white/60 text-xs">{ticket.customerEmail || 'N/A'}</div>
|
||||
<div className="md:hidden text-white/60 text-xs mt-1">{ticket.event?.title || 'N/A'}</div>
|
||||
</td>
|
||||
<td className="p-3 hidden md:table-cell">
|
||||
<div className="text-white font-medium text-sm">{ticket.event?.title || 'N/A'}</div>
|
||||
<div className="text-white/60 text-xs">{ticket.event?.organizationName || 'N/A'}</div>
|
||||
</td>
|
||||
<td className="p-3 text-white/80 text-sm">${(ticket.price || 0).toFixed(2)}</td>
|
||||
<td className="p-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${getStatusColor(ticket.status || 'unknown')}`}>
|
||||
{(ticket.status || 'unknown').charAt(0).toUpperCase() + (ticket.status || 'unknown').slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-white/80 text-xs">
|
||||
{ticket.createdAt ? new Date(ticket.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tickets.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-white/60 text-center">No tickets found</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
400
src/components/admin/ManagementTab.tsx
Normal file
400
src/components/admin/ManagementTab.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { makeAuthenticatedRequest } from '../../lib/api-client';
|
||||
|
||||
interface ManagementTabProps {
|
||||
platformMetrics: Record<string, unknown> | null;
|
||||
user: { id: string; email: string } | null;
|
||||
}
|
||||
|
||||
export default function ManagementTab({ _platformMetrics, _user }: ManagementTabProps) {
|
||||
const [activeSection, setActiveSection] = useState('features');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [territoryManagerStats, setTerritoryManagerStats] = useState<Record<string, unknown>>({});
|
||||
const [featureToggles, setFeatureToggles] = useState<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === 'features') {
|
||||
loadFeaturesData();
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const loadFeaturesData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadTerritoryManagerStats(),
|
||||
loadFeatureToggles()
|
||||
]);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTerritoryManagerStats = async () => {
|
||||
try {
|
||||
// Mock data for now - replace with actual API call
|
||||
setTerritoryManagerStats({
|
||||
activeManagers: 3,
|
||||
pendingApplications: 2,
|
||||
totalCommissions: 5910.00
|
||||
});
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
const loadFeatureToggles = async () => {
|
||||
try {
|
||||
// For now, Territory Manager is always enabled (development)
|
||||
setFeatureToggles({
|
||||
territoryManagerEnabled: true
|
||||
});
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerritoryManagerToggle = (enabled: boolean) => {
|
||||
// Show development warning
|
||||
alert('⚠️ Development Feature: The Territory Manager system is currently in development. Toggle changes will not persist between sessions.');
|
||||
|
||||
setFeatureToggles(prev => ({
|
||||
...prev,
|
||||
territoryManagerEnabled: enabled
|
||||
}));
|
||||
|
||||
};
|
||||
|
||||
const exportData = async (type: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let data: Record<string, unknown>[] = [];
|
||||
let filename = '';
|
||||
|
||||
switch (type) {
|
||||
case 'revenue': {
|
||||
{
|
||||
const revenueResponse = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=revenue_breakdown');
|
||||
if (revenueResponse.success) {
|
||||
const totals = revenueResponse.data.totals || {};
|
||||
data = [{
|
||||
gross_revenue: totals.grossRevenue || 0,
|
||||
platform_fees: totals.platformFees || 0,
|
||||
net_revenue: (totals.grossRevenue || 0) - (totals.platformFees || 0),
|
||||
export_date: new Date().toISOString()
|
||||
}];
|
||||
}
|
||||
filename = `revenue-report-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
case 'organizers': {
|
||||
{
|
||||
const organizerResponse = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=organizer_performance');
|
||||
if (organizerResponse.success) {
|
||||
data = (organizerResponse.data.organizers || []).map((org: Record<string, unknown>) => ({
|
||||
name: org.name,
|
||||
events: org.eventCount || 0,
|
||||
tickets_sold: org.ticketsSold || 0,
|
||||
total_revenue: org.totalRevenue || 0,
|
||||
platform_fees: org.platformFees || 0,
|
||||
avg_ticket_price: org.avgTicketPrice || 0,
|
||||
join_date: org.joinDate || ''
|
||||
}));
|
||||
}
|
||||
filename = `organizers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
case 'events': {
|
||||
{
|
||||
const eventResponse = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=event_analytics');
|
||||
if (eventResponse.success) {
|
||||
data = (eventResponse.data.events || []).map((event: Record<string, unknown>) => ({
|
||||
title: event.title,
|
||||
organization_id: event.organizationId,
|
||||
start_time: event.startTime,
|
||||
is_published: event.isPublished,
|
||||
category: event.category || '',
|
||||
tickets_sold: event.ticketsSold || 0,
|
||||
total_revenue: event.totalRevenue || 0,
|
||||
sell_through_rate: event.sellThroughRate || 0
|
||||
}));
|
||||
}
|
||||
filename = `events-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
case 'tickets': {
|
||||
{
|
||||
const ticketResponse = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=platform_overview');
|
||||
if (ticketResponse.success) {
|
||||
const summary = ticketResponse.data.summary || {};
|
||||
data = [{
|
||||
total_tickets: summary.totalTickets || 0,
|
||||
total_revenue: summary.totalRevenue || 0,
|
||||
platform_fees: summary.totalPlatformFees || 0,
|
||||
export_note: 'Summary data only - detailed ticket data available on request'
|
||||
}];
|
||||
}
|
||||
filename = `tickets-summary-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to CSV and download
|
||||
const csvContent = convertToCSV(data);
|
||||
downloadCSV(csvContent, filename);
|
||||
|
||||
} catch (_error) {
|
||||
alert('Error exporting data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const convertToCSV = (data: Record<string, unknown>[]) => {
|
||||
if (!data.length) return '';
|
||||
|
||||
const headers = Object.keys(data[0]).join(',');
|
||||
const rows = data.map(row =>
|
||||
Object.values(row).map(value =>
|
||||
typeof value === 'string' ? `"${value}"` : value
|
||||
).join(',')
|
||||
);
|
||||
|
||||
return [headers, ...rows].join('\n');
|
||||
};
|
||||
|
||||
const downloadCSV = (csvContent: string, filename: string) => {
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<h3 className="text-xl md:text-2xl font-light text-white">Platform Management</h3>
|
||||
|
||||
{/* Section Toggle */}
|
||||
<div className="flex items-center bg-white/10 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveSection('features')}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||
activeSection === 'features'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Features
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('export')}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||
activeSection === 'export'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Data Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSection === 'features' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Territory Manager Feature */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-white mb-2">Territory Manager System</h4>
|
||||
<p className="text-white/70 text-sm">
|
||||
Enable and manage the territory-based commission system for event organizers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="territory-manager-toggle"
|
||||
checked={featureToggles.territoryManagerEnabled}
|
||||
onChange={(e) => handleTerritoryManagerToggle(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor="territory-manager-toggle"
|
||||
className={`relative inline-block w-12 h-6 rounded-full cursor-pointer transition-colors ${
|
||||
featureToggles.territoryManagerEnabled ? 'status-pill status-success' : 'status-pill status-neutral'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
featureToggles.territoryManagerEnabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Territory Manager Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white/5 border border-white/20 rounded-lg p-4">
|
||||
<div className="text-sm text-white/60">Active Managers</div>
|
||||
<div className="text-2xl font-bold text-white">{territoryManagerStats.activeManagers || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/20 rounded-lg p-4">
|
||||
<div className="text-sm text-white/60">Pending Applications</div>
|
||||
<div className="text-2xl font-bold text-yellow-400">{territoryManagerStats.pendingApplications || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/20 rounded-lg p-4">
|
||||
<div className="text-sm text-white/60">Total Commissions</div>
|
||||
<div className="text-2xl font-bold style={{ color: 'var(--success-color)' }}">
|
||||
${(territoryManagerStats.totalCommissions || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => window.open('/admin/territory-manager/applications', '_blank')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white rounded-lg text-sm font-medium transition-all"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
View Applications
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open('/territory-manager/dashboard', '_blank')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2z" />
|
||||
</svg>
|
||||
Territory Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Export Options */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
|
||||
{/* Revenue Export */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 style={{ color: 'var(--success-color)' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-white">Revenue Data</h4>
|
||||
<p className="text-white/60 text-sm">Export platform revenue breakdown</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => exportData('revenue')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{loading ? 'Exporting...' : 'Export Revenue'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Organizers Export */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 style={{ color: 'var(--glass-text-accent)' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-white">Organizer Data</h4>
|
||||
<p className="text-white/60 text-sm">Export organizer performance metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => exportData('organizers')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{loading ? 'Exporting...' : 'Export Organizers'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Events Export */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2m-8 0v12a2 2 0 002 2h8a2 2 0 002-2V7m-8 0h8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-white">Event Data</h4>
|
||||
<p className="text-white/60 text-sm">Export all platform events</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => exportData('events')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{loading ? 'Exporting...' : 'Export Events'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tickets Export */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500/20 to-orange-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-white">Ticket Data</h4>
|
||||
<p className="text-white/60 text-sm">Export ticket summary data</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => exportData('tickets')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-700 hover:to-orange-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{loading ? 'Exporting...' : 'Export Tickets'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/components/admin/OrganizersTab.tsx
Normal file
241
src/components/admin/OrganizersTab.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { makeAuthenticatedRequest } from '../../lib/api-client';
|
||||
|
||||
interface OrganizersTabProps {
|
||||
platformMetrics: Record<string, unknown> | null;
|
||||
user: { id: string; email: string } | null;
|
||||
}
|
||||
|
||||
export default function OrganizersTab({ _platformMetrics, _user }: OrganizersTabProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [organizers, setOrganizers] = useState<Record<string, unknown>[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('totalRevenue');
|
||||
|
||||
useEffect(() => {
|
||||
loadOrganizers();
|
||||
}, []);
|
||||
|
||||
const loadOrganizers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=organizer_performance');
|
||||
if (result.success) {
|
||||
setOrganizers(result.data.organizers || []);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOrganizers = organizers
|
||||
.filter(org =>
|
||||
org.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
org.email?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'events':
|
||||
return (b.eventCount || 0) - (a.eventCount || 0);
|
||||
case 'revenue':
|
||||
return (b.totalRevenue || 0) - (a.totalRevenue || 0);
|
||||
case 'joinDate':
|
||||
return new Date(b.joinDate || 0).getTime() - new Date(a.joinDate || 0).getTime();
|
||||
default:
|
||||
return (b.totalRevenue || 0) - (a.totalRevenue || 0);
|
||||
}
|
||||
});
|
||||
|
||||
const getPerformanceColor = (revenue: number) => {
|
||||
if (revenue > 10000) return 'text-green-400';
|
||||
if (revenue > 5000) return 'text-yellow-400';
|
||||
return 'text-white';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4">
|
||||
<h3 className="text-xl md:text-2xl font-light text-white">Organizer Performance Dashboard</h3>
|
||||
<button
|
||||
onClick={loadOrganizers}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-white text-sm transition-colors self-start lg:self-auto"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Organizer Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/80">Total Organizers</p>
|
||||
<p className="text-2xl font-light text-white">{organizers.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/80">Avg Revenue</p>
|
||||
<p className="text-2xl font-light text-white">
|
||||
${organizers.length > 0 ?
|
||||
(organizers.reduce((sum, org) => sum + (org.totalRevenue || 0), 0) / organizers.length).toFixed(0) :
|
||||
'0'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/80">Avg Events</p>
|
||||
<p className="text-2xl font-light text-white">
|
||||
{organizers.length > 0 ?
|
||||
(organizers.reduce((sum, org) => sum + (org.eventCount || 0), 0) / organizers.length).toFixed(1) :
|
||||
'0'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search organizers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="revenue">Sort by Revenue</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="events">Sort by Events</option>
|
||||
<option value="joinDate">Sort by Join Date</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizers Table */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[700px]">
|
||||
<thead className="bg-white/10 backdrop-blur-xl">
|
||||
<tr>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Organization</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Events</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Revenue</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Platform Fees</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm hidden lg:table-cell">Avg. Ticket Price</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Status</th>
|
||||
<th className="text-left p-3 md:p-4 text-white font-medium text-sm">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{filteredOrganizers.map(org => (
|
||||
<tr key={org.id} className="hover:bg-white/10">
|
||||
<td className="p-3 md:p-4">
|
||||
<div className="text-white font-medium">{org.name}</div>
|
||||
<div className="text-white/60 text-sm">{org.email || 'No email'}</div>
|
||||
<div className="lg:hidden text-white/60 text-xs mt-1">
|
||||
Avg: ${(org.avgTicketPrice || 0).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 md:p-4 text-white/80">
|
||||
<div className="font-medium">{org.eventCount || 0}</div>
|
||||
<div className="text-white/60 text-xs">{org.publishedEvents || 0} published</div>
|
||||
</td>
|
||||
<td className="p-3 md:p-4">
|
||||
<div className={`font-bold ${getPerformanceColor(org.totalRevenue || 0)}`}>
|
||||
${(org.totalRevenue || 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-white/60 text-xs">{org.ticketsSold || 0} tickets sold</div>
|
||||
</td>
|
||||
<td className="p-3 md:p-4 text-white/80 font-medium">
|
||||
${(org.platformFees || 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="p-3 md:p-4 text-white/80 hidden lg:table-cell">
|
||||
${(org.avgTicketPrice || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="p-3 md:p-4">
|
||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded-full text-xs">
|
||||
Active
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 md:p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-1">
|
||||
<button
|
||||
onClick={() => window.open(`/admin/organizer-view/${org.id}`, '_blank')}
|
||||
className="bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`/admin/organizer/${org.id}/events`, '_blank')}
|
||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
Events
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredOrganizers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-4 text-white/60 text-center">
|
||||
{searchTerm ? 'No organizers match your search' : 'No organizers found'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
src/components/admin/RevenueTab.tsx
Normal file
266
src/components/admin/RevenueTab.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { makeAuthenticatedRequest } from '../../lib/api-client';
|
||||
|
||||
interface RevenueTabProps {
|
||||
platformMetrics: Record<string, unknown> | null;
|
||||
user: { id: string; email: string } | null;
|
||||
}
|
||||
|
||||
export default function RevenueTab({ _platformMetrics, _user }: RevenueTabProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [revenueBreakdown, setRevenueBreakdown] = useState<Record<string, unknown> | null>(null);
|
||||
const [monthlyComparison, setMonthlyComparison] = useState<Record<string, unknown> | null>(null);
|
||||
const [eventPerformance, setEventPerformance] = useState<Record<string, unknown> | null>(null);
|
||||
const [salesVelocity, setSalesVelocity] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadRevenueData();
|
||||
}, []);
|
||||
|
||||
const loadRevenueData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadRevenueBreakdown(),
|
||||
loadMonthlyComparison(),
|
||||
loadEventPerformance(),
|
||||
loadSalesVelocity()
|
||||
]);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevenueBreakdown = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=revenue_breakdown');
|
||||
if (result.success) {
|
||||
setRevenueBreakdown(result.data);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
const loadMonthlyComparison = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=sales_trends');
|
||||
if (result.success) {
|
||||
setMonthlyComparison(result.data);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
const loadEventPerformance = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=event_analytics');
|
||||
if (result.success) {
|
||||
setEventPerformance(result.data);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
const loadSalesVelocity = async () => {
|
||||
try {
|
||||
const result = await makeAuthenticatedRequest('/api/admin/super-analytics?metric=sales_trends');
|
||||
if (result.success) {
|
||||
setSalesVelocity(result.data);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-2xl font-light text-white">Revenue & Performance Analysis</h3>
|
||||
<button
|
||||
onClick={loadRevenueData}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
{/* Revenue Breakdown */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<h4 className="text-base md:text-lg font-medium text-white mb-3 md:mb-4">Revenue Breakdown</h4>
|
||||
{revenueBreakdown ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const totals = revenueBreakdown.totals || {};
|
||||
const processingFees = (totals.grossRevenue || 0) * 0.029;
|
||||
const netToOrganizers = (totals.grossRevenue || 0) - (totals.platformFees || 0) - processingFees;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Gross Revenue</span>
|
||||
<span className="text-white font-bold">${(totals.grossRevenue || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Platform Fees</span>
|
||||
<span className="text-white font-bold">${(totals.platformFees || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Processing Fees</span>
|
||||
<span className="text-white font-bold">${processingFees.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Net to Organizers</span>
|
||||
<span className="text-white font-bold">${netToOrganizers.toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-center py-8">No revenue breakdown available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monthly Comparison */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<h4 className="text-base md:text-lg font-medium text-white mb-3 md:mb-4">Monthly Comparison</h4>
|
||||
{monthlyComparison ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const summary = monthlyComparison.summary || {};
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Average Monthly Revenue</span>
|
||||
<span className="text-white font-bold">${(summary.averageMonthlyRevenue || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Revenue Growth</span>
|
||||
<span className={`font-bold ${
|
||||
(summary.growth || 0) > 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{(summary.growth || 0) > 0 ? '+' : ''}{(summary.growth || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Average Platform Fees</span>
|
||||
<span className="text-white font-bold">${(summary.averageMonthlyFees || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Total Periods</span>
|
||||
<span className="text-white font-bold">{summary.totalPeriods || 0}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-center py-8">No monthly comparison available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Performance */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<h4 className="text-base md:text-lg font-medium text-white mb-3 md:mb-4">Event Performance Metrics</h4>
|
||||
{eventPerformance ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const summary = eventPerformance.summary || {};
|
||||
const eventsThisMonth = Math.floor((summary.totalEvents || 0) / 12);
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Average Ticket Price</span>
|
||||
<span className="text-white font-bold">${(summary.avgTicketPrice || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Events This Month</span>
|
||||
<span className="text-white font-bold">{eventsThisMonth}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Avg. Tickets per Event</span>
|
||||
<span className="text-white font-bold">
|
||||
{(summary.totalEvents || 0) > 0 ? Math.floor((summary.totalTicketsSold || 0) / (summary.totalEvents || 1)) : 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Sell-through Rate</span>
|
||||
<span className="text-white font-bold">{(summary.avgSellThroughRate || 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-center py-8">No event performance data available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sales Velocity */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 md:p-6 shadow-lg">
|
||||
<h4 className="text-base md:text-lg font-medium text-white mb-3 md:mb-4">Sales Velocity</h4>
|
||||
{salesVelocity ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const trends = salesVelocity.trends || [];
|
||||
const recentTrends = trends.slice(-6);
|
||||
const totalTransactions = recentTrends.reduce((sum: number, t: Record<string, unknown>) => sum + (Number(t.transactions) || 0), 0);
|
||||
const totalRevenue = recentTrends.reduce((sum: number, t: Record<string, unknown>) => sum + (Number(t.revenue) || 0), 0);
|
||||
const avgTransactionValue = totalTransactions > 0 ? totalRevenue / totalTransactions : 0;
|
||||
|
||||
const currentMonth = recentTrends[recentTrends.length - 1] || {};
|
||||
const previousMonth = recentTrends[recentTrends.length - 2] || {};
|
||||
const momGrowth = previousMonth && (Number(previousMonth.revenue) || 0) > 0 ?
|
||||
(((Number(currentMonth.revenue) || 0) - (Number(previousMonth.revenue) || 0)) / (Number(previousMonth.revenue) || 1)) * 100 : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Recent Transactions</span>
|
||||
<span className="text-white font-bold">{totalTransactions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Avg Transaction Value</span>
|
||||
<span className="text-white font-bold">${avgTransactionValue.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Month-over-Month</span>
|
||||
<span className={`font-bold ${momGrowth > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{momGrowth > 0 ? '+' : ''}{momGrowth.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span className="text-white">Recent Revenue</span>
|
||||
<span className="text-white font-bold">${totalRevenue.toFixed(2)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-center py-8">No sales velocity data available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/admin/SuperAdminTabNavigation.tsx
Normal file
61
src/components/admin/SuperAdminTabNavigation.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
component: React.ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface SuperAdminTabNavigationProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
platformMetrics: Record<string, unknown> | null;
|
||||
user: { id: string; email: string } | null;
|
||||
}
|
||||
|
||||
export default function SuperAdminTabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
platformMetrics,
|
||||
user
|
||||
}: SuperAdminTabNavigationProps) {
|
||||
|
||||
const ActiveComponent = tabs.find(tab => tab.id === activeTab)?.component;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Navigation Tabs */}
|
||||
<div className="mb-6">
|
||||
<nav className="flex space-x-8 border-b border-white/20">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`tab-button border-b-2 py-2 px-4 text-sm font-medium transition-all duration-200 hover:bg-white/10 rounded-t-lg flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-indigo-400 text-white bg-white/10'
|
||||
: 'border-transparent text-white/80 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 shadow-lg rounded-2xl">
|
||||
{ActiveComponent && (
|
||||
<ActiveComponent
|
||||
platformMetrics={platformMetrics}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/components/craft/components/ButtonBlock.tsx
Normal file
142
src/components/craft/components/ButtonBlock.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { useNode } from '@craftjs/core';
|
||||
|
||||
interface ButtonBlockProps {
|
||||
text?: string;
|
||||
href?: string;
|
||||
target?: '_self' | '_blank';
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ButtonBlock: React.FC<ButtonBlockProps> = ({
|
||||
text = 'Button Text',
|
||||
href = '#',
|
||||
target = '_self',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-gradient-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700',
|
||||
secondary: 'bg-gradient-to-r from-slate-500 to-slate-600 text-white hover:from-slate-600 hover:to-slate-700',
|
||||
outline: 'border-2 border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white',
|
||||
ghost: 'text-blue-500 hover:bg-blue-50'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg'
|
||||
};
|
||||
|
||||
const widthClass = fullWidth ? 'w-full' : 'w-auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`p-2 ${className}`}
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
className={`
|
||||
inline-flex items-center justify-center
|
||||
font-medium rounded-lg
|
||||
transition-all duration-200
|
||||
shadow-lg hover:shadow-xl
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${widthClass}
|
||||
`}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonBlock.craft = {
|
||||
displayName: 'Button Block',
|
||||
props: {
|
||||
text: 'Button Text',
|
||||
href: '#',
|
||||
target: '_self',
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
fullWidth: false,
|
||||
className: ''
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Button Text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Button Text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Link URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Target
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="_self">Same Window</option>
|
||||
<option value="_blank">New Window</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Variant
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="primary">Primary</option>
|
||||
<option value="secondary">Secondary</option>
|
||||
<option value="outline">Outline</option>
|
||||
<option value="ghost">Ghost</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Size
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Full Width
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
105
src/components/craft/components/EventDetails.tsx
Normal file
105
src/components/craft/components/EventDetails.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { useNode } from '@craftjs/core';
|
||||
|
||||
interface EventDetailsProps {
|
||||
showVenue?: boolean;
|
||||
showDateTime?: boolean;
|
||||
showDescription?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EventDetails: React.FC<EventDetailsProps> = ({
|
||||
showVenue = true,
|
||||
showDateTime = true,
|
||||
showDescription = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag }, custom } = useNode();
|
||||
const { event, formattedDate, formattedTime } = custom || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`bg-gradient-to-br from-slate-50 to-white rounded-xl p-4 sm:p-5 border border-slate-200 shadow-lg ${className}`}
|
||||
>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-slate-900 mb-3 sm:mb-4 flex items-center">
|
||||
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full mr-2"></div>
|
||||
Event Details
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Venue */}
|
||||
{showVenue && event?.venue && (
|
||||
<div className="flex items-start p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-green-500 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-slate-900">Venue</p>
|
||||
<p className="text-slate-600 text-sm break-words">{event.venue}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date & Time */}
|
||||
{showDateTime && formattedDate && (
|
||||
<div className="flex items-start p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-slate-900">Date & Time</p>
|
||||
<p className="text-slate-600 text-sm break-words">{formattedDate} at {formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{showDescription && event?.description && (
|
||||
<div className="mt-4 p-3 sm:p-4 bg-white rounded-lg border border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-2 flex items-center">
|
||||
<div className="w-1 h-1 bg-gradient-to-r from-amber-400 to-orange-500 rounded-full mr-2"></div>
|
||||
About This Event
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm whitespace-pre-line leading-relaxed break-words">{event.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EventDetails.craft = {
|
||||
displayName: 'Event Details',
|
||||
props: {
|
||||
showVenue: true,
|
||||
showDateTime: true,
|
||||
showDescription: true,
|
||||
className: ''
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Venue
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Date & Time
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Description
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
139
src/components/craft/components/HeroSection.tsx
Normal file
139
src/components/craft/components/HeroSection.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { useNode } from '@craftjs/core';
|
||||
|
||||
interface HeroSectionProps {
|
||||
backgroundImage?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showEventLogo?: boolean;
|
||||
showEventDate?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const HeroSection: React.FC<HeroSectionProps> = ({
|
||||
backgroundImage,
|
||||
title,
|
||||
subtitle,
|
||||
showEventLogo = true,
|
||||
showEventDate = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag }, custom } = useNode();
|
||||
const { event, formattedDate, formattedTime } = custom || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Background Image */}
|
||||
{(backgroundImage || event?.image_url) && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={backgroundImage || event?.image_url}
|
||||
alt={title || event?.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-40"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 px-4 sm:px-6 py-8 sm:py-12 lg:py-16">
|
||||
<div className="max-w-4xl mx-auto text-center text-white">
|
||||
{/* Event Logo */}
|
||||
{showEventLogo && event?.organizations?.logo && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={event.organizations.logo}
|
||||
alt={event.organizations.name}
|
||||
className="h-16 w-16 sm:h-20 sm:w-20 rounded-xl mx-auto shadow-lg border-2 border-white/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-light mb-4 tracking-wide">
|
||||
{title || event?.title || 'Event Title'}
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
{(subtitle || event?.organizations?.name) && (
|
||||
<p className="text-xl sm:text-2xl text-white/90 mb-6">
|
||||
{subtitle || `Presented by ${event?.organizations?.name}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Event Date */}
|
||||
{showEventDate && formattedDate && (
|
||||
<div className="inline-block bg-white/10 backdrop-blur-sm rounded-lg p-4 border border-white/20">
|
||||
<p className="text-sm text-white/80 uppercase tracking-wide font-medium mb-1">Event Date</p>
|
||||
<p className="text-lg sm:text-xl font-semibold text-white">{formattedDate}</p>
|
||||
{formattedTime && (
|
||||
<p className="text-white/90 text-sm">{formattedTime}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
HeroSection.craft = {
|
||||
displayName: 'Hero Section',
|
||||
props: {
|
||||
backgroundImage: '',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
showEventLogo: true,
|
||||
showEventDate: true,
|
||||
className: 'min-h-96'
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Background Image URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Custom Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Leave empty to use event title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Custom Subtitle
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Leave empty to use organization name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Event Logo
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Event Date
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
178
src/components/craft/components/ImageBlock.tsx
Normal file
178
src/components/craft/components/ImageBlock.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import { useNode } from '@craftjs/core';
|
||||
|
||||
interface ImageBlockProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
width?: 'auto' | 'full' | '1/2' | '1/3' | '2/3' | '1/4' | '3/4';
|
||||
height?: 'auto' | '32' | '48' | '64' | '80' | '96';
|
||||
objectFit?: 'cover' | 'contain' | 'fill' | 'scale-down';
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ImageBlock: React.FC<ImageBlockProps> = ({
|
||||
src = 'https://via.placeholder.com/400x300?text=Add+Image',
|
||||
alt = 'Image',
|
||||
width = 'full',
|
||||
height = 'auto',
|
||||
objectFit = 'cover',
|
||||
rounded = 'lg',
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag }, actions: { setProp: _setProp } } = useNode();
|
||||
|
||||
const widthClasses = {
|
||||
auto: 'w-auto',
|
||||
full: 'w-full',
|
||||
'1/2': 'w-1/2',
|
||||
'1/3': 'w-1/3',
|
||||
'2/3': 'w-2/3',
|
||||
'1/4': 'w-1/4',
|
||||
'3/4': 'w-3/4'
|
||||
};
|
||||
|
||||
const heightClasses = {
|
||||
auto: 'h-auto',
|
||||
'32': 'h-32',
|
||||
'48': 'h-48',
|
||||
'64': 'h-64',
|
||||
'80': 'h-80',
|
||||
'96': 'h-96'
|
||||
};
|
||||
|
||||
const objectFitClasses = {
|
||||
cover: 'object-cover',
|
||||
contain: 'object-contain',
|
||||
fill: 'object-fill',
|
||||
'scale-down': 'object-scale-down'
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
'2xl': 'rounded-2xl',
|
||||
full: 'rounded-full'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`p-2 ${className}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`
|
||||
${widthClasses[width]}
|
||||
${heightClasses[height]}
|
||||
${objectFitClasses[objectFit]}
|
||||
${roundedClasses[rounded]}
|
||||
shadow-lg
|
||||
`}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/400x300?text=Image+Not+Found';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ImageBlock.craft = {
|
||||
displayName: 'Image Block',
|
||||
props: {
|
||||
src: 'https://via.placeholder.com/400x300?text=Add+Image',
|
||||
alt: 'Image',
|
||||
width: 'full',
|
||||
height: 'auto',
|
||||
objectFit: 'cover',
|
||||
rounded: 'lg',
|
||||
className: ''
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Image URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Alt Text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Describe the image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Width
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="full">Full Width</option>
|
||||
<option value="1/2">Half Width</option>
|
||||
<option value="1/3">One Third</option>
|
||||
<option value="2/3">Two Thirds</option>
|
||||
<option value="1/4">One Quarter</option>
|
||||
<option value="3/4">Three Quarters</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Height
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="32">8rem</option>
|
||||
<option value="48">12rem</option>
|
||||
<option value="64">16rem</option>
|
||||
<option value="80">20rem</option>
|
||||
<option value="96">24rem</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Object Fit
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="cover">Cover</option>
|
||||
<option value="contain">Contain</option>
|
||||
<option value="fill">Fill</option>
|
||||
<option value="scale-down">Scale Down</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Border Radius
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="none">None</option>
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
<option value="xl">Extra Large</option>
|
||||
<option value="2xl">2X Large</option>
|
||||
<option value="full">Full (Circle)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
65
src/components/craft/components/SpacerBlock.tsx
Normal file
65
src/components/craft/components/SpacerBlock.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { useNode } from '@craftjs/core';
|
||||
|
||||
interface SpacerBlockProps {
|
||||
height?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SpacerBlock: React.FC<SpacerBlockProps> = ({
|
||||
height = 'md',
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const heightClasses = {
|
||||
xs: 'h-2',
|
||||
sm: 'h-4',
|
||||
md: 'h-8',
|
||||
lg: 'h-12',
|
||||
xl: 'h-16',
|
||||
'2xl': 'h-24',
|
||||
'3xl': 'h-32'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`${heightClasses[height]} ${className}`}
|
||||
style={{ minHeight: '0.5rem' }}
|
||||
>
|
||||
{/* Invisible spacer content for editor visibility */}
|
||||
<div className="h-full w-full border-2 border-dashed border-gray-300 opacity-20 flex items-center justify-center">
|
||||
<span className="text-xs text-gray-400">Spacer</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SpacerBlock.craft = {
|
||||
displayName: 'Spacer Block',
|
||||
props: {
|
||||
height: 'md',
|
||||
className: ''
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Height
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="xs">Extra Small (0.5rem)</option>
|
||||
<option value="sm">Small (1rem)</option>
|
||||
<option value="md">Medium (2rem)</option>
|
||||
<option value="lg">Large (3rem)</option>
|
||||
<option value="xl">Extra Large (4rem)</option>
|
||||
<option value="2xl">2X Large (6rem)</option>
|
||||
<option value="3xl">3X Large (8rem)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
137
src/components/craft/components/TextBlock.tsx
Normal file
137
src/components/craft/components/TextBlock.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { useNode } from '@craftjs/core';
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
|
||||
interface TextBlockProps {
|
||||
text?: string;
|
||||
fontSize?: 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl';
|
||||
fontWeight?: 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
color?: 'slate-900' | 'slate-600' | 'slate-500' | 'white';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TextBlock: React.FC<TextBlockProps> = ({
|
||||
text = 'Edit this text...',
|
||||
fontSize = 'base',
|
||||
fontWeight = 'normal',
|
||||
textAlign = 'left',
|
||||
color = 'slate-900',
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag }, actions: { setProp } } = useNode();
|
||||
|
||||
const fontSizeClasses = {
|
||||
sm: 'text-sm',
|
||||
base: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
'2xl': 'text-2xl',
|
||||
'3xl': 'text-3xl'
|
||||
};
|
||||
|
||||
const fontWeightClasses = {
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold'
|
||||
};
|
||||
|
||||
const textAlignClasses = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
'slate-900': 'text-slate-900',
|
||||
'slate-600': 'text-slate-600',
|
||||
'slate-500': 'text-slate-500',
|
||||
'white': 'text-white'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`p-2 ${className}`}
|
||||
>
|
||||
<ContentEditable
|
||||
html={text}
|
||||
onChange={(e) => setProp((props: { text: string }) => props.text = e.target.value)}
|
||||
className={`
|
||||
outline-none
|
||||
${fontSizeClasses[fontSize]}
|
||||
${fontWeightClasses[fontWeight]}
|
||||
${textAlignClasses[textAlign]}
|
||||
${colorClasses[color]}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TextBlock.craft = {
|
||||
displayName: 'Text Block',
|
||||
props: {
|
||||
text: 'Edit this text...',
|
||||
fontSize: 'base',
|
||||
fontWeight: 'normal',
|
||||
textAlign: 'left',
|
||||
color: 'slate-900',
|
||||
className: ''
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Font Size
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="sm">Small</option>
|
||||
<option value="base">Base</option>
|
||||
<option value="lg">Large</option>
|
||||
<option value="xl">Extra Large</option>
|
||||
<option value="2xl">2X Large</option>
|
||||
<option value="3xl">3X Large</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Font Weight
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="semibold">Semibold</option>
|
||||
<option value="bold">Bold</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Text Alignment
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Text Color
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="slate-900">Dark</option>
|
||||
<option value="slate-600">Medium</option>
|
||||
<option value="slate-500">Light</option>
|
||||
<option value="white">White</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
100
src/components/craft/components/TicketSection.tsx
Normal file
100
src/components/craft/components/TicketSection.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useNode } from '@craftjs/core';
|
||||
import TicketCheckout from '../../TicketCheckout';
|
||||
|
||||
interface TicketSectionProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showStats?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TicketSection: React.FC<TicketSectionProps> = ({
|
||||
title = 'Get Your Tickets',
|
||||
subtitle = '',
|
||||
showStats = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag }, custom } = useNode();
|
||||
const { event } = custom || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`bg-gradient-to-br from-slate-50 to-white rounded-xl p-4 sm:p-5 border border-slate-200 shadow-lg ${className}`}
|
||||
>
|
||||
<div className="mb-3 sm:mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-slate-900 flex items-center">
|
||||
<div className="w-2 h-2 bg-gradient-to-r from-emerald-500 to-green-500 rounded-full mr-2"></div>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-slate-600 text-sm mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStats && event?.ticket_types && (
|
||||
<div className="mb-4 p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wide">Available</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{event.ticket_types.reduce((sum: number, type: { quantity_available: number; quantity_sold: number }) => sum + (type.quantity_available - type.quantity_sold), 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wide">Sold</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{event.ticket_types.reduce((sum: number, type: { quantity_sold: number }) => sum + type.quantity_sold, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event && <TicketCheckout event={event} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TicketSection.craft = {
|
||||
displayName: 'Ticket Section',
|
||||
props: {
|
||||
title: 'Get Your Tickets',
|
||||
subtitle: '',
|
||||
showStats: false,
|
||||
className: ''
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Section Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Get Your Tickets"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subtitle
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Optional subtitle"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Ticket Statistics
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
107
src/components/craft/components/TwoColumnLayout.tsx
Normal file
107
src/components/craft/components/TwoColumnLayout.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { useNode, Element } from '@craftjs/core';
|
||||
|
||||
interface TwoColumnLayoutProps {
|
||||
leftWidth?: '1/3' | '1/2' | '2/3';
|
||||
gap?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TwoColumnLayout: React.FC<TwoColumnLayoutProps> = ({
|
||||
leftWidth = '1/2',
|
||||
gap = 'md',
|
||||
className = ''
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const leftWidthClasses = {
|
||||
'1/3': 'w-1/3',
|
||||
'1/2': 'w-1/2',
|
||||
'2/3': 'w-2/3'
|
||||
};
|
||||
|
||||
const rightWidthClasses = {
|
||||
'1/3': 'w-2/3',
|
||||
'1/2': 'w-1/2',
|
||||
'2/3': 'w-1/3'
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-2',
|
||||
md: 'gap-4',
|
||||
lg: 'gap-6',
|
||||
xl: 'gap-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => connect(drag(ref))}
|
||||
className={`flex flex-col lg:flex-row ${gapClasses[gap]} ${className}`}
|
||||
>
|
||||
{/* Left Column */}
|
||||
<div className={`${leftWidthClasses[leftWidth]} min-h-24`}>
|
||||
<Element
|
||||
id="left-column"
|
||||
is="div"
|
||||
canvas
|
||||
className="w-full h-full p-2 border-2 border-dashed border-gray-300 rounded-lg"
|
||||
>
|
||||
<div className="text-center text-gray-400 text-sm py-4">
|
||||
Drop components here
|
||||
</div>
|
||||
</Element>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className={`${rightWidthClasses[leftWidth]} min-h-24`}>
|
||||
<Element
|
||||
id="right-column"
|
||||
is="div"
|
||||
canvas
|
||||
className="w-full h-full p-2 border-2 border-dashed border-gray-300 rounded-lg"
|
||||
>
|
||||
<div className="text-center text-gray-400 text-sm py-4">
|
||||
Drop components here
|
||||
</div>
|
||||
</Element>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TwoColumnLayout.craft = {
|
||||
displayName: 'Two Column Layout',
|
||||
props: {
|
||||
leftWidth: '1/2',
|
||||
gap: 'md',
|
||||
className: ''
|
||||
},
|
||||
related: {
|
||||
toolbar: () => (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Left Column Width
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="1/3">One Third</option>
|
||||
<option value="1/2">Half</option>
|
||||
<option value="2/3">Two Thirds</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Column Gap
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
<option value="xl">Extra Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
9
src/components/craft/components/index.ts
Normal file
9
src/components/craft/components/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Export all Craft.js components
|
||||
export { HeroSection } from './HeroSection';
|
||||
export { EventDetails } from './EventDetails';
|
||||
export { TicketSection } from './TicketSection';
|
||||
export { TextBlock } from './TextBlock';
|
||||
export { ImageBlock } from './ImageBlock';
|
||||
export { ButtonBlock } from './ButtonBlock';
|
||||
export { SpacerBlock } from './SpacerBlock';
|
||||
export { TwoColumnLayout } from './TwoColumnLayout';
|
||||
@@ -76,8 +76,8 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
||||
|
||||
setAvailableAddons(addonsData || []);
|
||||
setEventAddons(eventAddonsData || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading addons:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -112,8 +112,8 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
||||
if (error) throw error;
|
||||
|
||||
setEventAddons(prev => [...prev, data]);
|
||||
} catch (error) {
|
||||
console.error('Error adding addon:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,8 +127,8 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
||||
if (error) throw error;
|
||||
|
||||
setEventAddons(prev => prev.filter(ea => ea.id !== eventAddon.id));
|
||||
} catch (error) {
|
||||
console.error('Error removing addon:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,8 +144,8 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
||||
setEventAddons(prev => prev.map(ea =>
|
||||
ea.id === eventAddon.id ? { ...ea, is_active: !ea.is_active } : ea
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Error toggling addon:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
@@ -343,7 +343,10 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAddAddon(addon)}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||
className="px-3 py-1 text-white rounded-lg text-sm transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)'
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
|
||||
@@ -39,8 +39,8 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
try {
|
||||
const ordersData = await loadSalesData(eventId);
|
||||
setOrders(ordersData);
|
||||
} catch (error) {
|
||||
console.error('Error loading attendees data:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -50,9 +50,9 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
const attendeeMap = new Map<string, AttendeeData>();
|
||||
|
||||
orders.forEach(order => {
|
||||
const existing = attendeeMap.get(order.customer_email) || {
|
||||
email: order.customer_email,
|
||||
name: order.customer_name,
|
||||
const existing = attendeeMap.get(order.purchaser_email) || {
|
||||
email: order.purchaser_email,
|
||||
name: order.purchaser_name,
|
||||
ticketCount: 0,
|
||||
totalSpent: 0,
|
||||
checkedInCount: 0,
|
||||
@@ -60,19 +60,22 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
};
|
||||
|
||||
existing.tickets.push(order);
|
||||
if (order.status === 'confirmed') {
|
||||
if (!order.refund_status || order.refund_status === null) {
|
||||
existing.ticketCount += 1;
|
||||
existing.totalSpent += order.price_paid;
|
||||
existing.totalSpent += order.price;
|
||||
if (order.checked_in) {
|
||||
existing.checkedInCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
attendeeMap.set(order.customer_email, existing);
|
||||
attendeeMap.set(order.purchaser_email, existing);
|
||||
});
|
||||
|
||||
let processedAttendees = Array.from(attendeeMap.values());
|
||||
|
||||
// Only show attendees with active tickets (ticketCount > 0)
|
||||
processedAttendees = processedAttendees.filter(attendee => attendee.ticketCount > 0);
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
@@ -103,7 +106,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
|
||||
const handleCheckInAttendee = async (attendee: AttendeeData) => {
|
||||
const unCheckedTickets = attendee.tickets.filter(ticket =>
|
||||
!ticket.checked_in && ticket.status === 'confirmed'
|
||||
!ticket.checked_in && (!ticket.refund_status || ticket.refund_status === null)
|
||||
);
|
||||
|
||||
if (unCheckedTickets.length === 0) return;
|
||||
@@ -120,7 +123,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
|
||||
const handleRefundAttendee = async (attendee: AttendeeData) => {
|
||||
const confirmedTickets = attendee.tickets.filter(ticket =>
|
||||
ticket.status === 'confirmed'
|
||||
(!ticket.refund_status || ticket.refund_status === null)
|
||||
);
|
||||
|
||||
if (confirmedTickets.length === 0) return;
|
||||
@@ -134,7 +137,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
|
||||
setOrders(prev => prev.map(order =>
|
||||
confirmedTickets.some(t => t.id === order.id)
|
||||
? { ...order, status: 'refunded' }
|
||||
? { ...order, refund_status: 'completed' }
|
||||
: order
|
||||
));
|
||||
}
|
||||
@@ -142,7 +145,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
|
||||
const handleBulkCheckIn = async () => {
|
||||
const unCheckedTickets = orders.filter(order =>
|
||||
!order.checked_in && order.status === 'confirmed'
|
||||
!order.checked_in && (!order.refund_status || order.refund_status === null)
|
||||
);
|
||||
|
||||
if (unCheckedTickets.length === 0) {
|
||||
@@ -198,7 +201,11 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleBulkCheckIn}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
style={{
|
||||
background: 'var(--success-color)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
@@ -334,21 +341,23 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
Purchased: {new Date(ticket.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm font-mono">
|
||||
ID: {ticket.ticket_uuid}
|
||||
ID: {ticket.uuid}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-white font-bold">{formatCurrency(ticket.price_paid)}</div>
|
||||
<div className="text-white font-bold">{formatCurrency(ticket.price)}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
ticket.status === 'confirmed' ? 'bg-green-500/20 text-green-300 border border-green-500/30' :
|
||||
ticket.status === 'refunded' ? 'bg-red-500/20 text-red-300 border border-red-500/30' :
|
||||
'bg-yellow-500/20 text-yellow-300 border border-yellow-500/30'
|
||||
(!ticket.refund_status || ticket.refund_status === null) ? 'status-pill status-success' :
|
||||
ticket.refund_status === 'completed' ? 'status-pill status-error' :
|
||||
'status-pill status-warning'
|
||||
}`}>
|
||||
{ticket.status}
|
||||
{(!ticket.refund_status || ticket.refund_status === null) ? 'confirmed' :
|
||||
ticket.refund_status === 'completed' ? 'refunded' :
|
||||
ticket.refund_status === 'pending' ? 'pending' : 'failed'}
|
||||
</span>
|
||||
{ticket.checked_in ? (
|
||||
<span className="px-2 py-1 text-xs bg-green-500/20 text-green-300 border border-green-500/30 rounded-full">
|
||||
<span className="status-pill status-success">
|
||||
Checked In
|
||||
</span>
|
||||
) : (
|
||||
@@ -378,7 +387,10 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
handleCheckInAttendee(selectedAttendee);
|
||||
setShowAttendeeDetails(false);
|
||||
}}
|
||||
className="px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
className="px-6 py-3 text-white rounded-lg transition-colors"
|
||||
style={{
|
||||
background: 'var(--success-color)'
|
||||
}}
|
||||
>
|
||||
Check In
|
||||
</button>
|
||||
@@ -389,7 +401,10 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
||||
handleRefundAttendee(selectedAttendee);
|
||||
setShowAttendeeDetails(false);
|
||||
}}
|
||||
className="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
className="px-6 py-3 text-white rounded-lg transition-colors"
|
||||
style={{
|
||||
background: 'var(--error-color)'
|
||||
}}
|
||||
>
|
||||
Refund All
|
||||
</button>
|
||||
|
||||
589
src/components/manage/CustomPageTab.tsx
Normal file
589
src/components/manage/CustomPageTab.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PageBuilder from '../PageBuilder';
|
||||
import TemplateManager from '../TemplateManager';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
interface CustomPageTabProps {
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
eventSlug?: string | null;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preview_image_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface CustomPage {
|
||||
id: string;
|
||||
custom_slug: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
template_id?: string;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
view_count: number;
|
||||
template?: Template;
|
||||
}
|
||||
|
||||
const CustomPageTab: React.FC<CustomPageTabProps> = ({
|
||||
eventId,
|
||||
organizationId,
|
||||
eventSlug
|
||||
}) => {
|
||||
const [customPages, setCustomPages] = useState<CustomPage[]>([]);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPageBuilder, setShowPageBuilder] = useState(false);
|
||||
const [showTemplateManager, setShowTemplateManager] = useState(false);
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [_selectedTemplateId, _setSelectedTemplateId] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newPageData, setNewPageData] = useState({
|
||||
custom_slug: '',
|
||||
meta_title: '',
|
||||
meta_description: '',
|
||||
template_id: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [eventId, organizationId]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
console.log('Loading data for:', { organizationId, eventId });
|
||||
|
||||
// Get auth token
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
console.log('Session state:', { hasSession: !!session, hasToken: !!session?.access_token });
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (session?.access_token) {
|
||||
headers['Authorization'] = `Bearer ${session.access_token}`;
|
||||
}
|
||||
|
||||
console.log('Making API calls...');
|
||||
const [pagesRes, templatesRes] = await Promise.all([
|
||||
fetch(`/api/custom-pages?organization_id=${organizationId}&event_id=${eventId}`, { headers }),
|
||||
fetch(`/api/templates?organization_id=${organizationId}`, { headers })
|
||||
]);
|
||||
|
||||
const pagesData = await pagesRes.json();
|
||||
const templatesData = await templatesRes.json();
|
||||
|
||||
console.log('Pages response:', { status: pagesRes.status, data: pagesData });
|
||||
console.log('Templates response:', { status: templatesRes.status, data: templatesData });
|
||||
|
||||
if (pagesData.success) {
|
||||
setCustomPages(pagesData.pages || []);
|
||||
} else {
|
||||
console.error('Failed to load pages:', pagesData);
|
||||
setCustomPages([]);
|
||||
}
|
||||
|
||||
if (templatesData.success) {
|
||||
setTemplates(templatesData.templates || []);
|
||||
} else {
|
||||
console.error('Failed to load templates:', templatesData);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in loadData:', error);
|
||||
setCustomPages([]);
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCustomPage = async () => {
|
||||
if (!newPageData.custom_slug.trim()) {
|
||||
alert('Please enter a custom URL slug');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
alert('Please log in to create custom pages');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
organization_id: organizationId,
|
||||
event_id: eventId,
|
||||
template_id: newPageData.template_id || null,
|
||||
custom_slug: newPageData.custom_slug.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
||||
meta_title: newPageData.meta_title,
|
||||
meta_description: newPageData.meta_description,
|
||||
created_by: session.user.id
|
||||
};
|
||||
|
||||
console.log('Sending request body:', requestBody);
|
||||
console.log('JSON string:', JSON.stringify(requestBody));
|
||||
|
||||
const response = await fetch('/api/custom-pages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.access_token}`
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('API Response:', { status: response.status, data });
|
||||
|
||||
if (data.success) {
|
||||
setShowCreateModal(false);
|
||||
setNewPageData({
|
||||
custom_slug: '',
|
||||
meta_title: '',
|
||||
meta_description: '',
|
||||
template_id: ''
|
||||
});
|
||||
// Reload data to show the new page
|
||||
await loadData();
|
||||
} else {
|
||||
const errorMessage = data.details ? `${data.error}: ${data.details}` : data.error || 'Failed to create custom page';
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating custom page:', error);
|
||||
alert('Network error: Unable to create custom page. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditPage = (pageId: string) => {
|
||||
setSelectedPageId(pageId);
|
||||
setShowPageBuilder(true);
|
||||
};
|
||||
|
||||
const handleDeletePage = async (pageId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this custom page?')) return;
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
alert('Please log in to delete custom pages');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/custom-pages/${pageId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadData(); // Reload data
|
||||
alert('Page deleted successfully');
|
||||
} else {
|
||||
alert('Failed to delete page');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
alert('Error deleting page');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePageStatus = async (pageId: string, isActive: boolean) => {
|
||||
try {
|
||||
// Get auth token
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
alert('Please log in to update page status');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/custom-pages/${pageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.access_token}`
|
||||
},
|
||||
body: JSON.stringify({ is_active: isActive })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadData(); // Reload data
|
||||
alert(`Page ${isActive ? 'activated' : 'deactivated'} successfully`);
|
||||
} else {
|
||||
alert('Failed to update page status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating page status:', error);
|
||||
alert('Error updating page status');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePage = async (pageData: Record<string, unknown>) => {
|
||||
if (!selectedPageId) return;
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
alert('Please log in to save page changes');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/custom-pages/${selectedPageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.access_token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
page_data: pageData,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Page saved successfully!');
|
||||
await loadData();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert(`Failed to save page: ${errorData.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving page:', error);
|
||||
alert('Error saving page changes');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
setNewPageData({
|
||||
...newPageData,
|
||||
template_id: template.id,
|
||||
meta_title: `${eventSlug} - ${template.name}`,
|
||||
meta_description: template.description
|
||||
});
|
||||
setShowTemplateManager(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showPageBuilder) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="bg-white border-b border-gray-200 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setShowPageBuilder(false)}
|
||||
className="flex items-center px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
← Back to Custom Pages
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold">
|
||||
Editing Custom Page
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PageBuilder
|
||||
eventId={eventId}
|
||||
pageId={selectedPageId!}
|
||||
onSave={handleSavePage}
|
||||
onPreview={(pageData) => {
|
||||
console.log('Preview data:', pageData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (showTemplateManager) {
|
||||
return (
|
||||
<div className="h-screen -m-6">
|
||||
<div className="bg-white border-b border-gray-200 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setShowTemplateManager(false)}
|
||||
className="flex items-center px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
← Back to Custom Pages
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold">Select Template</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplateManager
|
||||
organizationId={organizationId}
|
||||
onTemplateSelect={handleTemplateSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Custom Sales Pages</h2>
|
||||
<p className="text-white/60">Create custom branded pages for your event with unique URLs</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 text-white rounded-md transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)'
|
||||
}}
|
||||
>
|
||||
Create Custom Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Default Event Page Info */}
|
||||
<div className="bg-white/5 border border-white/20 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white mb-1">Default Event Page</h3>
|
||||
<p className="text-white/60 text-sm">Standard event page accessible to everyone</p>
|
||||
<p className="text-blue-400 text-sm mt-1">
|
||||
blackcanyontickets.com/e/{eventSlug}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-500/20 text-green-400">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Pages List */}
|
||||
<div className="space-y-4">
|
||||
{customPages.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-white/20 rounded-xl">
|
||||
<div className="text-4xl mb-4">🎨</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">No Custom Pages Yet</h3>
|
||||
<p className="text-white/60 mb-4">Create custom branded pages with unique URLs for better marketing</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 text-white rounded-md transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)'
|
||||
}}
|
||||
>
|
||||
Create Your First Custom Page
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
customPages.map((page) => (
|
||||
<div
|
||||
key={page.id}
|
||||
className="bg-white/5 border border-white/20 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="font-semibold text-white">
|
||||
{page.meta_title || `Custom Page - ${page.custom_slug}`}
|
||||
</h3>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
page.is_active
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{page.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{page.template && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-500/20 text-blue-400">
|
||||
Template: {page.template.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-blue-400 text-sm mb-1">
|
||||
blackcanyontickets.com/{page.custom_slug}
|
||||
</p>
|
||||
{page.meta_description && (
|
||||
<p className="text-white/60 text-sm">{page.meta_description}</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 mt-2 text-xs text-white/40">
|
||||
<span>{page.view_count} views</span>
|
||||
<span>Created {new Date(page.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => window.open(`/${page.custom_slug}`, '_blank')}
|
||||
className="px-3 py-1 text-xs text-blue-400 hover:text-blue-300 border border-blue-400/50 rounded hover:bg-blue-400/10"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditPage(page.id)}
|
||||
className="px-3 py-1 text-xs text-white hover:text-gray-200 border border-white/50 rounded hover:bg-white/10"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTogglePageStatus(page.id, !page.is_active)}
|
||||
className={`px-3 py-1 text-xs border rounded ${
|
||||
page.is_active
|
||||
? 'text-yellow-400 border-yellow-400/50 hover:bg-yellow-400/10'
|
||||
: 'text-green-400 border-green-400/50 hover:bg-green-400/10'
|
||||
}`}
|
||||
>
|
||||
{page.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeletePage(page.id)}
|
||||
className="px-3 py-1 text-xs text-red-400 hover:text-red-300 border border-red-400/50 rounded hover:bg-red-400/10"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Create Custom Page Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">Create Custom Page</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Custom URL Slug
|
||||
</label>
|
||||
<div className="flex">
|
||||
<span className="inline-flex items-center px-3 py-2 border border-gray-300 border-r-0 rounded-l-md bg-gray-50 text-gray-500 text-sm">
|
||||
blackcanyontickets.com/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newPageData.custom_slug}
|
||||
onChange={(e) => setNewPageData({
|
||||
...newPageData,
|
||||
custom_slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
||||
})}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-r-md focus:outline-none focus:ring-2 text-gray-900"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
placeholder="festival2024"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Use lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Page Title (for SEO)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPageData.meta_title}
|
||||
onChange={(e) => setNewPageData({ ...newPageData, meta_title: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 text-gray-900"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
placeholder="Amazing Festival 2024 - Get Your Tickets"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Meta Description
|
||||
</label>
|
||||
<textarea
|
||||
value={newPageData.meta_description}
|
||||
onChange={(e) => setNewPageData({ ...newPageData, meta_description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 text-gray-900"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
rows={3}
|
||||
placeholder="Join us for an unforgettable festival experience..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Template (Optional)
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<select
|
||||
value={newPageData.template_id}
|
||||
onChange={(e) => setNewPageData({ ...newPageData, template_id: e.target.value })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 text-gray-900"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<option value="">No template (start from scratch)</option>
|
||||
{templates.map(template => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowTemplateManager(true)}
|
||||
className="px-4 py-2 rounded-md border"
|
||||
style={{
|
||||
color: 'var(--glass-text-accent)',
|
||||
borderColor: 'var(--glass-text-accent)'
|
||||
}}
|
||||
>
|
||||
Browse Templates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateCustomPage}
|
||||
className="px-4 py-2 text-white rounded-md transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)'
|
||||
}}
|
||||
>
|
||||
Create Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPageTab;
|
||||
@@ -27,7 +27,7 @@ interface DiscountTabProps {
|
||||
|
||||
export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
const [discountCodes, setDiscountCodes] = useState<DiscountCode[]>([]);
|
||||
const [ticketTypes, setTicketTypes] = useState<any[]>([]);
|
||||
const [ticketTypes, setTicketTypes] = useState<Record<string, unknown>[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCode, setEditingCode] = useState<DiscountCode | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -67,8 +67,8 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
|
||||
setDiscountCodes(discountData.data || []);
|
||||
setTicketTypes(ticketTypesData.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading discount codes:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -142,8 +142,8 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Error saving discount code:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
if (error) throw error;
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting discount code:', error);
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -174,8 +174,8 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
|
||||
if (error) throw error;
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling discount code:', error);
|
||||
} catch (_error) {
|
||||
// Handle error silently
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,7 +223,11 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
<h2 className="text-2xl font-light text-white">Discount Codes</h2>
|
||||
<button
|
||||
onClick={handleCreateCode}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@@ -240,7 +244,11 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
<p className="text-white/60 mb-4">No discount codes created yet</p>
|
||||
<button
|
||||
onClick={handleCreateCode}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Create Your First Discount Code
|
||||
</button>
|
||||
@@ -502,7 +510,11 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||
<button
|
||||
onClick={handleSaveCode}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : editingCode ? 'Update' : 'Create'}
|
||||
</button>
|
||||
|
||||
71
src/components/manage/EventSettingsTab.tsx
Normal file
71
src/components/manage/EventSettingsTab.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import VenueTab from './VenueTab';
|
||||
import MarketingTab from './MarketingTab';
|
||||
import SettingsTab from './SettingsTab';
|
||||
import EmbedCodeModal from '../modals/EmbedCodeModal';
|
||||
|
||||
interface EventSettingsTabProps {
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
eventSlug?: string;
|
||||
}
|
||||
|
||||
export default function EventSettingsTab({ eventId, organizationId, eventSlug }: EventSettingsTabProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'details' | 'venue' | 'marketing' | 'embed'>('details');
|
||||
const [showEmbedModal, setShowEmbedModal] = useState(false);
|
||||
|
||||
const subTabs = [
|
||||
{ id: 'details', label: 'Event Details' },
|
||||
{ id: 'venue', label: 'Venue' },
|
||||
{ id: 'marketing', label: 'Marketing' },
|
||||
{ id: 'embed', label: 'Embed Code' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sub-navigation */}
|
||||
<div className="flex flex-wrap border-b border-white/20 mb-6">
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
if (tab.id === 'embed') {
|
||||
setShowEmbedModal(true);
|
||||
} else {
|
||||
setActiveSubTab(tab.id as string);
|
||||
}
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors duration-200 border-b-2 ${
|
||||
activeSubTab === tab.id
|
||||
? 'border-blue-500 text-white'
|
||||
: 'border-transparent text-white/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
<div>
|
||||
{activeSubTab === 'details' && (
|
||||
<SettingsTab eventId={eventId} organizationId={organizationId} />
|
||||
)}
|
||||
{activeSubTab === 'venue' && (
|
||||
<VenueTab eventId={eventId} organizationId={organizationId} />
|
||||
)}
|
||||
{activeSubTab === 'marketing' && (
|
||||
<MarketingTab eventId={eventId} organizationId={organizationId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Embed Code Modal */}
|
||||
<EmbedCodeModal
|
||||
isOpen={showEmbedModal}
|
||||
onClose={() => setShowEmbedModal(false)}
|
||||
eventId={eventId}
|
||||
eventSlug={eventSlug || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
copyToClipboard,
|
||||
downloadAsset
|
||||
} from '../../lib/marketing-kit';
|
||||
import {
|
||||
generateSocialMediaContentWithAI,
|
||||
generateEmailTemplatesWithAI,
|
||||
generateFlyerDataWithAI,
|
||||
regenerateSocialMediaPost,
|
||||
regenerateEmailTemplate,
|
||||
saveContentVote,
|
||||
type EmailTemplate as AIEmailTemplate,
|
||||
type FlyerData
|
||||
} from '../../lib/marketing-kit-enhanced';
|
||||
import { loadEventData } from '../../lib/event-management';
|
||||
import type { MarketingKitData, SocialMediaContent, EmailTemplate } from '../../lib/marketing-kit';
|
||||
|
||||
@@ -20,9 +30,16 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
const [marketingKit, setMarketingKit] = useState<MarketingKitData | null>(null);
|
||||
const [socialContent, setSocialContent] = useState<SocialMediaContent[]>([]);
|
||||
const [emailTemplate, setEmailTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [aiEmailTemplates, setAiEmailTemplates] = useState<AIEmailTemplate[]>([]);
|
||||
const [flyerData, setFlyerData] = useState<FlyerData[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'social' | 'email' | 'assets'>('overview');
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generatingAI, setGeneratingAI] = useState(false);
|
||||
const [generatingEmails, setGeneratingEmails] = useState(false);
|
||||
const [generatingFlyers, setGeneratingFlyers] = useState(false);
|
||||
const [regeneratingPosts, setRegeneratingPosts] = useState<Record<string, boolean>>({});
|
||||
const [regeneratingEmails, setRegeneratingEmails] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -36,16 +53,28 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
if (kitData) {
|
||||
setMarketingKit(kitData);
|
||||
|
||||
// Generate social media content
|
||||
const socialData = generateSocialMediaContent(kitData.event);
|
||||
setSocialContent(socialData);
|
||||
// Load saved AI content from localStorage
|
||||
const savedSocialContent = localStorage.getItem(`social_content_${eventId}`);
|
||||
if (savedSocialContent) {
|
||||
setSocialContent(JSON.parse(savedSocialContent));
|
||||
}
|
||||
|
||||
// Generate email template
|
||||
const savedEmailTemplates = localStorage.getItem(`email_templates_${eventId}`);
|
||||
if (savedEmailTemplates) {
|
||||
setAiEmailTemplates(JSON.parse(savedEmailTemplates));
|
||||
}
|
||||
|
||||
const savedFlyerData = localStorage.getItem(`flyer_data_${eventId}`);
|
||||
if (savedFlyerData) {
|
||||
setFlyerData(JSON.parse(savedFlyerData));
|
||||
}
|
||||
|
||||
// Generate basic email template (fallback)
|
||||
const emailData = generateEmailTemplate(kitData.event);
|
||||
setEmailTemplate(emailData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading marketing kit:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,15 +87,13 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
if (newKit) {
|
||||
setMarketingKit(newKit);
|
||||
|
||||
// Refresh social and email content
|
||||
const socialData = generateSocialMediaContent(newKit.event);
|
||||
setSocialContent(socialData);
|
||||
// Don't auto-generate, wait for user to click generate button
|
||||
|
||||
const emailData = generateEmailTemplate(newKit.event);
|
||||
setEmailTemplate(emailData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating marketing kit:', error);
|
||||
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -77,7 +104,7 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
await copyToClipboard(content);
|
||||
alert('Content copied to clipboard!');
|
||||
} catch (error) {
|
||||
console.error('Error copying content:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,7 +112,164 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
try {
|
||||
await downloadAsset(assetUrl, filename);
|
||||
} catch (error) {
|
||||
console.error('Error downloading asset:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAISocialContent = async () => {
|
||||
if (!marketingKit) return;
|
||||
|
||||
setGeneratingAI(true);
|
||||
try {
|
||||
const aiContent = await generateSocialMediaContentWithAI(marketingKit.event);
|
||||
setSocialContent(aiContent);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(`social_content_${eventId}`, JSON.stringify(aiContent));
|
||||
} catch (error) {
|
||||
|
||||
alert('Failed to generate AI content. Please try again.');
|
||||
} finally {
|
||||
setGeneratingAI(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAIEmailTemplates = async () => {
|
||||
if (!marketingKit) return;
|
||||
|
||||
setGeneratingEmails(true);
|
||||
try {
|
||||
const aiTemplates = await generateEmailTemplatesWithAI(marketingKit.event);
|
||||
setAiEmailTemplates(aiTemplates);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(`email_templates_${eventId}`, JSON.stringify(aiTemplates));
|
||||
} catch (error) {
|
||||
|
||||
alert('Failed to generate AI email templates. Please try again.');
|
||||
} finally {
|
||||
setGeneratingEmails(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAIFlyers = async () => {
|
||||
if (!marketingKit) return;
|
||||
|
||||
setGeneratingFlyers(true);
|
||||
try {
|
||||
const aiFlyers = await generateFlyerDataWithAI(marketingKit.event);
|
||||
setFlyerData(aiFlyers);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(`flyer_data_${eventId}`, JSON.stringify(aiFlyers));
|
||||
} catch (error) {
|
||||
|
||||
alert('Failed to generate AI flyers. Please try again.');
|
||||
} finally {
|
||||
setGeneratingFlyers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteContent = async (contentId: string, vote: 'up' | 'down') => {
|
||||
await saveContentVote(contentId, vote);
|
||||
|
||||
// Update local state to reflect the vote
|
||||
setSocialContent(prev => prev.map(content =>
|
||||
content.id === contentId
|
||||
? { ...content, votes: (content.votes || 0) + (vote === 'up' ? 1 : -1) }
|
||||
: content
|
||||
));
|
||||
};
|
||||
|
||||
const handleRegenerateSocialPost = async (contentId: string, platform: string, tone?: string) => {
|
||||
if (!marketingKit) return;
|
||||
|
||||
setRegeneratingPosts(prev => ({ ...prev, [contentId]: true }));
|
||||
|
||||
try {
|
||||
const newPost = await regenerateSocialMediaPost(marketingKit.event, platform, tone);
|
||||
if (newPost) {
|
||||
// Replace the old post with the new one
|
||||
setSocialContent(prev => prev.map(content =>
|
||||
content.id === contentId ? newPost : content
|
||||
));
|
||||
|
||||
// Update localStorage
|
||||
const updatedContent = socialContent.map(content =>
|
||||
content.id === contentId ? newPost : content
|
||||
);
|
||||
localStorage.setItem(`social_content_${eventId}`, JSON.stringify(updatedContent));
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
alert('Failed to regenerate post. Please try again.');
|
||||
} finally {
|
||||
setRegeneratingPosts(prev => ({ ...prev, [contentId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateEmailTemplate = async (templateId: string, tone: string) => {
|
||||
if (!marketingKit) return;
|
||||
|
||||
setRegeneratingEmails(prev => ({ ...prev, [templateId]: true }));
|
||||
|
||||
try {
|
||||
const newTemplate = await regenerateEmailTemplate(marketingKit.event, tone);
|
||||
if (newTemplate) {
|
||||
// Replace the old template with the new one
|
||||
setAiEmailTemplates(prev => prev.map(template =>
|
||||
template.id === templateId ? newTemplate : template
|
||||
));
|
||||
|
||||
// Update localStorage
|
||||
const updatedTemplates = aiEmailTemplates.map(template =>
|
||||
template.id === templateId ? newTemplate : template
|
||||
);
|
||||
localStorage.setItem(`email_templates_${eventId}`, JSON.stringify(updatedTemplates));
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
alert('Failed to regenerate email template. Please try again.');
|
||||
} finally {
|
||||
setRegeneratingEmails(prev => ({ ...prev, [templateId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteEmailTemplate = async (templateId: string, vote: 'up' | 'down') => {
|
||||
await saveContentVote(templateId, vote);
|
||||
|
||||
// If thumbs down, automatically regenerate
|
||||
if (vote === 'down') {
|
||||
const template = aiEmailTemplates.find(t => t.id === templateId);
|
||||
if (template) {
|
||||
await handleRegenerateEmailTemplate(templateId, template.tone || 'professional');
|
||||
}
|
||||
} else {
|
||||
// Update local state to reflect the vote
|
||||
setAiEmailTemplates(prev => prev.map(template =>
|
||||
template.id === templateId
|
||||
? { ...template, votes: (template.votes || 0) + (vote === 'up' ? 1 : -1) }
|
||||
: template
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteSocialContent = async (contentId: string, vote: 'up' | 'down') => {
|
||||
await saveContentVote(contentId, vote);
|
||||
|
||||
// If thumbs down, automatically regenerate
|
||||
if (vote === 'down') {
|
||||
const content = socialContent.find(c => c.id === contentId);
|
||||
if (content) {
|
||||
await handleRegenerateSocialPost(contentId, content.platform, content.tone);
|
||||
}
|
||||
} else {
|
||||
// Update local state to reflect the vote
|
||||
setSocialContent(prev => prev.map(content =>
|
||||
content.id === contentId
|
||||
? { ...content, votes: (content.votes || 0) + (vote === 'up' ? 1 : -1) }
|
||||
: content
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,7 +319,11 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
<button
|
||||
onClick={handleGenerateKit}
|
||||
disabled={generating}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
@@ -153,7 +341,11 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
<button
|
||||
onClick={handleGenerateKit}
|
||||
disabled={generating}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Marketing Kit'}
|
||||
</button>
|
||||
@@ -191,18 +383,22 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/5 border border-white/20 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Marketing Kit Overview</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-400 mb-2">{marketingKit.assets.length}</div>
|
||||
<div className="text-white/60">Assets Generated</div>
|
||||
<div className="text-3xl font-bold text-blue-400 mb-2">{socialContent.length}</div>
|
||||
<div className="text-white/60">Social Posts</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-400 mb-2">{socialContent.length}</div>
|
||||
<div className="text-white/60">Social Templates</div>
|
||||
<div className="text-3xl font-bold text-green-400 mb-2">{aiEmailTemplates.length}</div>
|
||||
<div className="text-white/60">Email Templates</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-400 mb-2">1</div>
|
||||
<div className="text-white/60">Email Template</div>
|
||||
<div className="text-3xl font-bold text-purple-400 mb-2">{flyerData.length}</div>
|
||||
<div className="text-white/60">Flyer Concepts</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-orange-400 mb-2">{marketingKit.assets.length}</div>
|
||||
<div className="text-white/60">Legacy Assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,158 +432,419 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
||||
|
||||
{activeTab === 'social' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{socialContent.map((content) => (
|
||||
<div key={content.platform} className="bg-white/5 border border-white/20 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-blue-400">
|
||||
{getPlatformIcon(content.platform)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white capitalize">{content.platform}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Content</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm whitespace-pre-wrap">
|
||||
{content.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Hashtags</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{content.hashtags.map((hashtag, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-500/20 text-blue-300 rounded-lg text-xs">
|
||||
{hashtag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handleCopyContent(`${content.content}\n\n${content.hashtags.join(' ')}`)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Copy Content
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-semibold text-white">Social Media Content</h3>
|
||||
<button
|
||||
onClick={handleGenerateAISocialContent}
|
||||
disabled={generatingAI || !marketingKit}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
|
||||
generatingAI || !marketingKit
|
||||
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
style={generatingAI || !marketingKit ? {} : {
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{generatingAI ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Generating with AI...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Generate AI Content
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{socialContent.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-white/40 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
<p className="text-white/60 mb-4">No social media content generated yet</p>
|
||||
<p className="text-white/40 text-sm">Click "Generate AI Content" to create 5 unique posts</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{socialContent.map((content) => (
|
||||
<div key={content.platform} className="bg-white/5 border border-white/20 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-blue-400">
|
||||
{getPlatformIcon(content.platform)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white capitalize">{content.platform}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Content</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm whitespace-pre-wrap">
|
||||
{content.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Hashtags</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{content.hashtags.map((hashtag, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-500/20 text-blue-300 rounded-lg text-xs">
|
||||
{hashtag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleVoteSocialContent(content.id || '', 'up')}
|
||||
disabled={regeneratingPosts[content.id || '']}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
(content.votes || 0) > 0
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-white/10 text-white/60 hover:text-green-400'
|
||||
} ${regeneratingPosts[content.id || ''] ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title="Like this content"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVoteSocialContent(content.id || '', 'down')}
|
||||
disabled={regeneratingPosts[content.id || '']}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
regeneratingPosts[content.id || '']
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: (content.votes || 0) < 0
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-white/10 text-white/60 hover:text-red-400'
|
||||
}`}
|
||||
title={regeneratingPosts[content.id || ''] ? "Regenerating..." : "Dislike & regenerate"}
|
||||
>
|
||||
{regeneratingPosts[content.id || ''] ? (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{content.tone && (
|
||||
<span className="px-2 py-1 bg-white/10 text-white/60 rounded-lg text-xs capitalize">
|
||||
{content.tone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopyContent(`${content.content}\n\n${content.hashtags.join(' ')}`)}
|
||||
className="px-4 py-2 rounded-lg text-sm transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Copy Content
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'email' && emailTemplate && (
|
||||
{activeTab === 'email' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/5 border border-white/20 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Email Template</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Subject Line</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white">
|
||||
{emailTemplate.subject}
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => handleCopyContent(emailTemplate.subject)}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Copy Subject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Preview Text</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white">
|
||||
{emailTemplate.preview_text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">HTML Content</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm max-h-64 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap">{emailTemplate.html_content}</pre>
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => handleCopyContent(emailTemplate.html_content)}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Copy HTML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Text Content</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm max-h-64 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap">{emailTemplate.text_content}</pre>
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => handleCopyContent(emailTemplate.text_content)}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Copy Text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-semibold text-white">Email Templates</h3>
|
||||
<button
|
||||
onClick={handleGenerateAIEmailTemplates}
|
||||
disabled={generatingEmails || !marketingKit}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
|
||||
generatingEmails || !marketingKit
|
||||
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
style={generatingEmails || !marketingKit ? {} : {
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{generatingEmails ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Generating with AI...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Generate AI Email Templates
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiEmailTemplates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-white/40 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-white/60 mb-4">No AI email templates generated yet</p>
|
||||
<p className="text-white/40 text-sm">Click "Generate AI Email Templates" to create 3 unique templates</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{aiEmailTemplates.map((template, index) => (
|
||||
<div key={template.id} className="bg-white/5 border border-white/20 rounded-xl p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h4 className="text-lg font-semibold text-white capitalize">{template.tone} Tone</h4>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleVoteEmailTemplate(template.id || '', 'up')}
|
||||
disabled={regeneratingEmails[template.id || '']}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
(template.votes || 0) > 0
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-white/10 text-white/60 hover:text-green-400'
|
||||
} ${regeneratingEmails[template.id || ''] ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title="Like this template"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVoteEmailTemplate(template.id || '', 'down')}
|
||||
disabled={regeneratingEmails[template.id || '']}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
regeneratingEmails[template.id || '']
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: (template.votes || 0) < 0
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-white/10 text-white/60 hover:text-red-400'
|
||||
}`}
|
||||
title={regeneratingEmails[template.id || ''] ? "Regenerating..." : "Dislike & regenerate"}
|
||||
>
|
||||
{regeneratingEmails[template.id || ''] ? (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Subject Line</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white">
|
||||
{template.subject}
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => handleCopyContent(template.subject)}
|
||||
className="px-3 py-1 rounded-lg text-sm transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Copy Subject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Preview Text</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white">
|
||||
{template.preview_text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">HTML Content</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm max-h-64 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap">{template.html_content}</pre>
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => handleCopyContent(template.html_content)}
|
||||
className="px-3 py-1 rounded-lg text-sm transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Copy HTML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Text Content</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm max-h-64 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap">{template.text_content}</pre>
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => handleCopyContent(template.text_content)}
|
||||
className="px-3 py-1 rounded-lg text-sm transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Copy Text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'assets' && (
|
||||
<div className="space-y-6">
|
||||
{marketingKit.assets.length === 0 ? (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-semibold text-white">Marketing Assets</h3>
|
||||
<button
|
||||
onClick={handleGenerateAIFlyers}
|
||||
disabled={generatingFlyers || !marketingKit}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
|
||||
generatingFlyers || !marketingKit
|
||||
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
style={generatingFlyers || !marketingKit ? {} : {
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{generatingFlyers ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Generating with AI...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Generate AI Flyers
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{flyerData.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-white/40 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-white/60 mb-4">No assets generated yet</p>
|
||||
<button
|
||||
onClick={handleGenerateKit}
|
||||
disabled={generating}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Assets'}
|
||||
</button>
|
||||
<p className="text-white/60 mb-4">No AI flyers generated yet</p>
|
||||
<p className="text-white/40 text-sm">Click "Generate AI Flyers" to create 3 unique flyer concepts</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{marketingKit.assets.map((asset) => (
|
||||
<div key={asset.id} className="bg-white/5 border border-white/20 rounded-xl p-6">
|
||||
{flyerData.map((flyer) => (
|
||||
<div key={flyer.id} className="bg-white/5 border border-white/20 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white capitalize">
|
||||
{asset.asset_type.replace('_', ' ')}
|
||||
</h3>
|
||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-300 rounded-lg text-xs">
|
||||
{asset.asset_type}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-white capitalize">{flyer.style} Style</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleVoteContent(flyer.id || '', 'up')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
(flyer.votes || 0) > 0
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-white/10 text-white/60 hover:text-green-400'
|
||||
}`}
|
||||
title="Like this flyer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVoteContent(flyer.id || '', 'down')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
(flyer.votes || 0) < 0
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-white/10 text-white/60 hover:text-red-400'
|
||||
}`}
|
||||
title="Dislike this flyer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asset.asset_url && (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={asset.asset_url}
|
||||
alt={asset.asset_type}
|
||||
className="w-full h-32 object-cover rounded-lg bg-white/10"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Title</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white font-semibold">
|
||||
{flyer.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handleDownloadAsset(asset.asset_url, `${asset.asset_type}-${asset.id}`)}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Content</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm">
|
||||
{flyer.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Design Theme</label>
|
||||
<div className="bg-white/10 rounded-lg p-3 text-white/80 text-sm">
|
||||
{flyer.theme}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handleCopyContent(`${flyer.title}\n\n${flyer.content}\n\nDesign Theme: ${flyer.theme}`)}
|
||||
className="px-4 py-2 rounded-lg text-sm transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Copy Flyer Content
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
setOrders(ordersData);
|
||||
setTicketTypes(ticketTypesData);
|
||||
} catch (error) {
|
||||
console.error('Error loading orders data:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -55,7 +55,15 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
|
||||
// Apply status filter
|
||||
if (filters.status) {
|
||||
filtered = filtered.filter(order => order.status === filters.status);
|
||||
if (filters.status === 'confirmed') {
|
||||
filtered = filtered.filter(order => !order.refund_status || order.refund_status === null);
|
||||
} else if (filters.status === 'refunded') {
|
||||
filtered = filtered.filter(order => order.refund_status === 'completed');
|
||||
} else if (filters.status === 'pending') {
|
||||
filtered = filtered.filter(order => order.refund_status === 'pending');
|
||||
} else if (filters.status === 'cancelled') {
|
||||
filtered = filtered.filter(order => order.refund_status === 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Apply check-in filter
|
||||
@@ -67,9 +75,9 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(order =>
|
||||
order.customer_name.toLowerCase().includes(term) ||
|
||||
order.customer_email.toLowerCase().includes(term) ||
|
||||
order.ticket_uuid.toLowerCase().includes(term)
|
||||
order.purchaser_name.toLowerCase().includes(term) ||
|
||||
order.purchaser_email.toLowerCase().includes(term) ||
|
||||
order.uuid.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,11 +99,11 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
};
|
||||
|
||||
const handleRefundOrder = async (order: SalesData) => {
|
||||
if (confirm(`Are you sure you want to refund ${order.customer_name}'s ticket?`)) {
|
||||
if (confirm(`Are you sure you want to refund ${order.purchaser_name}'s ticket?`)) {
|
||||
const success = await refundTicket(order.id);
|
||||
if (success) {
|
||||
setOrders(prev => prev.map(o =>
|
||||
o.id === order.id ? { ...o, status: 'refunded' } : o
|
||||
o.id === order.id ? { ...o, refund_status: 'completed' } : o
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -126,7 +134,7 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Error exporting data:', error);
|
||||
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
@@ -134,12 +142,12 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
|
||||
const getOrderStats = () => {
|
||||
const totalOrders = filteredOrders.length;
|
||||
const confirmedOrders = filteredOrders.filter(o => o.status === 'confirmed').length;
|
||||
const refundedOrders = filteredOrders.filter(o => o.status === 'refunded').length;
|
||||
const confirmedOrders = filteredOrders.filter(o => !o.refund_status || o.refund_status === null).length;
|
||||
const refundedOrders = filteredOrders.filter(o => o.refund_status === 'completed').length;
|
||||
const checkedInOrders = filteredOrders.filter(o => o.checked_in).length;
|
||||
const totalRevenue = filteredOrders
|
||||
.filter(o => o.status === 'confirmed')
|
||||
.reduce((sum, o) => sum + o.price_paid, 0);
|
||||
.filter(o => !o.refund_status || o.refund_status === null)
|
||||
.reduce((sum, o) => sum + o.price, 0);
|
||||
|
||||
return {
|
||||
totalOrders,
|
||||
@@ -302,11 +310,11 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Name:</span>
|
||||
<div className="text-white font-medium">{selectedOrder.customer_name}</div>
|
||||
<div className="text-white font-medium">{selectedOrder.purchaser_name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Email:</span>
|
||||
<div className="text-white">{selectedOrder.customer_email}</div>
|
||||
<div className="text-white">{selectedOrder.purchaser_email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,7 +328,7 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Ticket ID:</span>
|
||||
<div className="text-white font-mono text-sm">{selectedOrder.ticket_uuid}</div>
|
||||
<div className="text-white font-mono text-sm">{selectedOrder.uuid}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Purchase Date:</span>
|
||||
@@ -340,16 +348,18 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Price Paid:</span>
|
||||
<div className="text-white font-bold">{formatCurrency(selectedOrder.price_paid)}</div>
|
||||
<div className="text-white font-bold">{formatCurrency(selectedOrder.price)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Status:</span>
|
||||
<div className={`font-medium ${
|
||||
selectedOrder.status === 'confirmed' ? 'text-green-400' :
|
||||
selectedOrder.status === 'refunded' ? 'text-red-400' :
|
||||
(!selectedOrder.refund_status || selectedOrder.refund_status === null) ? 'text-green-400' :
|
||||
selectedOrder.refund_status === 'completed' ? 'text-red-400' :
|
||||
'text-yellow-400'
|
||||
}`}>
|
||||
{selectedOrder.status.charAt(0).toUpperCase() + selectedOrder.status.slice(1)}
|
||||
{(!selectedOrder.refund_status || selectedOrder.refund_status === null) ? 'Confirmed' :
|
||||
selectedOrder.refund_status === 'completed' ? 'Refunded' :
|
||||
selectedOrder.refund_status === 'pending' ? 'Pending' : 'Failed'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,13 +384,17 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
</svg>
|
||||
Not Checked In
|
||||
</div>
|
||||
{selectedOrder.status === 'confirmed' && (
|
||||
{(!selectedOrder.refund_status || selectedOrder.refund_status === null) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCheckInOrder(selectedOrder);
|
||||
setShowOrderDetails(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
className="px-4 py-2 rounded-lg transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
style={{
|
||||
background: 'var(--success-color)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Check In Now
|
||||
</button>
|
||||
@@ -403,7 +417,11 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
||||
handleRefundOrder(selectedOrder);
|
||||
setShowOrderDetails(false);
|
||||
}}
|
||||
className="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
className="px-6 py-3 rounded-lg transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
style={{
|
||||
background: 'var(--error-color)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Refund Order
|
||||
</button>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
if (error) throw error;
|
||||
setPresaleCodes(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading presale codes:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
setShowModal(false);
|
||||
loadPresaleCodes();
|
||||
} catch (error) {
|
||||
console.error('Error saving presale code:', error);
|
||||
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -140,7 +140,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
if (error) throw error;
|
||||
loadPresaleCodes();
|
||||
} catch (error) {
|
||||
console.error('Error deleting presale code:', error);
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -155,7 +155,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
if (error) throw error;
|
||||
loadPresaleCodes();
|
||||
} catch (error) {
|
||||
console.error('Error toggling presale code:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +181,11 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
<h2 className="text-2xl font-light text-white">Presale Codes</h2>
|
||||
<button
|
||||
onClick={handleCreateCode}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@@ -198,7 +202,11 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
<p className="text-white/60 mb-4">No presale codes created yet</p>
|
||||
<button
|
||||
onClick={handleCreateCode}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Create Your First Presale Code
|
||||
</button>
|
||||
@@ -402,7 +410,11 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
<button
|
||||
onClick={handleSaveCode}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : editingCode ? 'Update' : 'Create'}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { formatCurrency } from '../../lib/event-management';
|
||||
import TicketPreviewModal from '../modals/TicketPreviewModal';
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
@@ -19,11 +20,13 @@ interface PrintedTicket {
|
||||
|
||||
interface PrintedTabProps {
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export default function PrintedTab({ eventId }: PrintedTabProps) {
|
||||
export default function PrintedTab({ eventId, organizationId }: PrintedTabProps) {
|
||||
const [printedTickets, setPrintedTickets] = useState<PrintedTicket[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [barcodeMethod, setBarcodeMethod] = useState<'generate' | 'manual'>('generate');
|
||||
const [barcodeData, setBarcodeData] = useState({
|
||||
startNumber: 1,
|
||||
@@ -53,7 +56,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
||||
if (error) throw error;
|
||||
setPrintedTickets(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading printed tickets:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -127,7 +130,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
||||
});
|
||||
setManualBarcodes('');
|
||||
} catch (error) {
|
||||
console.error('Error creating printed tickets:', error);
|
||||
|
||||
alert('Failed to create printed tickets');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
@@ -159,7 +162,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
||||
setEditingTicket(null);
|
||||
loadPrintedTickets();
|
||||
} catch (error) {
|
||||
console.error('Error updating printed ticket:', error);
|
||||
|
||||
alert('Failed to update printed ticket');
|
||||
}
|
||||
};
|
||||
@@ -175,7 +178,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
||||
if (error) throw error;
|
||||
loadPrintedTickets();
|
||||
} catch (error) {
|
||||
console.error('Error deleting printed ticket:', error);
|
||||
|
||||
alert('Failed to delete printed ticket');
|
||||
}
|
||||
}
|
||||
@@ -251,16 +254,35 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-light text-white">Printed Tickets</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Printed Tickets
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-light text-white mb-2">Printed Tickets</h2>
|
||||
<p className="text-white/80">Manage barcodes for printed tickets</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowPreviewModal(true)}
|
||||
className="bg-white/10 hover:bg-white/20 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 backdrop-blur-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Preview Sample Ticket
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 flex items-center gap-2"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Printed Tickets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
@@ -568,6 +590,14 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Preview Modal */}
|
||||
<TicketPreviewModal
|
||||
isOpen={showPreviewModal}
|
||||
onClose={() => setShowPreviewModal(false)}
|
||||
eventId={eventId}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
if (error) throw error;
|
||||
setPromotions(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading promotions:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
setShowModal(false);
|
||||
loadPromotions();
|
||||
} catch (error) {
|
||||
console.error('Error saving promotion:', error);
|
||||
|
||||
alert('Failed to save promotion');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -145,7 +145,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
if (error) throw error;
|
||||
loadPromotions();
|
||||
} catch (error) {
|
||||
console.error('Error deleting promotion:', error);
|
||||
|
||||
alert('Failed to delete promotion');
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
if (error) throw error;
|
||||
loadPromotions();
|
||||
} catch (error) {
|
||||
console.error('Error toggling promotion:', error);
|
||||
|
||||
alert('Failed to toggle promotion');
|
||||
}
|
||||
};
|
||||
@@ -252,7 +252,11 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
<h2 className="text-2xl font-light text-white">Promotions & Campaigns</h2>
|
||||
<button
|
||||
onClick={handleCreatePromotion}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@@ -290,7 +294,11 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
<p className="text-white/60 mb-4">No promotions created yet</p>
|
||||
<button
|
||||
onClick={handleCreatePromotion}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Create Your First Promotion
|
||||
</button>
|
||||
@@ -424,7 +432,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:border-transparent"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
placeholder="Early Bird Special"
|
||||
/>
|
||||
</div>
|
||||
@@ -446,7 +457,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value as any }))}
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:ring-2 focus:border-transparent"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<option value="early_bird">Early Bird</option>
|
||||
<option value="flash_sale">Flash Sale</option>
|
||||
@@ -462,7 +476,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
type="number"
|
||||
value={formData.discount_percentage}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, discount_percentage: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:border-transparent"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
@@ -476,7 +493,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, start_date: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:ring-2 focus:border-transparent"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -486,7 +506,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, end_date: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:ring-2 focus:border-transparent"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,7 +520,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
type="number"
|
||||
value={formData.max_uses}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:border-transparent"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-text-accent)'
|
||||
} as React.CSSProperties}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
@@ -513,7 +539,11 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
||||
<button
|
||||
onClick={handleSavePromotion}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : editingPromotion ? 'Update' : 'Create'}
|
||||
</button>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function SettingsTab({ eventId, organizationId }: SettingsTabProp
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading event settings:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export default function SettingsTab({ eventId, organizationId }: SettingsTabProp
|
||||
alert('Failed to save settings');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
|
||||
alert('Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -241,7 +241,11 @@ export default function SettingsTab({ eventId, organizationId }: SettingsTabProp
|
||||
<h3 className="text-lg font-semibold text-white">Custom Fields</h3>
|
||||
<button
|
||||
onClick={addCustomField}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||
className="px-3 py-1 rounded-lg text-sm transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Add Field
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { EventData } from '../../lib/event-management';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
component: React.ComponentType<any>;
|
||||
icon: React.ReactNode;
|
||||
component: React.ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface TabNavigationProps {
|
||||
@@ -13,6 +14,8 @@ interface TabNavigationProps {
|
||||
onTabChange: (tabId: string) => void;
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
eventData: EventData | null;
|
||||
eventSlug?: string | null;
|
||||
}
|
||||
|
||||
export default function TabNavigation({
|
||||
@@ -20,7 +23,9 @@ export default function TabNavigation({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
eventId,
|
||||
organizationId
|
||||
organizationId,
|
||||
eventData,
|
||||
eventSlug
|
||||
}: TabNavigationProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -32,11 +37,14 @@ export default function TabNavigation({
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-white/20">
|
||||
{/* Mobile Tab Dropdown */}
|
||||
<div className="md:hidden px-4 py-3">
|
||||
<div className="lg:hidden px-4 py-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="flex items-center justify-between w-full bg-white/10 backdrop-blur-lg border border-white/20 text-white px-4 py-3 rounded-xl focus:ring-2 focus:ring-blue-500 transition-all duration-200"
|
||||
className="flex items-center justify-between w-full bg-white/10 backdrop-blur-lg border border-white/20 text-white px-4 py-3 rounded-xl focus:ring-2 transition-all duration-200"
|
||||
style={{
|
||||
'--tw-ring-color': 'var(--glass-border-focus)'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{currentTab?.icon}</span>
|
||||
@@ -75,16 +83,20 @@ export default function TabNavigation({
|
||||
</div>
|
||||
|
||||
{/* Desktop Tab Navigation */}
|
||||
<div className="hidden md:flex overflow-x-auto">
|
||||
<div className="hidden lg:flex overflow-x-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors duration-200 whitespace-nowrap border-b-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-400 bg-white/5'
|
||||
? 'border-transparent bg-white/5'
|
||||
: 'border-transparent text-white/60 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
style={activeTab === tab.id ? {
|
||||
borderBottomColor: 'var(--glass-text-accent)',
|
||||
color: 'var(--glass-text-accent)'
|
||||
} : {}}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
<span>{tab.name}</span>
|
||||
@@ -95,11 +107,23 @@ export default function TabNavigation({
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6 min-h-[600px]">
|
||||
{CurrentTabComponent && (
|
||||
{CurrentTabComponent && eventData ? (
|
||||
<CurrentTabComponent
|
||||
eventId={eventId}
|
||||
organizationId={organizationId}
|
||||
eventSlug={eventSlug}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="text-white/60 mb-4">
|
||||
{!CurrentTabComponent ? 'Tab component not found' : 'Event data not loaded'}
|
||||
</div>
|
||||
<div className="text-white/40 text-sm">
|
||||
Debug: activeTab={activeTab}, eventData={eventData ? 'loaded' : 'null'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
58
src/components/manage/TicketingAccessTab.tsx
Normal file
58
src/components/manage/TicketingAccessTab.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import TicketsTab from './TicketsTab';
|
||||
import PresaleTab from './PresaleTab';
|
||||
import DiscountTab from './DiscountTab';
|
||||
import PrintedTab from './PrintedTab';
|
||||
|
||||
interface TicketingAccessTabProps {
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export default function TicketingAccessTab({ eventId, organizationId }: TicketingAccessTabProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'tickets' | 'presale' | 'discount' | 'printed'>('tickets');
|
||||
|
||||
const subTabs = [
|
||||
{ id: 'tickets', label: 'Ticket Types' },
|
||||
{ id: 'presale', label: 'Access Codes' },
|
||||
{ id: 'discount', label: 'Discounts' },
|
||||
{ id: 'printed', label: 'Printed Tickets' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sub-navigation */}
|
||||
<div className="flex border-b border-white/20 mb-6">
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id as any)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors duration-200 border-b-2 ${
|
||||
activeSubTab === tab.id
|
||||
? 'border-blue-500 text-white'
|
||||
: 'border-transparent text-white/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
<div>
|
||||
{activeSubTab === 'tickets' && (
|
||||
<TicketsTab eventId={eventId} organizationId={organizationId} />
|
||||
)}
|
||||
{activeSubTab === 'presale' && (
|
||||
<PresaleTab eventId={eventId} organizationId={organizationId} />
|
||||
)}
|
||||
{activeSubTab === 'discount' && (
|
||||
<DiscountTab eventId={eventId} organizationId={organizationId} />
|
||||
)}
|
||||
{activeSubTab === 'printed' && (
|
||||
<PrintedTab eventId={eventId} organizationId={organizationId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
setTicketTypes(ticketTypesData);
|
||||
setSalesData(salesDataResult);
|
||||
} catch (error) {
|
||||
console.error('Error loading tickets data:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -48,132 +48,137 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteTicketType = async (ticketType: TicketType) => {
|
||||
if (confirm(`Are you sure you want to delete "${ticketType.name}"?`)) {
|
||||
const success = await deleteTicketType(ticketType.id);
|
||||
if (success) {
|
||||
setTicketTypes(prev => prev.filter(t => t.id !== ticketType.id));
|
||||
}
|
||||
const handleDeleteTicketType = async (ticketTypeId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this ticket type? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTicketType(ticketTypeId);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTicketType = async (ticketType: TicketType) => {
|
||||
const success = await toggleTicketTypeStatus(ticketType.id, !ticketType.is_active);
|
||||
if (success) {
|
||||
setTicketTypes(prev => prev.map(t =>
|
||||
t.id === ticketType.id ? { ...t, is_active: !t.is_active } : t
|
||||
));
|
||||
const handleToggleStatus = async (ticketType: TicketType) => {
|
||||
try {
|
||||
await toggleTicketTypeStatus(ticketType.id, !ticketType.is_active);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalSave = (ticketType: TicketType) => {
|
||||
if (editingTicketType) {
|
||||
setTicketTypes(prev => prev.map(t =>
|
||||
t.id === ticketType.id ? ticketType : t
|
||||
));
|
||||
} else {
|
||||
setTicketTypes(prev => [...prev, ticketType]);
|
||||
}
|
||||
setShowModal(false);
|
||||
const getSalesStats = (ticketTypeId: string) => {
|
||||
const salesMetrics = calculateSalesMetrics(salesData.filter(sale => sale.ticket_type_id === ticketTypeId));
|
||||
return {
|
||||
sold: salesMetrics.totalTickets,
|
||||
revenue: salesMetrics.totalRevenue,
|
||||
available: ticketTypes.find(tt => tt.id === ticketTypeId)?.available || 0
|
||||
};
|
||||
};
|
||||
|
||||
const getTicketTypeStats = (ticketType: TicketType) => {
|
||||
const typeSales = salesData.filter(sale =>
|
||||
sale.ticket_type_id === ticketType.id && sale.status === 'confirmed'
|
||||
);
|
||||
const sold = typeSales.length;
|
||||
const revenue = typeSales.reduce((sum, sale) => sum + sale.price_paid, 0);
|
||||
const available = ticketType.quantity - sold;
|
||||
const renderTicketCard = (ticketType: TicketType) => {
|
||||
const stats = getSalesStats(ticketType.id);
|
||||
const percentage = ticketType.quantity > 0
|
||||
? (stats.sold / ticketType.quantity) * 100
|
||||
: 0;
|
||||
|
||||
return { sold, revenue, available };
|
||||
};
|
||||
|
||||
const renderTicketTypeCard = (ticketType: TicketType) => {
|
||||
const stats = getTicketTypeStats(ticketType);
|
||||
const percentage = ticketType.quantity > 0 ? (stats.sold / ticketType.quantity) * 100 : 0;
|
||||
const isActive = ticketType.is_active && ticketType.sale_start <= new Date() &&
|
||||
(!ticketType.sale_end || ticketType.sale_end >= new Date());
|
||||
|
||||
return (
|
||||
<div key={ticketType.id} className="bg-white/5 border border-white/20 rounded-xl p-6 hover:bg-white/10 transition-all duration-200">
|
||||
<div
|
||||
key={ticketType.id}
|
||||
className="rounded-xl p-6 transition-all duration-200 glass-card hover:shadow-lg"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-white">{ticketType.name}</h3>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
ticketType.is_active
|
||||
? 'bg-green-500/20 text-green-300 border border-green-500/30'
|
||||
: 'bg-red-500/20 text-red-300 border border-red-500/30'
|
||||
}`}>
|
||||
{ticketType.is_active ? 'Active' : 'Inactive'}
|
||||
<h3 className="text-xl font-semibold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||
{ticketType.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isActive ? 'premium-success' : 'premium-error'
|
||||
}`}
|
||||
>
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
{ticketType.description && (
|
||||
<p className="text-white/70 text-sm mb-3">{ticketType.description}</p>
|
||||
<p className="text-sm mb-3" style={{ color: 'var(--glass-text-secondary)' }}>
|
||||
{ticketType.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-white mb-2">
|
||||
{formatCurrency(ticketType.price_cents)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEditTicketType(ticketType)}
|
||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleTicketType(ticketType)}
|
||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
||||
title={ticketType.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{ticketType.is_active ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.142 4.142M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTicketType(ticketType)}
|
||||
className="p-2 text-white/60 hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="text-2xl font-bold mb-2" style={{ color: 'var(--glass-text-primary)' }}>
|
||||
{formatCurrency(ticketType.price)}
|
||||
{ticketType.fees_included && <span className="text-sm font-normal" style={{ color: 'var(--glass-text-tertiary)' }}> (fees included)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-white/60">Sold</div>
|
||||
<div className="text-white font-semibold">{stats.sold}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/60">Available</div>
|
||||
<div className="text-white font-semibold">{stats.available}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/60">Revenue</div>
|
||||
<div className="text-white font-semibold">{formatCurrency(stats.revenue)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handleEditTicketType(ticketType)}
|
||||
className="p-2 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--glass-text-tertiary)' }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(ticketType)}
|
||||
className="p-2 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--glass-text-tertiary)' }}
|
||||
>
|
||||
{ticketType.is_active ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTicketType(ticketType.id)}
|
||||
className="p-2 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--error-color)' }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-white/10 rounded-full h-2">
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div style={{ color: 'var(--glass-text-tertiary)' }}>Sold</div>
|
||||
<div className="font-semibold" style={{ color: 'var(--glass-text-primary)' }}>{stats.sold}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div style={{ color: 'var(--glass-text-tertiary)' }}>Available</div>
|
||||
<div className="font-semibold" style={{ color: 'var(--glass-text-primary)' }}>{stats.available}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div style={{ color: 'var(--glass-text-tertiary)' }}>Revenue</div>
|
||||
<div className="font-semibold" style={{ color: 'var(--glass-text-primary)' }}>{formatCurrency(stats.revenue)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="w-full rounded-full h-2" style={{ background: 'var(--glass-bg)' }}>
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
className="h-2 rounded-full bg-gradient-to-r from-blue-600 to-purple-600"
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-white/60">
|
||||
<div className="text-xs" style={{ color: 'var(--glass-text-tertiary)' }}>
|
||||
{percentage.toFixed(1)}% sold ({stats.sold} of {ticketType.quantity})
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,81 +186,84 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const renderTicketTypeList = (ticketType: TicketType) => {
|
||||
const stats = getTicketTypeStats(ticketType);
|
||||
const percentage = ticketType.quantity > 0 ? (stats.sold / ticketType.quantity) * 100 : 0;
|
||||
const renderTicketRow = (ticketType: TicketType) => {
|
||||
const stats = getSalesStats(ticketType.id);
|
||||
const percentage = ticketType.quantity > 0
|
||||
? (stats.sold / ticketType.quantity) * 100
|
||||
: 0;
|
||||
|
||||
const isActive = ticketType.is_active && ticketType.sale_start <= new Date() &&
|
||||
(!ticketType.sale_end || ticketType.sale_end >= new Date());
|
||||
|
||||
return (
|
||||
<tr key={ticketType.id} className="border-b border-white/10 hover:bg-white/5 transition-colors">
|
||||
<tr key={ticketType.id} className="border-b transition-colors hover:opacity-90" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-white">{ticketType.name}</div>
|
||||
{ticketType.description && (
|
||||
<div className="text-white/60 text-sm">{ticketType.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
ticketType.is_active
|
||||
? 'bg-green-500/20 text-green-300 border border-green-500/30'
|
||||
: 'bg-red-500/20 text-red-300 border border-red-500/30'
|
||||
}`}>
|
||||
{ticketType.is_active ? 'Active' : 'Inactive'}
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: 'var(--glass-text-primary)' }}>{ticketType.name}</div>
|
||||
{ticketType.description && (
|
||||
<div className="text-sm" style={{ color: 'var(--glass-text-secondary)' }}>{ticketType.description}</div>
|
||||
)}
|
||||
<span
|
||||
className={`inline-block mt-1 px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isActive ? 'premium-success' : 'premium-error'
|
||||
}`}
|
||||
>
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-white font-semibold">
|
||||
{formatCurrency(ticketType.price_cents)}
|
||||
<td className="py-4 px-4 font-semibold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||
{formatCurrency(ticketType.price)}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-white">{stats.sold}</td>
|
||||
<td className="py-4 px-4 text-white">{stats.available}</td>
|
||||
<td className="py-4 px-4 text-white font-semibold">
|
||||
<td className="py-4 px-4" style={{ color: 'var(--glass-text-primary)' }}>{stats.sold}</td>
|
||||
<td className="py-4 px-4" style={{ color: 'var(--glass-text-primary)' }}>{stats.available}</td>
|
||||
<td className="py-4 px-4 font-semibold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||
{formatCurrency(stats.revenue)}
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-white/10 rounded-full h-2">
|
||||
<div className="w-20 rounded-full h-2" style={{ background: 'var(--glass-bg)' }}>
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
className="h-2 rounded-full bg-gradient-to-r from-blue-600 to-purple-600"
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white/60 text-sm">{percentage.toFixed(1)}%</span>
|
||||
<span className="text-sm" style={{ color: 'var(--glass-text-tertiary)' }}>{percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEditTicketType(ticketType)}
|
||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
||||
title="Edit"
|
||||
className="p-2 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--glass-text-tertiary)' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleTicketType(ticketType)}
|
||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
||||
title={ticketType.is_active ? 'Deactivate' : 'Activate'}
|
||||
onClick={() => handleToggleStatus(ticketType)}
|
||||
className="p-2 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--glass-text-tertiary)' }}
|
||||
>
|
||||
{ticketType.is_active ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.142 4.142M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTicketType(ticketType)}
|
||||
className="p-2 text-white/60 hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
onClick={() => handleDeleteTicketType(ticketType.id)}
|
||||
className="p-2 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--error-color)' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -268,7 +276,7 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: 'var(--glass-text-primary)' }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -276,35 +284,48 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-light text-white">Ticket Types & Pricing</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center bg-white/10 rounded-lg p-1">
|
||||
<h2 className="text-2xl font-light" style={{ color: 'var(--glass-text-primary)' }}>Ticket Types & Pricing</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center rounded-lg p-1" style={{ background: 'var(--glass-bg)' }}>
|
||||
<button
|
||||
onClick={() => setViewMode('card')}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||
className={`px-3 py-1.5 rounded-md transition-all ${
|
||||
viewMode === 'card'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white'
|
||||
? ''
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
background: viewMode === 'card' ? 'var(--glass-bg-lg)' : 'transparent',
|
||||
color: viewMode === 'card' ? 'var(--glass-text-primary)' : 'var(--glass-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
Cards
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||
className={`px-3 py-1.5 rounded-md transition-all ${
|
||||
viewMode === 'list'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white'
|
||||
? ''
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
background: viewMode === 'list' ? 'var(--glass-bg-lg)' : 'transparent',
|
||||
color: viewMode === 'list' ? 'var(--glass-text-primary)' : 'var(--glass-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
List
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateTicketType}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 rounded-lg font-medium transition-all duration-200"
|
||||
style={{ color: '#ffffff' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Ticket Type
|
||||
@@ -313,14 +334,15 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
</div>
|
||||
|
||||
{ticketTypes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-white/40 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="text-center py-12 rounded-xl glass-card">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" style={{ color: 'var(--glass-text-tertiary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
<p className="text-white/60 mb-4">No ticket types created yet</p>
|
||||
<p className="mb-4" style={{ color: 'var(--glass-text-secondary)' }}>No ticket types created yet</p>
|
||||
<button
|
||||
onClick={handleCreateTicketType}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 rounded-lg font-medium transition-all duration-200"
|
||||
style={{ color: '#ffffff' }}
|
||||
>
|
||||
Create Your First Ticket Type
|
||||
</button>
|
||||
@@ -328,25 +350,25 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{ticketTypes.map(renderTicketTypeCard)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{ticketTypes.map(renderTicketCard)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/5 border border-white/20 rounded-xl overflow-hidden">
|
||||
<div className="rounded-xl overflow-hidden glass-card">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<thead style={{ background: 'var(--glass-bg-lg)' }}>
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Name</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Price</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Sold</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Available</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Revenue</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Progress</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Actions</th>
|
||||
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Price</th>
|
||||
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Sold</th>
|
||||
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Available</th>
|
||||
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Revenue</th>
|
||||
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Progress</th>
|
||||
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ticketTypes.map(renderTicketTypeList)}
|
||||
{ticketTypes.map(renderTicketRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -354,13 +376,14 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<TicketTypeModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={handleModalSave}
|
||||
eventId={eventId}
|
||||
ticketType={editingTicketType}
|
||||
/>
|
||||
{showModal && (
|
||||
<TicketTypeModal
|
||||
eventId={eventId}
|
||||
ticketType={editingTicketType}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={loadData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,17 +31,30 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
|
||||
const [mapsData, eventData] = await Promise.all([
|
||||
loadSeatingMaps(organizationId),
|
||||
loadEventData(eventId, organizationId)
|
||||
]);
|
||||
|
||||
setSeatingMaps(mapsData);
|
||||
setVenueData(eventData?.venue_data || {});
|
||||
// Validate seating maps data
|
||||
const validMaps = mapsData.filter(map => {
|
||||
if (!map.layout_data) {
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
setSeatingMaps(validMaps);
|
||||
setVenueData({
|
||||
venue: eventData?.venue || '',
|
||||
seating_type: eventData?.seating_type || 'general_admission'
|
||||
});
|
||||
setCurrentSeatingMap(eventData?.seating_map || null);
|
||||
setSeatingType(eventData?.seating_map ? 'assigned' : 'general');
|
||||
setSeatingType(eventData?.seating_type === 'assigned' ? 'assigned' : 'general');
|
||||
} catch (error) {
|
||||
console.error('Error loading venue data:', error);
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -97,7 +110,9 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
||||
};
|
||||
|
||||
const renderSeatingPreview = (seatingMap: SeatingMap) => {
|
||||
const layoutItems = seatingMap.layout_data as LayoutItem[];
|
||||
const layoutItems = Array.isArray(seatingMap.layout_data)
|
||||
? seatingMap.layout_data as LayoutItem[]
|
||||
: [];
|
||||
const totalCapacity = layoutItems.reduce((sum, item) => sum + (item.capacity || 0), 0);
|
||||
|
||||
return (
|
||||
@@ -180,7 +195,10 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleApplySeatingMap(seatingMap)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||
className="px-4 py-2 text-white rounded-lg text-sm transition-colors"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)'
|
||||
}}
|
||||
>
|
||||
Apply to Event
|
||||
</button>
|
||||
@@ -205,7 +223,11 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
||||
<h2 className="text-2xl font-light text-white">Venue & Seating</h2>
|
||||
<button
|
||||
onClick={handleCreateSeatingMap}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@@ -280,14 +302,22 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
||||
<p className="text-white/60 mb-4">No seating maps created yet</p>
|
||||
<button
|
||||
onClick={handleCreateSeatingMap}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
className="px-6 py-3 rounded-lg font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Create Your First Seating Map
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{seatingMaps.map(renderSeatingPreview)}
|
||||
{seatingMaps.map((seatingMap) => (
|
||||
<div key={seatingMap.id}>
|
||||
{renderSeatingPreview(seatingMap)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function EmbedCodeModal({
|
||||
setCopied(type);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,21 +68,22 @@ export default function EmbedCodeModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-2xl w-full max-w-sm sm:max-w-2xl lg:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-light text-white">Embed Event Widget</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
className="text-white/60 hover:text-white transition-colors p-2 rounded-full hover:bg-white/10 touch-manipulation"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Configuration Panel */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -100,7 +101,11 @@ export default function EmbedCodeModal({
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopy(directLink, 'link')}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-r-lg transition-colors"
|
||||
className="px-4 py-2 rounded-r-lg transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{copied === 'link' ? '✓' : 'Copy'}
|
||||
</button>
|
||||
@@ -224,7 +229,11 @@ export default function EmbedCodeModal({
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
onClick={() => handleCopy(generateEmbedCode(), 'embed')}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
className="px-4 py-2 rounded-lg transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
style={{
|
||||
background: 'var(--glass-text-accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{copied === 'embed' ? '✓ Copied' : 'Copy Code'}
|
||||
</button>
|
||||
|
||||
272
src/components/modals/TicketPreviewModal.tsx
Normal file
272
src/components/modals/TicketPreviewModal.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
interface TicketPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
interface TicketType {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface PreviewData {
|
||||
eventTitle: string;
|
||||
eventDate: string;
|
||||
eventTime: string;
|
||||
venue: string;
|
||||
ticketType: string;
|
||||
price: string;
|
||||
barcode: string;
|
||||
}
|
||||
|
||||
export default function TicketPreviewModal({ isOpen, onClose, eventId, organizationId }: TicketPreviewModalProps) {
|
||||
const [ticketTypes, setTicketTypes] = useState<TicketType[]>([]);
|
||||
const [selectedTicketType, setSelectedTicketType] = useState('');
|
||||
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadTicketTypes();
|
||||
}
|
||||
}, [isOpen, eventId]);
|
||||
|
||||
const loadTicketTypes = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('id, name, price')
|
||||
.eq('event_id', eventId)
|
||||
.eq('is_active', true)
|
||||
.order('display_order');
|
||||
|
||||
if (error) throw error;
|
||||
setTicketTypes(data || []);
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const generateTicketPreview = async (ticketTypeId: string) => {
|
||||
if (!ticketTypeId) {
|
||||
setPreviewData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load event details
|
||||
const { data: event } = await supabase
|
||||
.from('events')
|
||||
.select('title, date, venue')
|
||||
.eq('id', eventId)
|
||||
.single();
|
||||
|
||||
const ticketType = ticketTypes.find(t => t.id === ticketTypeId);
|
||||
|
||||
if (event && ticketType) {
|
||||
const eventDate = new Date(event.date);
|
||||
setPreviewData({
|
||||
eventTitle: event.title,
|
||||
eventDate: eventDate.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}),
|
||||
eventTime: eventDate.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}),
|
||||
venue: event.venue,
|
||||
ticketType: ticketType.name,
|
||||
price: `$${(ticketType.price / 100).toFixed(2)}`,
|
||||
barcode: 'SAMPLE-' + Math.random().toString(36).substr(2, 9).toUpperCase()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
const printContent = document.getElementById('ticket-preview-content');
|
||||
if (!printContent) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Sample Ticket</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.ticket-wrapper { max-width: 400px; margin: 0 auto; }
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.ticket-wrapper { max-width: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${printContent.innerHTML}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={onClose}>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl w-full max-w-sm sm:max-w-2xl lg:max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="flex justify-between items-center mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl lg:text-3xl font-light text-white mb-2">Sample Ticket Preview</h2>
|
||||
<p className="text-sm sm:text-base text-white/80">Preview how your printed tickets will look</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors duration-200 p-2 rounded-full hover:bg-white/10 touch-manipulation"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-4 sm:p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<select
|
||||
value={selectedTicketType}
|
||||
onChange={(e) => {
|
||||
setSelectedTicketType(e.target.value);
|
||||
generateTicketPreview(e.target.value);
|
||||
}}
|
||||
className="bg-white/10 border border-white/20 text-white px-4 py-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select ticket type...</option>
|
||||
{ticketTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name} - ${(type.price / 100).toFixed(2)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={!previewData}
|
||||
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 disabled:from-gray-600 disabled:to-gray-600 text-white px-6 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
Print Sample
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="ticket-preview-content" className="bg-white rounded-2xl p-8 relative min-h-[400px] flex items-center justify-center">
|
||||
{loading ? (
|
||||
<div className="text-gray-500 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-500 mx-auto mb-4"></div>
|
||||
<p className="text-lg font-medium">Generating preview...</p>
|
||||
</div>
|
||||
) : previewData ? (
|
||||
<div className="ticket-wrapper relative">
|
||||
{/* SAMPLE Watermark */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-red-300 text-8xl font-bold opacity-40 transform rotate-45 select-none">
|
||||
SAMPLE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticket Content */}
|
||||
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg p-6 max-w-md mx-auto relative">
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{previewData.eventTitle}</h3>
|
||||
<p className="text-gray-700 font-medium">{previewData.eventDate}</p>
|
||||
<p className="text-gray-700 font-medium">{previewData.eventTime}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-b border-gray-300 py-4 mb-4">
|
||||
<p className="text-gray-600 text-sm uppercase tracking-wide mb-1">Venue</p>
|
||||
<p className="text-gray-900 font-semibold">{previewData.venue}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm uppercase tracking-wide mb-1">Ticket Type</p>
|
||||
<p className="text-gray-900 font-semibold">{previewData.ticketType}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-600 text-sm uppercase tracking-wide mb-1">Price</p>
|
||||
<p className="text-gray-900 font-semibold">{previewData.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 rounded-lg p-4 text-center">
|
||||
<div className="bg-black text-white font-mono text-sm py-2 px-4 rounded mb-2">
|
||||
{previewData.barcode}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<svg className="h-12" viewBox="0 0 200 50">
|
||||
{[...Array(40)].map((_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={i * 5}
|
||||
y="0"
|
||||
width={Math.random() > 0.5 ? 3 : 2}
|
||||
height="50"
|
||||
fill="#000"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-500 text-xs mt-4">
|
||||
This is a sample ticket for preview purposes only
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium mb-2">Select a ticket type to preview</p>
|
||||
<p className="text-sm opacity-75">The preview will show how your ticket will look when printed</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-3 rounded-xl font-medium text-white/80 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,9 +35,9 @@ export default function AttendeesTable({
|
||||
const attendeeMap = new Map<string, AttendeeData>();
|
||||
|
||||
orders.forEach(order => {
|
||||
const existing = attendeeMap.get(order.customer_email) || {
|
||||
email: order.customer_email,
|
||||
name: order.customer_name,
|
||||
const existing = attendeeMap.get(order.purchaser_email) || {
|
||||
email: order.purchaser_email,
|
||||
name: order.purchaser_name,
|
||||
ticketCount: 0,
|
||||
totalSpent: 0,
|
||||
checkedInCount: 0,
|
||||
@@ -45,18 +45,19 @@ export default function AttendeesTable({
|
||||
};
|
||||
|
||||
existing.tickets.push(order);
|
||||
if (order.status === 'confirmed') {
|
||||
if (!order.refund_status || order.refund_status === null) {
|
||||
existing.ticketCount += 1;
|
||||
existing.totalSpent += order.price_paid;
|
||||
existing.totalSpent += order.price;
|
||||
if (order.checked_in) {
|
||||
existing.checkedInCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
attendeeMap.set(order.customer_email, existing);
|
||||
attendeeMap.set(order.purchaser_email, existing);
|
||||
});
|
||||
|
||||
return Array.from(attendeeMap.values());
|
||||
// Only show attendees with active tickets (ticketCount > 0)
|
||||
return Array.from(attendeeMap.values()).filter(attendee => attendee.ticketCount > 0);
|
||||
}, [orders]);
|
||||
|
||||
const sortedAttendees = useMemo(() => {
|
||||
|
||||
@@ -54,29 +54,32 @@ export default function OrdersTable({
|
||||
const SortIcon = ({ field }: { field: keyof SalesData }) => {
|
||||
if (sortField !== field) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 text-[var(--ui-text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? (
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 text-[var(--ui-text-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 text-[var(--ui-text-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const getStatusBadge = (refund_status: string | null) => {
|
||||
const status = (!refund_status || refund_status === null) ? 'confirmed' :
|
||||
refund_status === 'completed' ? 'refunded' :
|
||||
refund_status === 'pending' ? 'pending' : 'cancelled';
|
||||
const statusClasses = {
|
||||
confirmed: 'bg-green-500/20 text-green-300 border-green-500/30',
|
||||
pending: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30',
|
||||
refunded: 'bg-red-500/20 text-red-300 border-red-500/30',
|
||||
cancelled: 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
confirmed: 'bg-[var(--success-bg)] text-[var(--success-color)] border-[var(--success-border)]',
|
||||
pending: 'bg-[var(--warning-bg)] text-[var(--warning-color)] border-[var(--warning-border)]',
|
||||
refunded: 'bg-[var(--error-bg)] text-[var(--error-color)] border-[var(--error-border)]',
|
||||
cancelled: 'bg-[var(--ui-bg-secondary)] text-[var(--ui-text-tertiary)] border-[var(--ui-border-secondary)]'
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -99,10 +102,10 @@ export default function OrdersTable({
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-white/40 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-12 h-12 text-[var(--ui-text-muted)] mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p className="text-white/60">No orders found</p>
|
||||
<p className="text-[var(--ui-text-tertiary)]">No orders found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,102 +115,102 @@ export default function OrdersTable({
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/20">
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">
|
||||
<tr className="border-b border-[var(--ui-border-primary)]">
|
||||
<th className="text-left py-3 px-4 text-[var(--ui-text-secondary)] font-medium">
|
||||
<button
|
||||
onClick={() => handleSort('customer_name')}
|
||||
className="flex items-center space-x-1 hover:text-white transition-colors"
|
||||
onClick={() => handleSort('purchaser_name')}
|
||||
className="flex items-center space-x-1 hover:text-[var(--ui-text-primary)] transition-colors"
|
||||
>
|
||||
<span>Customer</span>
|
||||
<SortIcon field="customer_name" />
|
||||
<SortIcon field="purchaser_name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">
|
||||
<th className="text-left py-3 px-4 text-[var(--ui-text-secondary)] font-medium">
|
||||
<button
|
||||
onClick={() => handleSort('ticket_types')}
|
||||
className="flex items-center space-x-1 hover:text-white transition-colors"
|
||||
className="flex items-center space-x-1 hover:text-[var(--ui-text-primary)] transition-colors"
|
||||
>
|
||||
<span>Ticket Type</span>
|
||||
<SortIcon field="ticket_types" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">
|
||||
<th className="text-left py-3 px-4 text-[var(--ui-text-secondary)] font-medium">
|
||||
<button
|
||||
onClick={() => handleSort('price_paid')}
|
||||
className="flex items-center space-x-1 hover:text-white transition-colors"
|
||||
onClick={() => handleSort('price')}
|
||||
className="flex items-center space-x-1 hover:text-[var(--ui-text-primary)] transition-colors"
|
||||
>
|
||||
<span>Amount</span>
|
||||
<SortIcon field="price_paid" />
|
||||
<SortIcon field="price" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">
|
||||
<th className="text-left py-3 px-4 text-[var(--ui-text-secondary)] font-medium">
|
||||
<button
|
||||
onClick={() => handleSort('status')}
|
||||
className="flex items-center space-x-1 hover:text-white transition-colors"
|
||||
onClick={() => handleSort('refund_status')}
|
||||
className="flex items-center space-x-1 hover:text-[var(--ui-text-primary)] transition-colors"
|
||||
>
|
||||
<span>Status</span>
|
||||
<SortIcon field="status" />
|
||||
<SortIcon field="refund_status" />
|
||||
</button>
|
||||
</th>
|
||||
{showCheckIn && (
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">
|
||||
<th className="text-left py-3 px-4 text-[var(--ui-text-secondary)] font-medium">
|
||||
<button
|
||||
onClick={() => handleSort('checked_in')}
|
||||
className="flex items-center space-x-1 hover:text-white transition-colors"
|
||||
className="flex items-center space-x-1 hover:text-[var(--ui-text-primary)] transition-colors"
|
||||
>
|
||||
<span>Check-in</span>
|
||||
<SortIcon field="checked_in" />
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left py-3 px-4 text-white/80 font-medium">
|
||||
<th className="text-left py-3 px-4 text-[var(--ui-text-secondary)] font-medium">
|
||||
<button
|
||||
onClick={() => handleSort('created_at')}
|
||||
className="flex items-center space-x-1 hover:text-white transition-colors"
|
||||
className="flex items-center space-x-1 hover:text-[var(--ui-text-primary)] transition-colors"
|
||||
>
|
||||
<span>Date</span>
|
||||
<SortIcon field="created_at" />
|
||||
</button>
|
||||
</th>
|
||||
{showActions && (
|
||||
<th className="text-right py-3 px-4 text-white/80 font-medium">Actions</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--ui-text-secondary)] font-medium">Actions</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="border-b border-white/10 hover:bg-white/5 transition-colors">
|
||||
<tr key={order.id} className="border-b border-[var(--ui-border-secondary)] hover:bg-[var(--ui-bg-secondary)] transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<div className="text-white font-medium">{order.customer_name}</div>
|
||||
<div className="text-white/60 text-sm">{order.customer_email}</div>
|
||||
<div className="text-[var(--ui-text-primary)] font-medium">{order.purchaser_name}</div>
|
||||
<div className="text-[var(--ui-text-tertiary)] text-sm">{order.purchaser_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-white">{order.ticket_types.name}</div>
|
||||
<div className="text-[var(--ui-text-primary)]">{order.ticket_types.name}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-white font-medium">{formatCurrency(order.price_paid)}</div>
|
||||
<div className="text-[var(--ui-text-primary)] font-medium">{formatCurrency(order.price)}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{getStatusBadge(order.status)}
|
||||
{getStatusBadge(order.refund_status)}
|
||||
</td>
|
||||
{showCheckIn && (
|
||||
<td className="py-3 px-4">
|
||||
{order.checked_in ? (
|
||||
<span className="text-green-400 flex items-center">
|
||||
<span className="text-[var(--success-color)] flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Checked In
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/60">Not Checked In</span>
|
||||
<span className="text-[var(--ui-text-tertiary)]">Not Checked In</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-white/80 text-sm">{formatDate(order.created_at)}</div>
|
||||
<div className="text-[var(--ui-text-secondary)] text-sm">{formatDate(order.created_at)}</div>
|
||||
</td>
|
||||
{showActions && (
|
||||
<td className="py-3 px-4 text-right">
|
||||
@@ -215,7 +218,7 @@ export default function OrdersTable({
|
||||
{onViewOrder && (
|
||||
<button
|
||||
onClick={() => onViewOrder(order)}
|
||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
||||
className="p-2 text-[var(--ui-text-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
|
||||
title="View Details"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -224,10 +227,10 @@ export default function OrdersTable({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onCheckIn && !order.checked_in && order.status === 'confirmed' && (
|
||||
{onCheckIn && !order.checked_in && (!order.refund_status || order.refund_status === null) && (
|
||||
<button
|
||||
onClick={() => onCheckIn(order)}
|
||||
className="p-2 text-white/60 hover:text-green-400 transition-colors"
|
||||
className="p-2 text-[var(--ui-text-tertiary)] hover:text-[var(--success-color)] transition-colors"
|
||||
title="Check In"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -235,10 +238,10 @@ export default function OrdersTable({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onRefundOrder && order.status === 'confirmed' && (
|
||||
{onRefundOrder && (!order.refund_status || order.refund_status === null) && (
|
||||
<button
|
||||
onClick={() => onRefundOrder(order)}
|
||||
className="p-2 text-white/60 hover:text-red-400 transition-colors"
|
||||
className="p-2 text-[var(--ui-text-tertiary)] hover:text-[var(--error-color)] transition-colors"
|
||||
title="Refund"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -258,24 +261,24 @@ export default function OrdersTable({
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-white/60 text-sm">
|
||||
<div className="text-[var(--ui-text-tertiary)] text-sm">
|
||||
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, orders.length)} of {orders.length} orders
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 bg-[var(--ui-bg-primary)] text-[var(--ui-text-primary)] rounded-lg hover:bg-[var(--ui-bg-elevated)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-white/80 text-sm">
|
||||
<span className="text-[var(--ui-text-secondary)] text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 bg-[var(--ui-bg-primary)] text-[var(--ui-text-primary)] rounded-lg hover:bg-[var(--ui-bg-elevated)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -17,6 +17,32 @@ import CookieConsent from '../components/CookieConsent.astro';
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
|
||||
<!-- Critical theme initialization - prevents FOUC -->
|
||||
<script>
|
||||
(function() {
|
||||
// Get theme immediately - no localStorage check to avoid blocking
|
||||
const savedTheme = (function() {
|
||||
try {
|
||||
return localStorage.getItem('theme') ||
|
||||
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
} catch (e) {
|
||||
return 'dark';
|
||||
}
|
||||
})();
|
||||
|
||||
// Apply theme immediately to prevent flash
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
document.documentElement.classList.add(savedTheme);
|
||||
|
||||
// Store for later use
|
||||
window.__INITIAL_THEME__ = savedTheme;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Import stylesheets -->
|
||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||
<link rel="stylesheet" href="/src/styles/glassmorphism.css" />
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<!-- Skip Links for Accessibility -->
|
||||
@@ -31,8 +57,47 @@ import CookieConsent from '../components/CookieConsent.astro';
|
||||
<Footer />
|
||||
<CookieConsent />
|
||||
|
||||
<!-- Initialize accessibility features -->
|
||||
<!-- Initialize theme management, accessibility, and performance optimizations -->
|
||||
<script>
|
||||
// Theme management
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Apply initial theme to body
|
||||
const initialTheme = window.__INITIAL_THEME__ || 'dark';
|
||||
document.body.classList.remove('light', 'dark');
|
||||
document.body.classList.add(initialTheme);
|
||||
|
||||
// Initialize performance optimizations
|
||||
import('/src/lib/performance.js').then(({ initializePerformanceOptimizations }) => {
|
||||
initializePerformanceOptimizations();
|
||||
}).catch(() => {
|
||||
// Fallback for browsers that don't support dynamic imports
|
||||
console.log('Performance optimizations not available');
|
||||
});
|
||||
|
||||
// Listen for theme changes
|
||||
window.addEventListener('themeChanged', (e) => {
|
||||
const newTheme = e.detail.theme;
|
||||
|
||||
// Update document element
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(newTheme);
|
||||
|
||||
// Update body
|
||||
document.body.classList.remove('light', 'dark');
|
||||
document.body.classList.add(newTheme);
|
||||
|
||||
// Store theme
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Force style recalculation
|
||||
document.body.style.display = 'none';
|
||||
document.body.offsetHeight;
|
||||
document.body.style.display = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize accessibility features
|
||||
import { initializeAccessibility, initializeHighContrastSupport, initializeReducedMotionSupport } from '../lib/accessibility';
|
||||
|
||||
// Initialize all accessibility features
|
||||
|
||||
@@ -16,6 +16,43 @@ import CookieConsent from '../components/CookieConsent.astro';
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
|
||||
<!-- Critical theme initialization - prevents FOUC -->
|
||||
<script>
|
||||
(function() {
|
||||
// Force dark mode for login page to prevent flickering
|
||||
const forcedTheme = 'dark';
|
||||
|
||||
// Apply theme immediately to prevent flash
|
||||
document.documentElement.setAttribute('data-theme', forcedTheme);
|
||||
document.documentElement.classList.add(forcedTheme);
|
||||
document.documentElement.classList.remove('light');
|
||||
|
||||
// Apply to body immediately as well
|
||||
document.body.classList.add(forcedTheme);
|
||||
document.body.classList.remove('light');
|
||||
|
||||
// Store for later use
|
||||
window.__INITIAL_THEME__ = forcedTheme;
|
||||
window.__FORCE_DARK_MODE__ = true;
|
||||
|
||||
// Override localStorage temporarily to prevent theme switching
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = function(key, value) {
|
||||
if (key === 'theme' && value !== 'dark') {
|
||||
console.log('[THEME] Blocking theme change to:', value);
|
||||
return;
|
||||
}
|
||||
return originalSetItem.call(this, key, value);
|
||||
};
|
||||
|
||||
console.log('[THEME] Forced dark mode initialized');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Import stylesheets -->
|
||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||
<link rel="stylesheet" href="/src/styles/glassmorphism.css" />
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Skip Links for Accessibility -->
|
||||
@@ -25,8 +62,31 @@ import CookieConsent from '../components/CookieConsent.astro';
|
||||
|
||||
<CookieConsent />
|
||||
|
||||
<!-- Initialize accessibility features -->
|
||||
<!-- Initialize theme management and accessibility features -->
|
||||
<script>
|
||||
// Theme management - force dark mode only
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Force dark theme regardless of saved preferences
|
||||
const forcedTheme = 'dark';
|
||||
document.body.classList.remove('light', 'dark');
|
||||
document.body.classList.add(forcedTheme);
|
||||
|
||||
// Block any theme change events on login page
|
||||
window.addEventListener('themeChanged', (e) => {
|
||||
// Prevent theme changes on login page
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Force back to dark
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.classList.remove('light');
|
||||
document.documentElement.classList.add('dark');
|
||||
document.body.classList.remove('light');
|
||||
document.body.classList.add('dark');
|
||||
}, true);
|
||||
});
|
||||
|
||||
// Initialize accessibility features
|
||||
import { initializeAccessibility, initializeHighContrastSupport, initializeReducedMotionSupport } from '../lib/accessibility';
|
||||
|
||||
// Initialize all accessibility features
|
||||
|
||||
@@ -15,13 +15,44 @@ import Navigation from '../components/Navigation.astro';
|
||||
|
||||
<Layout title={title}>
|
||||
<style>
|
||||
.bg-grid-pattern {
|
||||
[data-theme="dark"] .bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(139, 92, 246, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(139, 92, 246, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .secure-layout-bg {
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0%, #fafafa 50%, #f8f7fc 100%),
|
||||
radial-gradient(ellipse at top left, rgba(237, 233, 254, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(221, 214, 254, 0.2) 0%, transparent 50%);
|
||||
background-size: 100% 100%, 80% 80%, 60% 60%;
|
||||
}
|
||||
|
||||
[data-theme="light"] .bg-orb-light-1 {
|
||||
background: radial-gradient(circle at center, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .bg-orb-light-2 {
|
||||
background: radial-gradient(circle at center, rgba(168, 85, 247, 0.06) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .bg-orb-light-3 {
|
||||
background: radial-gradient(circle at center, rgba(196, 181, 253, 0.05) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .secure-layout-bg {
|
||||
background: var(--bg-gradient);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -51,12 +82,12 @@ import Navigation from '../components/Navigation.astro';
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<div class="min-h-screen secure-layout-bg">
|
||||
<!-- Animated background elements -->
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-purple-600/20 to-pink-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-blue-600/20 to-cyan-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-gradient-to-br from-indigo-600/10 to-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 rounded-full blur-3xl animate-pulse bg-orb-light-1" style="background: var(--bg-orb-1);"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 rounded-full blur-3xl animate-pulse bg-orb-light-2" style="background: var(--bg-orb-2);"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full blur-3xl animate-pulse bg-orb-light-3" style="background: var(--bg-orb-3);"></div>
|
||||
</div>
|
||||
|
||||
<!-- Grid pattern overlay -->
|
||||
@@ -79,4 +110,15 @@ import Navigation from '../components/Navigation.astro';
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Theme transition support -->
|
||||
<script>
|
||||
// Add smooth theme transitions
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mainDiv = document.querySelector('.min-h-screen');
|
||||
if (mainDiv) {
|
||||
mainDiv.style.transition = 'background 0.3s ease';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
@@ -186,7 +186,7 @@ export function addKeyboardNavigation() {
|
||||
*/
|
||||
export function validateColorContrast() {
|
||||
// This would typically integrate with a color contrast checking library
|
||||
console.log('Color contrast validation would run here');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface EventAddOn {
|
||||
status: 'active' | 'cancelled' | 'expired';
|
||||
purchased_at: string;
|
||||
expires_at?: string;
|
||||
metadata?: Record<string, any>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AddOnWithAccess extends AddOnType {
|
||||
@@ -46,7 +46,7 @@ export async function getAvailableAddOns(
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data.map((item: any) => ({
|
||||
return data.map((item: Record<string, unknown>) => ({
|
||||
id: item.addon_id,
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
@@ -62,7 +62,7 @@ export async function getAvailableAddOns(
|
||||
purchased_at: item.purchased_at
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching available add-ons:', error);
|
||||
console.error('Available add-ons loading error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export async function hasFeatureAccess(
|
||||
if (error) throw error;
|
||||
return data === true;
|
||||
} catch (error) {
|
||||
console.error('Error checking feature access:', error);
|
||||
console.error('Feature access check error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export async function purchaseEventAddOn(
|
||||
addOnTypeId: string,
|
||||
organizationId: string,
|
||||
priceCents: number,
|
||||
metadata?: Record<string, any>
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<{ success: boolean; addOnId?: string; error?: string }> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
@@ -115,7 +115,7 @@ export async function purchaseEventAddOn(
|
||||
|
||||
return { success: true, addOnId: data.id };
|
||||
} catch (error) {
|
||||
console.error('Error purchasing add-on:', error);
|
||||
console.error('Add-on purchase error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
@@ -143,7 +143,7 @@ export async function getEventAddOns(eventId: string): Promise<EventAddOn[]> {
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching event add-ons:', error);
|
||||
console.error('Event add-ons loading error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export async function calculateAddOnRevenue(organizationId: string): Promise<{
|
||||
if (subError) throw subError;
|
||||
|
||||
const subscriptionRevenue = (subscriptions || [])
|
||||
.reduce((sum, sub: any) => sum + (sub.add_on_types?.price_cents || 0), 0);
|
||||
.reduce((sum, sub: Record<string, unknown>) => sum + ((sub.add_on_types as Record<string, unknown>)?.price_cents as number || 0), 0);
|
||||
|
||||
return {
|
||||
totalRevenue: eventRevenue + subscriptionRevenue,
|
||||
@@ -233,7 +233,7 @@ export async function calculateAddOnRevenue(organizationId: string): Promise<{
|
||||
subscriptionRevenue
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calculating add-on revenue:', error);
|
||||
console.error('Add-on revenue calculation error:', error);
|
||||
return {
|
||||
totalRevenue: 0,
|
||||
eventAddOns: 0,
|
||||
|
||||
607
src/lib/admin-api-router.ts
Normal file
607
src/lib/admin-api-router.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
/**
|
||||
* Admin API Router for centralized admin dashboard API calls
|
||||
* This provides a centralized way to handle admin-specific API operations
|
||||
*/
|
||||
export class AdminApiRouter {
|
||||
private session: any = null;
|
||||
private isAdmin = false;
|
||||
|
||||
/**
|
||||
* Initialize the admin API router with authentication
|
||||
*/
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.session = session;
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRecord, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('role, name, email')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (userError || !userRecord || userRecord.role !== 'admin') {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isAdmin = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform statistics for admin dashboard
|
||||
*/
|
||||
async getPlatformStats(): Promise<{
|
||||
organizations: number;
|
||||
events: number;
|
||||
tickets: number;
|
||||
revenue: number;
|
||||
platformFees: number;
|
||||
users: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return { organizations: 0, events: 0, tickets: 0, revenue: 0, platformFees: 0, users: 0, error: 'Admin authentication required' };
|
||||
}
|
||||
}
|
||||
|
||||
const [organizationsResult, eventsResult, ticketsResult, usersResult] = await Promise.all([
|
||||
supabase.from('organizations').select('id'),
|
||||
supabase.from('events').select('id'),
|
||||
supabase.from('tickets').select('price'),
|
||||
supabase.from('users').select('id')
|
||||
]);
|
||||
|
||||
const organizations = organizationsResult.data?.length || 0;
|
||||
const events = eventsResult.data?.length || 0;
|
||||
const users = usersResult.data?.length || 0;
|
||||
const tickets = ticketsResult.data || [];
|
||||
const ticketCount = tickets.length;
|
||||
const revenue = tickets.reduce((sum, ticket) => sum + (ticket.price || 0), 0);
|
||||
const platformFees = revenue * 0.05; // Assuming 5% platform fee
|
||||
|
||||
return {
|
||||
organizations,
|
||||
events,
|
||||
tickets: ticketCount,
|
||||
revenue,
|
||||
platformFees,
|
||||
users
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
return { organizations: 0, events: 0, tickets: 0, revenue: 0, platformFees: 0, users: 0, error: 'Failed to load platform statistics' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent activity for admin dashboard
|
||||
*/
|
||||
async getRecentActivity(): Promise<any[]> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const [eventsResult, usersResult, ticketsResult] = await Promise.all([
|
||||
supabase.from('events').select('*, organizations(name)').order('created_at', { ascending: false }).limit(5),
|
||||
supabase.from('users').select('*, organizations(name)').order('created_at', { ascending: false }).limit(5),
|
||||
supabase.from('tickets').select('*, events(title)').order('created_at', { ascending: false }).limit(10)
|
||||
]);
|
||||
|
||||
const activities = [];
|
||||
|
||||
// Add recent events
|
||||
if (eventsResult.data) {
|
||||
eventsResult.data.forEach(event => {
|
||||
activities.push({
|
||||
type: 'event',
|
||||
title: `New event created: ${event.title}`,
|
||||
subtitle: `by ${event.organizations?.name || 'Unknown'}`,
|
||||
time: new Date(event.created_at),
|
||||
icon: '📅'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add recent users
|
||||
if (usersResult.data) {
|
||||
usersResult.data.forEach(user => {
|
||||
activities.push({
|
||||
type: 'user',
|
||||
title: `New user registered: ${user.name || user.email}`,
|
||||
subtitle: `Organization: ${user.organizations?.name || 'None'}`,
|
||||
time: new Date(user.created_at),
|
||||
icon: '👤'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add recent tickets
|
||||
if (ticketsResult.data) {
|
||||
ticketsResult.data.slice(0, 5).forEach(ticket => {
|
||||
activities.push({
|
||||
type: 'ticket',
|
||||
title: `Ticket sold: $${ticket.price}`,
|
||||
subtitle: `for ${ticket.events?.title || 'Unknown Event'}`,
|
||||
time: new Date(ticket.created_at),
|
||||
icon: '🎫'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by time and take the most recent 10
|
||||
activities.sort((a, b) => b.time - a.time);
|
||||
return activities.slice(0, 10);
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single organization by ID
|
||||
*/
|
||||
async getOrganization(orgId: string): Promise<any | null> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const { data: org, error } = await supabase
|
||||
.from('organizations')
|
||||
.select('*')
|
||||
.eq('id', orgId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return org;
|
||||
} catch (error) {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organizations with additional metadata
|
||||
*/
|
||||
async getOrganizations(): Promise<any[]> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { data: orgs, error } = await supabase
|
||||
.from('organizations')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get user counts for each organization
|
||||
if (orgs) {
|
||||
for (const org of orgs) {
|
||||
const { data: users } = await supabase
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('organization_id', org.id);
|
||||
org.user_count = users ? users.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return orgs || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with organization details
|
||||
*/
|
||||
async getUsers(): Promise<any[]> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { data: users, error } = await supabase
|
||||
.from('users')
|
||||
.select(`
|
||||
*,
|
||||
organizations(name)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return users || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events with organization and user details
|
||||
*/
|
||||
async getEvents(): Promise<any[]> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { data: events, error } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
*,
|
||||
organizations(name),
|
||||
users(name, email),
|
||||
venues(name)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get ticket type counts for each event
|
||||
if (events) {
|
||||
for (const event of events) {
|
||||
const { data: ticketTypes } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('id')
|
||||
.eq('event_id', event.id);
|
||||
event.ticket_type_count = ticketTypes ? ticketTypes.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return events || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets with event and organization details
|
||||
*/
|
||||
async getTickets(): Promise<any[]> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { data: tickets, error } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
*,
|
||||
ticket_types (
|
||||
name,
|
||||
price
|
||||
),
|
||||
events (
|
||||
title,
|
||||
venue,
|
||||
start_time,
|
||||
organizations (
|
||||
name
|
||||
)
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100);
|
||||
|
||||
if (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return tickets || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization platform fees
|
||||
*/
|
||||
async updateOrganizationFees(orgId: string, feeData: {
|
||||
platform_fee_type: string;
|
||||
platform_fee_percentage?: number;
|
||||
platform_fee_fixed?: number;
|
||||
platform_fee_model: string;
|
||||
platform_fee_notes?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return { success: false, error: 'Admin authentication required' };
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
platform_fee_type: feeData.platform_fee_type,
|
||||
platform_fee_model: feeData.platform_fee_model,
|
||||
absorb_fee_in_price: feeData.platform_fee_model === 'absorbed_in_price',
|
||||
platform_fee_notes: feeData.platform_fee_notes || null
|
||||
};
|
||||
|
||||
// Only set fields that should be used based on fee type
|
||||
switch (feeData.platform_fee_type) {
|
||||
case 'percentage_only':
|
||||
updateData.platform_fee_percentage = feeData.platform_fee_percentage || 0;
|
||||
updateData.platform_fee_fixed = 0;
|
||||
break;
|
||||
case 'fixed_only':
|
||||
updateData.platform_fee_percentage = 0;
|
||||
updateData.platform_fee_fixed = feeData.platform_fee_fixed || 0;
|
||||
break;
|
||||
case 'percentage_plus_fixed':
|
||||
default:
|
||||
updateData.platform_fee_percentage = feeData.platform_fee_percentage || 0;
|
||||
updateData.platform_fee_fixed = feeData.platform_fee_fixed || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('organizations')
|
||||
.update(updateData)
|
||||
.eq('id', orgId);
|
||||
|
||||
if (error) {
|
||||
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { success: false, error: 'Failed to update organization fees' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test event for demonstration
|
||||
*/
|
||||
async createTestEvent(): Promise<{ success: boolean; error?: string; eventId?: string }> {
|
||||
try {
|
||||
if (!this.isAdmin) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return { success: false, error: 'Admin authentication required' };
|
||||
}
|
||||
}
|
||||
|
||||
// Create a test organization first if none exists
|
||||
let testOrgId;
|
||||
const { data: orgs } = await supabase.from('organizations').select('id').limit(1);
|
||||
|
||||
if (!orgs || orgs.length === 0) {
|
||||
const { data: newOrg, error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
.insert({
|
||||
name: 'Demo Organization',
|
||||
website: 'https://demo.blackcanyontickets.com',
|
||||
description: 'Test organization for platform demonstration'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orgError) {
|
||||
|
||||
return { success: false, error: orgError.message };
|
||||
}
|
||||
testOrgId = newOrg.id;
|
||||
} else {
|
||||
testOrgId = orgs[0].id;
|
||||
}
|
||||
|
||||
// Create test event
|
||||
const { data: event, error } = await supabase
|
||||
.from('events')
|
||||
.insert({
|
||||
title: 'Demo Concert - ' + new Date().toLocaleDateString(),
|
||||
slug: 'demo-concert-' + Date.now(),
|
||||
description: 'A demonstration event showcasing the Black Canyon Tickets platform features.',
|
||||
venue: 'Demo Venue',
|
||||
start_time: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
end_time: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString(), // +3 hours
|
||||
organization_id: testOrgId,
|
||||
is_published: true
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
// Create sample ticket types
|
||||
const ticketTypesResult = await supabase.from('ticket_types').insert([
|
||||
{
|
||||
event_id: event.id,
|
||||
name: 'General Admission',
|
||||
price: 25.00,
|
||||
quantity_available: 100,
|
||||
description: 'Standard entry ticket'
|
||||
},
|
||||
{
|
||||
event_id: event.id,
|
||||
name: 'VIP',
|
||||
price: 75.00,
|
||||
quantity_available: 20,
|
||||
description: 'VIP experience with premium benefits'
|
||||
}
|
||||
]);
|
||||
|
||||
if (ticketTypesResult.error) {
|
||||
|
||||
// Event created successfully but ticket types failed
|
||||
return { success: true, eventId: event.id, error: 'Event created but ticket types failed' };
|
||||
}
|
||||
|
||||
return { success: true, eventId: event.id };
|
||||
} catch (error) {
|
||||
|
||||
return { success: false, error: 'Failed to create test event' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the super analytics API with proper authentication
|
||||
*/
|
||||
async getSuperAnalytics(metric: string = 'platform_overview'): Promise<any> {
|
||||
try {
|
||||
if (!this.session) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
return { success: false, error: 'Authentication required' };
|
||||
}
|
||||
}
|
||||
|
||||
// Construct full URL to handle localhost issues
|
||||
let baseUrl = window.location.origin;
|
||||
|
||||
// Special handling for localhost to ensure proper resolution
|
||||
if (window.location.hostname === 'localhost' && window.location.port) {
|
||||
baseUrl = `${window.location.protocol}//localhost:${window.location.port}`;
|
||||
}
|
||||
|
||||
const apiUrl = `${baseUrl}/api/admin/super-analytics?metric=${metric}`;
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
signal: controller.signal
|
||||
}).finally(() => clearTimeout(timeoutId));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return { success: false, error: 'Request timeout - the server took too long to respond' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to load analytics data',
|
||||
details: {
|
||||
errorType: error.name,
|
||||
metric: metric,
|
||||
url: window.location.href
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information
|
||||
*/
|
||||
getUserInfo(): { name: string; email: string } | null {
|
||||
if (!this.session) return null;
|
||||
|
||||
return {
|
||||
name: this.session.user.user_metadata?.name || this.session.user.email,
|
||||
email: this.session.user.email
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated as admin
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.isAdmin && this.session !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Supabase client for direct access
|
||||
*/
|
||||
getSupabaseClient() {
|
||||
return supabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
*/
|
||||
getSession() {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
await supabase.auth.signOut();
|
||||
this.session = null;
|
||||
this.isAdmin = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const adminApi = new AdminApiRouter();
|
||||
@@ -65,7 +65,7 @@ export async function generateEventDescription(request: EventDescriptionRequest)
|
||||
|
||||
return parseGeneratedDescription(generatedText);
|
||||
} catch (error) {
|
||||
console.error('Error generating event description:', error);
|
||||
console.error('AI event description generation error:', error);
|
||||
throw new Error('Failed to generate AI event description. Please try again.');
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ etc.`;
|
||||
.filter(title => title.length > 0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating title suggestions:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -264,5 +264,5 @@ export async function trackAIUsage(
|
||||
cost: number
|
||||
) {
|
||||
// This could be logged to analytics or usage tracking system
|
||||
console.log(`AI Usage - Org: ${organizationId}, Feature: ${feature}, Tokens: ${tokens}, Cost: $${cost/100}`);
|
||||
|
||||
}
|
||||
389
src/lib/api-client.ts
Normal file
389
src/lib/api-client.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { supabase } from './supabase';
|
||||
import type { Database } from './database.types';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface EventStats {
|
||||
ticketsSold: number;
|
||||
ticketsAvailable: number;
|
||||
checkedIn: number;
|
||||
totalRevenue: number;
|
||||
netRevenue: number;
|
||||
}
|
||||
|
||||
export interface EventDetails {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
venue: string;
|
||||
start_time: string;
|
||||
slug: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private authenticated = false;
|
||||
private session: any = null;
|
||||
private userOrganizationId: string | null = null;
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
|
||||
this.authenticated = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
|
||||
this.authenticated = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.session = session;
|
||||
this.authenticated = true;
|
||||
|
||||
// Get user's organization
|
||||
const { data: userRecord, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
|
||||
}
|
||||
|
||||
this.userOrganizationId = userRecord?.organization_id || null;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
this.authenticated = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(): Promise<boolean> {
|
||||
if (!this.authenticated) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
|
||||
// Don't automatically redirect here - let the component handle it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getEventDetails(eventId: string): Promise<ApiResponse<EventDetails>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
const { data: event, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('id', eventId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
|
||||
return { data: null, error: error.message, success: false };
|
||||
}
|
||||
|
||||
return { data: event, error: null, success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { data: null, error: 'Failed to load event details', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getEventStats(eventId: string): Promise<ApiResponse<EventStats>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
// Load ticket sales data
|
||||
const { data: tickets, error: ticketsError } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
id,
|
||||
price,
|
||||
checked_in,
|
||||
refund_status,
|
||||
ticket_types (
|
||||
id,
|
||||
quantity_available
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId);
|
||||
|
||||
if (ticketsError) {
|
||||
|
||||
return { data: null, error: ticketsError.message, success: false };
|
||||
}
|
||||
|
||||
// Load ticket types for capacity calculation
|
||||
const { data: ticketTypes, error: ticketTypesError } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('id, quantity_available')
|
||||
.eq('event_id', eventId)
|
||||
.eq('is_active', true);
|
||||
|
||||
if (ticketTypesError) {
|
||||
|
||||
return { data: null, error: ticketTypesError.message, success: false };
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const ticketsSold = tickets?.length || 0;
|
||||
// Only count non-refunded tickets for revenue
|
||||
const activeTickets = tickets?.filter(ticket =>
|
||||
!ticket.refund_status || ticket.refund_status === null
|
||||
) || [];
|
||||
const totalRevenue = activeTickets.reduce((sum, ticket) => sum + ticket.price, 0) || 0;
|
||||
const netRevenue = totalRevenue * 0.97; // Assuming 3% platform fee
|
||||
const checkedIn = tickets?.filter(ticket => ticket.checked_in).length || 0;
|
||||
const totalCapacity = ticketTypes?.reduce((sum, type) => sum + type.quantity_available, 0) || 0;
|
||||
const ticketsAvailable = totalCapacity - ticketsSold;
|
||||
|
||||
const stats: EventStats = {
|
||||
ticketsSold,
|
||||
ticketsAvailable,
|
||||
checkedIn,
|
||||
totalRevenue,
|
||||
netRevenue
|
||||
};
|
||||
|
||||
return { data: stats, error: null, success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { data: null, error: 'Failed to load event statistics', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getTicketTypes(eventId: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.eq('is_active', true)
|
||||
.order('display_order', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
|
||||
return { data: null, error: error.message, success: false };
|
||||
}
|
||||
|
||||
return { data: data || [], error: null, success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { data: null, error: 'Failed to load ticket types', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getOrders(eventId: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
*,
|
||||
ticket_types (
|
||||
name,
|
||||
price
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
return { data: null, error: error.message, success: false };
|
||||
}
|
||||
|
||||
return { data: data || [], error: null, success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { data: null, error: 'Failed to load orders', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getPresaleCodes(eventId: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('presale_codes')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
return { data: null, error: error.message, success: false };
|
||||
}
|
||||
|
||||
return { data: data || [], error: null, success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { data: null, error: 'Failed to load presale codes', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscountCodes(eventId: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('discount_codes')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
return { data: null, error: error.message, success: false };
|
||||
}
|
||||
|
||||
return { data: data || [], error: null, success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { data: null, error: 'Failed to load discount codes', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getAttendees(eventId: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
*,
|
||||
ticket_types (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
.is('refund_status', null)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
|
||||
return { data: null, error: error.message, success: false };
|
||||
}
|
||||
|
||||
return { data: data || [], error: null, success: true };
|
||||
} catch (error) {
|
||||
|
||||
return { data: null, error: 'Failed to load attendees', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getUserOrganizationId(): string | null {
|
||||
return this.userOrganizationId;
|
||||
}
|
||||
|
||||
getUser(): any {
|
||||
return this.session?.user || null;
|
||||
}
|
||||
|
||||
getSession(): any {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return this.authenticated;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await supabase.auth.signOut();
|
||||
this.authenticated = false;
|
||||
this.session = null;
|
||||
this.userOrganizationId = null;
|
||||
}
|
||||
|
||||
// Generic authenticated request helper
|
||||
async makeAuthenticatedRequest<T>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
if (!(await this.ensureAuthenticated())) {
|
||||
return { data: null, error: 'Authentication required', success: false };
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.session?.access_token}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
data: null,
|
||||
error: errorText || `HTTP ${response.status}`,
|
||||
success: false
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data, error: null, success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Request failed',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
// Export a hook-like function for easier use in components
|
||||
export async function useApi() {
|
||||
if (!apiClient.isAuthenticated()) {
|
||||
await apiClient.initialize();
|
||||
}
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
// Export the makeAuthenticatedRequest function for direct use
|
||||
export async function makeAuthenticatedRequest<T>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
return apiClient.makeAuthenticatedRequest<T>(url, options);
|
||||
}
|
||||
290
src/lib/api-router.ts
Normal file
290
src/lib/api-router.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { apiClient } from './api-client';
|
||||
import type { EventStats, EventDetails } from './api-client';
|
||||
import type { EventData } from './event-management';
|
||||
|
||||
/**
|
||||
* Browser-friendly API router that handles all the common API calls
|
||||
* This is designed to be used in Astro client-side scripts and React components
|
||||
*/
|
||||
export class ApiRouter {
|
||||
|
||||
/**
|
||||
* Load event details and statistics for the event management page
|
||||
*/
|
||||
static async loadEventPage(eventId: string): Promise<{
|
||||
event: EventDetails | null;
|
||||
stats: EventStats | null;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
// Initialize API client
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
// Load event details and stats in parallel
|
||||
const [eventResult, statsResult] = await Promise.all([
|
||||
client.getEventDetails(eventId),
|
||||
client.getEventStats(eventId)
|
||||
]);
|
||||
|
||||
return {
|
||||
event: eventResult.data,
|
||||
stats: statsResult.data,
|
||||
error: eventResult.error || statsResult.error
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Event page loading error:', error);
|
||||
return {
|
||||
event: null,
|
||||
stats: null,
|
||||
error: 'Failed to load event data'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only event statistics (for QuickStats component)
|
||||
*/
|
||||
static async loadEventStats(eventId: string): Promise<EventStats | null> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
const result = await client.getEventStats(eventId);
|
||||
if (!result.success) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only event details (for EventHeader component)
|
||||
*/
|
||||
static async loadEventDetails(eventId: string): Promise<EventDetails | null> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
const result = await client.getEventDetails(eventId);
|
||||
if (!result.success) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load ticket types for an event
|
||||
*/
|
||||
static async loadTicketTypes(eventId: string): Promise<any[]> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
const result = await client.getTicketTypes(eventId);
|
||||
if (!result.success) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load orders for an event
|
||||
*/
|
||||
static async loadOrders(eventId: string): Promise<any[]> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
const result = await client.getOrders(eventId);
|
||||
if (!result.success) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to format currency
|
||||
*/
|
||||
static formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to format date
|
||||
*/
|
||||
static formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load complete event data (for EventManagement component)
|
||||
* This consolidates the loadEventData function into the API router
|
||||
*/
|
||||
static async loadEventData(eventId: string): Promise<EventData | null> {
|
||||
try {
|
||||
const { loadEventData } = await import('./event-management');
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
// Get user's organization ID from the API client
|
||||
const userOrganizationId = await client.getUserOrganizationId();
|
||||
if (!userOrganizationId) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await loadEventData(eventId, userOrganizationId);
|
||||
} catch (error) {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
static async checkAuth(): Promise<{
|
||||
authenticated: boolean;
|
||||
user: any;
|
||||
organizationId: string | null;
|
||||
}> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
// The useApi function already initializes if needed, but let's double-check
|
||||
// by calling initialize again to ensure we have the latest session state
|
||||
await client.initialize();
|
||||
|
||||
const isAuthenticated = client.isAuthenticated();
|
||||
const user = client.getUser();
|
||||
const organizationId = client.getUserOrganizationId();
|
||||
|
||||
return {
|
||||
authenticated: isAuthenticated,
|
||||
user,
|
||||
organizationId
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
user: null,
|
||||
organizationId: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load presale codes for an event
|
||||
*/
|
||||
static async loadPresaleCodes(eventId: string): Promise<any[]> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
const result = await client.getPresaleCodes(eventId);
|
||||
if (!result.success) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load discount codes for an event
|
||||
*/
|
||||
static async loadDiscountCodes(eventId: string): Promise<any[]> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
const result = await client.getDiscountCodes(eventId);
|
||||
if (!result.success) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load attendees for an event
|
||||
*/
|
||||
static async loadAttendees(eventId: string): Promise<any[]> {
|
||||
try {
|
||||
const client = await import('./api-client').then(m => m.useApi());
|
||||
|
||||
const result = await client.getAttendees(eventId);
|
||||
if (!result.success) {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (error) {
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Territory Manager integration
|
||||
*/
|
||||
static async isTerritoryManager(): Promise<boolean> {
|
||||
try {
|
||||
const { TerritoryManagerAuth } = await import('./territory-manager-auth');
|
||||
return await TerritoryManagerAuth.isTerritoryManager();
|
||||
} catch (error) {
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async getTerritoryManagerRouter() {
|
||||
try {
|
||||
const { territoryManagerRouter } = await import('./territory-manager-router');
|
||||
return territoryManagerRouter;
|
||||
} catch (error) {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export the router for global use
|
||||
export const api = ApiRouter;
|
||||
@@ -2,6 +2,9 @@ import { supabase } from './supabase';
|
||||
import { logSecurityEvent, logUserActivity } from './logger';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
|
||||
// Get the Supabase URL for cookie name generation
|
||||
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || import.meta.env.SUPABASE_URL;
|
||||
|
||||
export interface AuthContext {
|
||||
user: User;
|
||||
session: Session;
|
||||
@@ -29,7 +32,26 @@ export async function verifyAuth(request: Request): Promise<AuthContext | null>
|
||||
// Try cookies if no auth header
|
||||
if (!accessToken && cookieHeader) {
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
accessToken = cookies['sb-access-token'] || cookies['supabase-auth-token'];
|
||||
|
||||
// Extract Supabase project ref for cookie names
|
||||
const projectRef = supabaseUrl.split('//')[1].split('.')[0];
|
||||
|
||||
// Check for various Supabase cookie patterns
|
||||
accessToken = cookies[`sb-${projectRef}-auth-token`] ||
|
||||
cookies[`sb-${projectRef}-auth-token.0`] ||
|
||||
cookies[`sb-${projectRef}-auth-token.1`] ||
|
||||
cookies['sb-access-token'] ||
|
||||
cookies['supabase-auth-token'] ||
|
||||
cookies['access_token'];
|
||||
|
||||
// Log for debugging (only if no token found)
|
||||
if (!accessToken) {
|
||||
console.log('Auth debug - no token found:', {
|
||||
projectRef,
|
||||
cookieKeys: Object.keys(cookies).filter(k => k.includes('sb') || k.includes('supabase')),
|
||||
tokenSource: 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -84,7 +106,7 @@ export async function verifyAuth(request: Request): Promise<AuthContext | null>
|
||||
organizationId: userRecord?.organization_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Auth verification error:', error);
|
||||
console.error('Error verifying auth:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export class BackupManager {
|
||||
backupData[table] = data || [];
|
||||
totalSize += JSON.stringify(data).length;
|
||||
} catch (error) {
|
||||
console.error(`Error backing up table ${table}:`, error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -235,15 +235,14 @@ export class BackupManager {
|
||||
const tablesToRestore = options.tables || backup.metadata.tables;
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('DRY RUN: Would restore tables:', tablesToRestore);
|
||||
console.log('Backup metadata:', backup.metadata);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore each table
|
||||
for (const table of tablesToRestore) {
|
||||
if (!backup.data[table]) {
|
||||
console.warn(`Table ${table} not found in backup`);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -267,9 +266,8 @@ export class BackupManager {
|
||||
throw new Error(`Failed to restore table ${table}: ${insertError.message}`);
|
||||
}
|
||||
|
||||
console.log(`Restored ${backup.data[table].length} rows to table ${table}`);
|
||||
} catch (error) {
|
||||
console.error(`Error restoring table ${table}:`, error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -327,7 +325,7 @@ export class BackupManager {
|
||||
backups.push(metadata);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get metadata for backup ${file.name}:`, error);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,12 +383,12 @@ export class BackupManager {
|
||||
.remove([fileName]);
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to delete backup ${backupId}:`, error);
|
||||
|
||||
} else {
|
||||
console.log(`Deleted old backup: ${backupId}`);
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting backup ${backupId}:`, error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +446,7 @@ export class BackupManager {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.warn(`Failed to save backup metadata: ${error.message}`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +497,6 @@ export class BackupScheduler {
|
||||
// Monthly backups on the 1st at 4 AM
|
||||
this.scheduleBackup('monthly', '0 4 1 * *', 'monthly');
|
||||
|
||||
console.log('Backup scheduler started');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -508,7 +505,7 @@ export class BackupScheduler {
|
||||
stopScheduledBackups() {
|
||||
for (const [name, interval] of this.intervals) {
|
||||
clearInterval(interval);
|
||||
console.log(`Stopped ${name} backup schedule`);
|
||||
|
||||
}
|
||||
this.intervals.clear();
|
||||
}
|
||||
@@ -522,14 +519,13 @@ export class BackupScheduler {
|
||||
|
||||
const runBackup = async () => {
|
||||
try {
|
||||
console.log(`Starting ${name} backup...`);
|
||||
|
||||
await this.backupManager.createBackup(type);
|
||||
console.log(`${name} backup completed successfully`);
|
||||
|
||||
// Cleanup old backups after successful backup
|
||||
await this.backupManager.cleanupBackups();
|
||||
} catch (error) {
|
||||
console.error(`${name} backup failed:`, error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ class CanvasImageGenerator {
|
||||
|
||||
this.ctx.drawImage(logoImage, logoX, logoY, logoSize, logoSize);
|
||||
} catch (error) {
|
||||
console.warn('Could not load organization logo:', error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
321
src/lib/codereadr-api.ts
Normal file
321
src/lib/codereadr-api.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* CodeREADr API Client
|
||||
* https://secure.codereadr.com/apidocs/README.md
|
||||
*/
|
||||
|
||||
const API_KEY = '3bcb2250e2c9cf4adf4e807f912f907e';
|
||||
const API_BASE_URL = 'https://secure.codereadr.com/api/';
|
||||
|
||||
export interface CodereadrUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface CodereadrDatabase {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
records_count: number;
|
||||
}
|
||||
|
||||
export interface CodereadrService {
|
||||
id: string;
|
||||
name: string;
|
||||
database_id: string;
|
||||
user_id: string;
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CodereadrScan {
|
||||
id: string;
|
||||
service_id: string;
|
||||
user_id: string;
|
||||
value: string;
|
||||
timestamp: string;
|
||||
response: string;
|
||||
device_id: string;
|
||||
location?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CodereadrScanResponse {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
response_id: string;
|
||||
scan_id: string;
|
||||
}
|
||||
|
||||
export interface CodereadrExportTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
format: 'csv' | 'xml' | 'json';
|
||||
}
|
||||
|
||||
class CodereadrApi {
|
||||
private apiKey: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(apiKey: string = API_KEY) {
|
||||
this.apiKey = apiKey;
|
||||
this.baseUrl = API_BASE_URL;
|
||||
}
|
||||
|
||||
private async makeRequest(endpoint: string, method: 'GET' | 'POST' = 'GET', data?: any): Promise<any> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add API key to all requests
|
||||
params.append('api_key', this.apiKey);
|
||||
|
||||
// Add data for POST requests
|
||||
if (data) {
|
||||
Object.keys(data).forEach(key => {
|
||||
if (data[key] !== undefined && data[key] !== null) {
|
||||
params.append(key, data[key].toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'BCT-WhiteLabel/1.0'
|
||||
}
|
||||
};
|
||||
|
||||
if (method === 'POST') {
|
||||
options.body = params;
|
||||
} else {
|
||||
// GET request - append params to URL
|
||||
const finalUrl = `${url}?${params.toString()}`;
|
||||
const response = await fetch(finalUrl, options);
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
private async handleResponse(response: Response): Promise<any> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
// Some endpoints return CSV or plain text
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// Users API
|
||||
async createUser(username: string, email: string, password: string): Promise<CodereadrUser> {
|
||||
const response = await this.makeRequest('users/create', 'POST', {
|
||||
username,
|
||||
email,
|
||||
password
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getUsers(): Promise<CodereadrUser[]> {
|
||||
const response = await this.makeRequest('users');
|
||||
return response;
|
||||
}
|
||||
|
||||
async getUserById(userId: string): Promise<CodereadrUser> {
|
||||
const response = await this.makeRequest(`users/${userId}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateUser(userId: string, data: Partial<CodereadrUser>): Promise<CodereadrUser> {
|
||||
const response = await this.makeRequest(`users/${userId}/update`, 'POST', data);
|
||||
return response;
|
||||
}
|
||||
|
||||
async deleteUser(userId: string): Promise<boolean> {
|
||||
const response = await this.makeRequest(`users/${userId}/delete`, 'POST');
|
||||
return response.success;
|
||||
}
|
||||
|
||||
// Databases API
|
||||
async createDatabase(name: string, description?: string): Promise<CodereadrDatabase> {
|
||||
const response = await this.makeRequest('databases/create', 'POST', {
|
||||
name,
|
||||
description
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getDatabases(): Promise<CodereadrDatabase[]> {
|
||||
const response = await this.makeRequest('databases');
|
||||
return response;
|
||||
}
|
||||
|
||||
async getDatabaseById(databaseId: string): Promise<CodereadrDatabase> {
|
||||
const response = await this.makeRequest(`databases/${databaseId}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateDatabase(databaseId: string, data: Partial<CodereadrDatabase>): Promise<CodereadrDatabase> {
|
||||
const response = await this.makeRequest(`databases/${databaseId}/update`, 'POST', data);
|
||||
return response;
|
||||
}
|
||||
|
||||
async deleteDatabase(databaseId: string): Promise<boolean> {
|
||||
const response = await this.makeRequest(`databases/${databaseId}/delete`, 'POST');
|
||||
return response.success;
|
||||
}
|
||||
|
||||
// Database Records API
|
||||
async addDatabaseRecord(databaseId: string, value: string, response: string): Promise<any> {
|
||||
const result = await this.makeRequest('database/add', 'POST', {
|
||||
database_id: databaseId,
|
||||
value,
|
||||
response
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateDatabaseRecord(databaseId: string, value: string, response: string): Promise<any> {
|
||||
const result = await this.makeRequest('database/update', 'POST', {
|
||||
database_id: databaseId,
|
||||
value,
|
||||
response
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteDatabaseRecord(databaseId: string, value: string): Promise<boolean> {
|
||||
const response = await this.makeRequest('database/delete', 'POST', {
|
||||
database_id: databaseId,
|
||||
value
|
||||
});
|
||||
return response.success;
|
||||
}
|
||||
|
||||
// Services API
|
||||
async createService(name: string, databaseId: string, userId: string): Promise<CodereadrService> {
|
||||
const response = await this.makeRequest('services/create', 'POST', {
|
||||
name,
|
||||
database_id: databaseId,
|
||||
user_id: userId
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getServices(): Promise<CodereadrService[]> {
|
||||
const response = await this.makeRequest('services');
|
||||
return response;
|
||||
}
|
||||
|
||||
async getServiceById(serviceId: string): Promise<CodereadrService> {
|
||||
const response = await this.makeRequest(`services/${serviceId}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateService(serviceId: string, data: Partial<CodereadrService>): Promise<CodereadrService> {
|
||||
const response = await this.makeRequest(`services/${serviceId}/update`, 'POST', data);
|
||||
return response;
|
||||
}
|
||||
|
||||
async deleteService(serviceId: string): Promise<boolean> {
|
||||
const response = await this.makeRequest(`services/${serviceId}/delete`, 'POST');
|
||||
return response.success;
|
||||
}
|
||||
|
||||
// Scans API
|
||||
async getScans(serviceId?: string, userId?: string, limit?: number, offset?: number): Promise<CodereadrScan[]> {
|
||||
const params: any = {};
|
||||
|
||||
if (serviceId) params.service_id = serviceId;
|
||||
if (userId) params.user_id = userId;
|
||||
if (limit) params.limit = limit;
|
||||
if (offset) params.offset = offset;
|
||||
|
||||
const response = await this.makeRequest('scans', 'GET', params);
|
||||
return response;
|
||||
}
|
||||
|
||||
async getScanById(scanId: string): Promise<CodereadrScan> {
|
||||
const response = await this.makeRequest(`scans/${scanId}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Export scans to CSV/XML/JSON
|
||||
async exportScans(templateId: string, format: 'csv' | 'xml' | 'json' = 'csv'): Promise<string> {
|
||||
const response = await this.makeRequest('scans/export', 'GET', {
|
||||
template: templateId,
|
||||
return_type: format
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
// Validate a barcode against a database
|
||||
async validateBarcode(databaseId: string, value: string): Promise<any> {
|
||||
const response = await this.makeRequest('database/lookup', 'POST', {
|
||||
database_id: databaseId,
|
||||
value
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
// Simulate a scan (for testing/integration)
|
||||
async simulateScan(serviceId: string, userId: string, value: string): Promise<CodereadrScanResponse> {
|
||||
const response = await this.makeRequest('scans/simulate', 'POST', {
|
||||
service_id: serviceId,
|
||||
user_id: userId,
|
||||
value
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
// Kiosk mode configuration
|
||||
async setKioskMode(serviceId: string, config: any): Promise<any> {
|
||||
const response = await this.makeRequest('services/kiosk', 'POST', {
|
||||
service_id: serviceId,
|
||||
kiosk_mode_config: JSON.stringify(config)
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
// Get device information
|
||||
async getDevices(): Promise<any[]> {
|
||||
const response = await this.makeRequest('devices');
|
||||
return response;
|
||||
}
|
||||
|
||||
// Questions API (for collecting additional data after scans)
|
||||
async createQuestion(text: string, type: 'text' | 'number' | 'select' | 'checkbox' | 'date'): Promise<any> {
|
||||
const response = await this.makeRequest('questions/create', 'POST', {
|
||||
text,
|
||||
type
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getQuestions(): Promise<any[]> {
|
||||
const response = await this.makeRequest('questions');
|
||||
return response;
|
||||
}
|
||||
|
||||
async assignQuestionToService(serviceId: string, questionId: string): Promise<any> {
|
||||
const response = await this.makeRequest('services/questions/assign', 'POST', {
|
||||
service_id: serviceId,
|
||||
question_id: questionId
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export default CodereadrApi;
|
||||
export { CodereadrApi };
|
||||
351
src/lib/email.ts
351
src/lib/email.ts
@@ -15,7 +15,7 @@ export const EMAIL_CONFIG = {
|
||||
|
||||
// Validate email configuration
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn('RESEND_API_KEY environment variable is not set. Email functionality will be disabled.');
|
||||
console.warn('RESEND_API_KEY not configured - email functionality will be limited');
|
||||
}
|
||||
|
||||
export interface TicketEmailData {
|
||||
@@ -60,6 +60,35 @@ export interface OrderConfirmationData {
|
||||
refundPolicy?: string;
|
||||
}
|
||||
|
||||
export interface ApplicationReceivedData {
|
||||
organizationName: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
expectedApprovalTime: string;
|
||||
referenceNumber: string;
|
||||
nextSteps: string[];
|
||||
}
|
||||
|
||||
export interface AdminNotificationData {
|
||||
organizationName: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
businessType: string;
|
||||
approvalScore: number;
|
||||
requiresReview: boolean;
|
||||
adminDashboardUrl: string;
|
||||
}
|
||||
|
||||
export interface ApprovalNotificationData {
|
||||
organizationName: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
approvedBy: string;
|
||||
stripeOnboardingUrl: string;
|
||||
nextSteps: string[];
|
||||
welcomeMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code data URL for email
|
||||
*/
|
||||
@@ -79,7 +108,7 @@ async function generateQRCodeDataURL(ticketUuid: string): Promise<string> {
|
||||
});
|
||||
return qrCodeDataURL;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -412,7 +441,7 @@ function createOrderConfirmationHTML(data: OrderConfirmationData): string {
|
||||
*/
|
||||
export async function sendTicketConfirmationEmail(ticketData: TicketEmailData): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn('Email service not configured. Skipping ticket confirmation email.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,9 +480,8 @@ export async function sendTicketConfirmationEmail(ticketData: TicketEmailData):
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Ticket confirmation email sent successfully:', data?.id);
|
||||
} catch (error) {
|
||||
console.error('Error sending ticket confirmation email:', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -463,7 +491,7 @@ export async function sendTicketConfirmationEmail(ticketData: TicketEmailData):
|
||||
*/
|
||||
export async function sendOrderConfirmationEmail(orderData: OrderConfirmationData): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn('Email service not configured. Skipping order confirmation email.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -492,9 +520,8 @@ export async function sendOrderConfirmationEmail(orderData: OrderConfirmationDat
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Order confirmation email sent successfully:', data?.id);
|
||||
} catch (error) {
|
||||
console.error('Error sending order confirmation email:', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -536,10 +563,10 @@ export async function sendOrganizerNotificationEmail(data: {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error sending organizer notification:', error);
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending organizer notification email:', error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +589,309 @@ export async function testEmailConfiguration(): Promise<boolean> {
|
||||
// We expect this to fail with invalid email, but connection should work
|
||||
return error?.message?.includes('Invalid') || false;
|
||||
} catch (error) {
|
||||
console.error('Email configuration test failed:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create application received email HTML
|
||||
*/
|
||||
function createApplicationReceivedHTML(data: ApplicationReceivedData): string {
|
||||
const nextStepsList = data.nextSteps.map(step => `<li>${step}</li>`).join('');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Application Received</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f8fafc; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||
.header { background: linear-gradient(135deg, #1f2937 0%, #374151 100%); color: white; padding: 32px; text-align: center; }
|
||||
.content { padding: 32px; }
|
||||
.status-badge { background: #fef3c7; color: #92400e; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 600; display: inline-block; }
|
||||
.info-box { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||
.next-steps { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||
.footer { background: #f8fafc; padding: 24px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 28px;">Application Received</h1>
|
||||
<p style="margin: 16px 0 0; font-size: 16px; opacity: 0.9;">Your Black Canyon Tickets application is being reviewed</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="status-badge">⏳ Pending Review</div>
|
||||
|
||||
<p style="font-size: 18px; margin: 24px 0 16px;">Hi ${data.userName},</p>
|
||||
|
||||
<p>Thank you for your interest in Black Canyon Tickets! We've received your application for <strong>${data.organizationName}</strong> and our team is reviewing it.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin: 0 0 12px; color: #1f2937;">📋 Application Details</h3>
|
||||
<ul style="margin: 0; padding-left: 20px;">
|
||||
<li><strong>Organization:</strong> ${data.organizationName}</li>
|
||||
<li><strong>Reference Number:</strong> ${data.referenceNumber}</li>
|
||||
<li><strong>Expected Review Time:</strong> ${data.expectedApprovalTime}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h3 style="margin: 0 0 12px; color: #15803d;">🚀 What happens next?</h3>
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${nextStepsList}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p>We'll notify you as soon as your application is reviewed. In the meantime, you can explore our platform features and documentation.</p>
|
||||
|
||||
<p style="margin: 24px 0;">
|
||||
Questions? Contact our support team at <a href="mailto:${EMAIL_CONFIG.SUPPORT_EMAIL}" style="color: #3b82f6;">${EMAIL_CONFIG.SUPPORT_EMAIL}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p style="margin: 0;">
|
||||
This email was sent by Black Canyon Tickets<br>
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/privacy" style="color: #6b7280;">Privacy Policy</a> |
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/terms" style="color: #6b7280;">Terms of Service</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create admin notification email HTML
|
||||
*/
|
||||
function createAdminNotificationHTML(data: AdminNotificationData): string {
|
||||
const scoreColor = data.approvalScore >= 80 ? '#10b981' : data.approvalScore >= 60 ? '#f59e0b' : '#ef4444';
|
||||
const reviewStatus = data.requiresReview ? 'Manual Review Required' : 'Auto-Approval Eligible';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Application - Admin Review</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f8fafc; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||
.header { background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%); color: white; padding: 32px; text-align: center; }
|
||||
.content { padding: 32px; }
|
||||
.review-box { background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||
.score-badge { padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 600; display: inline-block; margin-bottom: 12px; }
|
||||
.action-button { background: #dc2626; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; display: inline-block; margin: 16px 0; }
|
||||
.footer { background: #f8fafc; padding: 24px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 28px;">🔔 New Application</h1>
|
||||
<p style="margin: 16px 0 0; font-size: 16px; opacity: 0.9;">Admin Review Required</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="score-badge" style="background: ${scoreColor}; color: white;">
|
||||
Score: ${data.approvalScore}/100
|
||||
</div>
|
||||
|
||||
<h2 style="margin: 0 0 16px; color: #1f2937;">${data.organizationName}</h2>
|
||||
|
||||
<div class="review-box">
|
||||
<h3 style="margin: 0 0 12px; color: #dc2626;">📊 Application Summary</h3>
|
||||
<ul style="margin: 0; padding-left: 20px;">
|
||||
<li><strong>Applicant:</strong> ${data.userName} (${data.userEmail})</li>
|
||||
<li><strong>Business Type:</strong> ${data.businessType}</li>
|
||||
<li><strong>Approval Score:</strong> ${data.approvalScore}/100</li>
|
||||
<li><strong>Review Status:</strong> ${reviewStatus}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>${data.requiresReview ? 'This application requires manual review based on our approval criteria.' : 'This application meets auto-approval criteria but may need final verification.'}</p>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${data.adminDashboardUrl}" class="action-button">
|
||||
Review Application →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #6b7280;">
|
||||
You can approve, reject, or request more information from the admin dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p style="margin: 0;">
|
||||
Black Canyon Tickets Admin System<br>
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/admin" style="color: #6b7280;">Admin Dashboard</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create approval notification email HTML
|
||||
*/
|
||||
function createApprovalNotificationHTML(data: ApprovalNotificationData): string {
|
||||
const nextStepsList = data.nextSteps.map(step => `<li>${step}</li>`).join('');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Account Approved - Welcome!</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f8fafc; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||
.header { background: linear-gradient(135deg, #10b981 0%, #14b8a6 100%); color: white; padding: 32px; text-align: center; }
|
||||
.content { padding: 32px; }
|
||||
.approval-badge { background: #d1fae5; color: #065f46; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 600; display: inline-block; }
|
||||
.welcome-box { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||
.next-steps { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||
.action-button { background: #1f2937; color: white; padding: 16px 32px; border-radius: 8px; text-decoration: none; font-weight: 600; display: inline-block; margin: 16px 0; }
|
||||
.footer { background: #f8fafc; padding: 24px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 28px;">🎉 Account Approved!</h1>
|
||||
<p style="margin: 16px 0 0; font-size: 16px; opacity: 0.9;">Welcome to Black Canyon Tickets</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="approval-badge">✅ Approved by ${data.approvedBy}</div>
|
||||
|
||||
<p style="font-size: 18px; margin: 24px 0 16px;">Congratulations ${data.userName}!</p>
|
||||
|
||||
<div class="welcome-box">
|
||||
<h3 style="margin: 0 0 12px; color: #065f46;">🏢 ${data.organizationName}</h3>
|
||||
<p style="margin: 0;">Your account has been approved and you're ready to complete the secure payment setup process.</p>
|
||||
${data.welcomeMessage ? `<p style="margin: 16px 0 0; font-style: italic;">${data.welcomeMessage}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h3 style="margin: 0 0 12px; color: #1e40af;">🔒 Complete Your Setup</h3>
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${nextStepsList}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${data.stripeOnboardingUrl}" class="action-button">
|
||||
Complete Secure Setup →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 16px; margin: 20px 0;">
|
||||
<strong>🔒 Security Note:</strong> This setup process uses bank-level encryption through Stripe Connect. Your sensitive information is never stored on our servers.
|
||||
</p>
|
||||
|
||||
<p>Questions about the setup process? Contact our support team at <a href="mailto:${EMAIL_CONFIG.SUPPORT_EMAIL}" style="color: #3b82f6;">${EMAIL_CONFIG.SUPPORT_EMAIL}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p style="margin: 0;">
|
||||
This email was sent by Black Canyon Tickets<br>
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/privacy" style="color: #6b7280;">Privacy Policy</a> |
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/terms" style="color: #6b7280;">Terms of Service</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send application received email
|
||||
*/
|
||||
export async function sendApplicationReceivedEmail(data: ApplicationReceivedData): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: emailData, error } = await resend.emails.send({
|
||||
from: EMAIL_CONFIG.FROM_EMAIL,
|
||||
to: [data.userEmail],
|
||||
subject: `Application received for ${data.organizationName} - BCT#${data.referenceNumber}`,
|
||||
html: createApplicationReceivedHTML(data)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send admin notification email
|
||||
*/
|
||||
export async function sendAdminNotificationEmail(data: AdminNotificationData): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: emailData, error } = await resend.emails.send({
|
||||
from: EMAIL_CONFIG.FROM_EMAIL,
|
||||
to: [EMAIL_CONFIG.SUPPORT_EMAIL], // Send to support team
|
||||
subject: `New application requires review: ${data.organizationName}`,
|
||||
html: createAdminNotificationHTML(data)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send approval notification email
|
||||
*/
|
||||
export async function sendApprovalNotificationEmail(data: ApprovalNotificationData): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: emailData, error } = await resend.emails.send({
|
||||
from: EMAIL_CONFIG.FROM_EMAIL,
|
||||
to: [data.userEmail],
|
||||
subject: `🎉 ${data.organizationName} approved - Complete your setup`,
|
||||
html: createApprovalNotificationHTML(data)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,17 @@ export interface EventData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
venue: string;
|
||||
slug: string;
|
||||
organization_id: string;
|
||||
venue_data?: any;
|
||||
seating_map_id?: string;
|
||||
seating_map_id?: string | null;
|
||||
seating_type?: string;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
end_time?: string;
|
||||
is_published?: boolean;
|
||||
is_public?: boolean;
|
||||
seating_map?: any;
|
||||
}
|
||||
|
||||
@@ -29,18 +34,12 @@ export interface EventStats {
|
||||
|
||||
export async function loadEventData(eventId: string, organizationId: string): Promise<EventData | null> {
|
||||
try {
|
||||
|
||||
// First try to load the event by ID only
|
||||
const { data: event, error } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
venue,
|
||||
slug,
|
||||
organization_id,
|
||||
venue_data,
|
||||
seating_map_id,
|
||||
*,
|
||||
seating_maps (
|
||||
id,
|
||||
name,
|
||||
@@ -48,11 +47,36 @@ export async function loadEventData(eventId: string, organizationId: string): Pr
|
||||
)
|
||||
`)
|
||||
.eq('id', eventId)
|
||||
.eq('organization_id', organizationId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading event:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user has access to this event
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const { data: userProfile } = await supabase
|
||||
.from('users')
|
||||
.select('role, organization_id')
|
||||
.eq('id', user?.id || '')
|
||||
.single();
|
||||
|
||||
const isAdmin = userProfile?.role === 'admin';
|
||||
|
||||
// If not admin and event doesn't belong to user's organization, deny access
|
||||
if (!isAdmin && event.organization_id !== userProfile?.organization_id) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,7 +85,7 @@ export async function loadEventData(eventId: string, organizationId: string): Pr
|
||||
seating_map: event.seating_maps
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading event data:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -73,41 +97,40 @@ export async function loadEventStats(eventId: string): Promise<EventStats> {
|
||||
.from('tickets')
|
||||
.select(`
|
||||
id,
|
||||
price_paid,
|
||||
price,
|
||||
checked_in,
|
||||
ticket_types (
|
||||
id,
|
||||
name,
|
||||
price_cents,
|
||||
quantity
|
||||
price,
|
||||
quantity_available
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
.eq('status', 'confirmed');
|
||||
.eq('event_id', eventId);
|
||||
|
||||
if (ticketsError) {
|
||||
console.error('Error loading tickets:', ticketsError);
|
||||
|
||||
return getDefaultStats();
|
||||
}
|
||||
|
||||
// Get ticket types for availability calculation
|
||||
const { data: ticketTypes, error: typesError } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('id, quantity')
|
||||
.select('id, quantity_available')
|
||||
.eq('event_id', eventId)
|
||||
.eq('is_active', true);
|
||||
|
||||
if (typesError) {
|
||||
console.error('Error loading ticket types:', typesError);
|
||||
|
||||
return getDefaultStats();
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const ticketsSold = tickets?.length || 0;
|
||||
const totalRevenue = tickets?.reduce((sum, ticket) => sum + ticket.price_paid, 0) || 0;
|
||||
const totalRevenue = tickets?.reduce((sum, ticket) => sum + (ticket.price || 0), 0) || 0;
|
||||
const netRevenue = totalRevenue * 0.97; // Assuming 3% platform fee
|
||||
const checkedIn = tickets?.filter(ticket => ticket.checked_in).length || 0;
|
||||
const totalCapacity = ticketTypes?.reduce((sum, type) => sum + type.quantity, 0) || 0;
|
||||
const totalCapacity = ticketTypes?.reduce((sum, type) => sum + (type.quantity_available || 0), 0) || 0;
|
||||
const ticketsAvailable = totalCapacity - ticketsSold;
|
||||
|
||||
return {
|
||||
@@ -118,7 +141,7 @@ export async function loadEventStats(eventId: string): Promise<EventStats> {
|
||||
checkedIn
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading event stats:', error);
|
||||
|
||||
return getDefaultStats();
|
||||
}
|
||||
}
|
||||
@@ -131,13 +154,13 @@ export async function updateEventData(eventId: string, updates: Partial<EventDat
|
||||
.eq('id', eventId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating event:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating event data:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ async function addScrapedEventToDatabase(eventDetails: ScrapedEventDetails): Pro
|
||||
.single();
|
||||
|
||||
if (existingEvent) {
|
||||
console.log(`Event ${eventId} already exists, skipping`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -275,7 +275,6 @@ async function addScrapedEventToDatabase(eventDetails: ScrapedEventDetails): Pro
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully added featured event: ${eventDetails.title}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
@@ -289,7 +288,6 @@ async function addScrapedEventToDatabase(eventDetails: ScrapedEventDetails): Pro
|
||||
*/
|
||||
export async function runEventScraper(): Promise<{ success: boolean; message: string; newEvent?: ScrapedEventDetails }> {
|
||||
try {
|
||||
console.log('🔍 Starting event scraper...');
|
||||
|
||||
// Get current event slug
|
||||
const currentSlug = await getCurrentEventSlug();
|
||||
@@ -300,8 +298,6 @@ export async function runEventScraper(): Promise<{ success: boolean; message: st
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found current event slug: ${currentSlug}`);
|
||||
|
||||
// Check if this is a new event
|
||||
const lastSeenSlug = await loadLastSeenSlug();
|
||||
if (currentSlug === lastSeenSlug) {
|
||||
@@ -320,8 +316,6 @@ export async function runEventScraper(): Promise<{ success: boolean; message: st
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`📅 New event found: ${eventDetails.title}`);
|
||||
|
||||
// Add to database as featured event
|
||||
const added = await addScrapedEventToDatabase(eventDetails);
|
||||
if (!added) {
|
||||
@@ -418,7 +412,6 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Initialized scraper organization and user');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -297,7 +297,7 @@ async function saveLastSyncTime(timestamp: string): Promise<void> {
|
||||
*/
|
||||
async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
||||
if (!supabase) {
|
||||
console.log(`❌ No Supabase client for checking event ${firebaseId}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -312,18 +312,18 @@ async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.log(`🔍 Event firebase-${firebaseId} not found in database: ${error.message}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
console.log(`✅ Event ${firebaseId} already exists: ${data.title}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log(`❌ Error checking event ${firebaseId}:`, error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -333,7 +333,7 @@ async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
||||
*/
|
||||
async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boolean> {
|
||||
if (!supabase) {
|
||||
console.log('❌ Supabase client not available for adding Firebase event');
|
||||
|
||||
logError('Supabase client not available for adding Firebase event');
|
||||
return false;
|
||||
}
|
||||
@@ -341,7 +341,6 @@ async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boole
|
||||
try {
|
||||
// Generate a proper UUID for the event ID (can't use string concatenation)
|
||||
const eventId = crypto.randomUUID();
|
||||
console.log(`💾 Attempting to insert event with ID: ${eventId} (Firebase ID: ${processedEvent.firebaseId})`);
|
||||
|
||||
// Insert the new event as featured and public
|
||||
const { error } = await supabase
|
||||
@@ -366,16 +365,15 @@ async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boole
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.log(`❌ Database insert failed for ${processedEvent.title}:`, error);
|
||||
|
||||
logError('Failed to insert Firebase event into database', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ Added featured event: ${processedEvent.title} (${processedEvent.priceRange})`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.log(`💥 Exception adding event ${processedEvent.title}:`, error);
|
||||
|
||||
logError('Error adding Firebase event to database', error);
|
||||
return false;
|
||||
}
|
||||
@@ -386,7 +384,6 @@ async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boole
|
||||
*/
|
||||
export async function runFirebaseEventScraper(): Promise<{ success: boolean; message: string; newEvents?: ProcessedEvent[] }> {
|
||||
try {
|
||||
console.log('🔍 Starting Firebase event scraper...');
|
||||
|
||||
// Authenticate with Firebase
|
||||
const idToken = await authenticateFirebase();
|
||||
@@ -397,8 +394,6 @@ export async function runFirebaseEventScraper(): Promise<{ success: boolean; mes
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ Authenticated with Firebase');
|
||||
|
||||
// Ensure scraper organization exists
|
||||
try {
|
||||
const orgInitialized = await initializeScraperOrganization();
|
||||
@@ -416,11 +411,9 @@ export async function runFirebaseEventScraper(): Promise<{ success: boolean; mes
|
||||
debug: { step: 'organization_init_exception', error: orgError },
|
||||
};
|
||||
}
|
||||
console.log('✅ Black Canyon Tickets organization ready');
|
||||
|
||||
// Fetch events from Firebase
|
||||
const firebaseEvents = await fetchFirebaseEvents(idToken);
|
||||
console.log(`📅 Found ${firebaseEvents.length} events in Firebase`);
|
||||
|
||||
if (firebaseEvents.length === 0) {
|
||||
return {
|
||||
@@ -432,25 +425,23 @@ export async function runFirebaseEventScraper(): Promise<{ success: boolean; mes
|
||||
// Process and filter new events
|
||||
const newEvents: ProcessedEvent[] = [];
|
||||
|
||||
console.log('🔍 Processing Firebase events...');
|
||||
for (const firebaseEvent of firebaseEvents) {
|
||||
console.log(`📅 Processing: ${firebaseEvent.name} (ID: ${firebaseEvent.id})`);
|
||||
|
||||
const exists = await eventExistsInDatabase(firebaseEvent.id);
|
||||
|
||||
if (!exists) {
|
||||
console.log(`🆕 Adding new event: ${firebaseEvent.name}`);
|
||||
|
||||
const processedEvent = processFirebaseEvent(firebaseEvent);
|
||||
const added = await addEventToDatabase(processedEvent);
|
||||
|
||||
if (added) {
|
||||
newEvents.push(processedEvent);
|
||||
console.log(`✅ Successfully added: ${processedEvent.title}`);
|
||||
|
||||
} else {
|
||||
console.log(`❌ Failed to add: ${firebaseEvent.name}`);
|
||||
|
||||
}
|
||||
} else {
|
||||
console.log(`⏭️ Event already exists: ${firebaseEvent.name}`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +505,7 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
||||
|
||||
try {
|
||||
// Check if scraper organization exists
|
||||
console.log(`🔍 Checking for organization: ${SCRAPER_ORGANIZATION_ID}`);
|
||||
|
||||
const { data: existingOrg, error: checkError } = await supabase
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
@@ -522,12 +513,10 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
||||
.single();
|
||||
|
||||
if (existingOrg) {
|
||||
console.log('✅ Organization already exists');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('🆕 Creating new organization:', checkError?.message);
|
||||
|
||||
// Create scraper organization
|
||||
const { error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
@@ -539,7 +528,7 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
||||
});
|
||||
|
||||
if (orgError) {
|
||||
console.log('❌ Failed to create organization:', orgError);
|
||||
|
||||
logError('Failed to create scraper organization', orgError);
|
||||
return false;
|
||||
}
|
||||
@@ -555,12 +544,11 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
||||
});
|
||||
|
||||
if (userError) {
|
||||
console.log('❌ Failed to create user:', userError);
|
||||
|
||||
logError('Failed to create scraper user', userError);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Initialized Firebase scraper organization and user');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,7 +44,7 @@ export class GeolocationService {
|
||||
}
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
console.warn('Geolocation is not supported by this browser');
|
||||
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export class GeolocationService {
|
||||
resolve(location);
|
||||
},
|
||||
(error) => {
|
||||
console.warn('Error getting location:', error.message);
|
||||
|
||||
resolve(null);
|
||||
},
|
||||
options
|
||||
@@ -98,7 +98,7 @@ export class GeolocationService {
|
||||
return location;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error getting IP location:', error);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export class GeolocationService {
|
||||
return location;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error geocoding address:', error);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -155,10 +155,10 @@ export class GeolocationService {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving location preference:', error);
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving location preference:', error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export class GeolocationService {
|
||||
locationSource: data.location_source
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting location preference:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export class GeolocationService {
|
||||
return location;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('GPS location failed, trying IP geolocation:', error);
|
||||
|
||||
}
|
||||
|
||||
return await this.getLocationFromIP();
|
||||
|
||||
745
src/lib/marketing-kit-enhanced.ts
Normal file
745
src/lib/marketing-kit-enhanced.ts
Normal file
@@ -0,0 +1,745 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './database.types';
|
||||
|
||||
const supabase = createClient<Database>(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
// OpenAI configuration
|
||||
const OPENAI_API_KEY = import.meta.env.OPENAI_API_KEY;
|
||||
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
export interface SocialMediaContent {
|
||||
id?: string;
|
||||
platform: 'facebook' | 'twitter' | 'instagram' | 'linkedin';
|
||||
content: string;
|
||||
hashtags: string[];
|
||||
image_url?: string;
|
||||
tone?: 'professional' | 'casual' | 'exciting' | 'informative' | 'urgent';
|
||||
votes?: number;
|
||||
generated_at?: string;
|
||||
}
|
||||
|
||||
export async function generateSocialMediaContentWithAI(event: any): Promise<SocialMediaContent[]> {
|
||||
if (!OPENAI_API_KEY) {
|
||||
|
||||
return generateFallbackContent(event);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const prompt = `Generate 5 varied social media posts for an event with the following details:
|
||||
Event: ${event.title}
|
||||
Date: ${eventDate}
|
||||
Venue: ${event.venue}
|
||||
Description: ${event.description || 'A special event'}
|
||||
|
||||
Please create 5 different posts with these variations:
|
||||
1. Professional/formal tone for LinkedIn
|
||||
2. Casual/friendly tone for Facebook
|
||||
3. Exciting/energetic tone for Instagram
|
||||
4. Informative/educational tone
|
||||
5. Urgent/FOMO (fear of missing out) tone
|
||||
|
||||
For each post:
|
||||
- Keep it concise and engaging
|
||||
- Include relevant emojis
|
||||
- Suggest 3-5 relevant hashtags
|
||||
- Vary the messaging approach
|
||||
- Make each post unique and platform-appropriate
|
||||
|
||||
Format as JSON with an array called "posts", each containing:
|
||||
{
|
||||
"platform": "facebook|twitter|instagram|linkedin",
|
||||
"content": "post text",
|
||||
"hashtags": ["tag1", "tag2"],
|
||||
"tone": "professional|casual|exciting|informative|urgent"
|
||||
}`;
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a social media marketing expert specializing in event promotion. Generate engaging, varied content that drives ticket sales. Always respond with valid JSON.'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.9,
|
||||
max_tokens: 2000
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
// Parse the JSON response
|
||||
let parsedContent;
|
||||
try {
|
||||
parsedContent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
|
||||
return generateFallbackContent(event);
|
||||
}
|
||||
|
||||
// Extract posts array
|
||||
const posts = parsedContent.posts || parsedContent;
|
||||
|
||||
// Ensure it's an array
|
||||
const postsArray = Array.isArray(posts) ? posts : [posts];
|
||||
|
||||
return postsArray.map((post: any, index: number) => ({
|
||||
id: `ai-${Date.now()}-${index}`,
|
||||
platform: post.platform || 'facebook',
|
||||
content: post.content || '',
|
||||
hashtags: Array.isArray(post.hashtags) ? post.hashtags : [],
|
||||
tone: post.tone || 'professional',
|
||||
votes: 0,
|
||||
generated_at: new Date().toISOString()
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return generateFallbackContent(event);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFallbackContent(event: any): SocialMediaContent[] {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const baseHashtags = ['#event', '#tickets', '#liveentertainment'];
|
||||
|
||||
return [
|
||||
{
|
||||
id: `fallback-${Date.now()}-1`,
|
||||
platform: 'linkedin',
|
||||
content: `We're pleased to announce ${event.title}, taking place on ${eventDate} at ${event.venue}. This exclusive event offers an exceptional opportunity for networking and entertainment. Secure your tickets today.`,
|
||||
hashtags: [...baseHashtags, '#networking', '#professional'],
|
||||
tone: 'professional',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-${Date.now()}-2`,
|
||||
platform: 'facebook',
|
||||
content: `🎉 Hey everyone! Don't miss out on ${event.title}! Join us on ${eventDate} at ${event.venue} for an unforgettable experience. Grab your tickets now - link in bio! 🎫`,
|
||||
hashtags: [...baseHashtags, '#community', '#fun'],
|
||||
tone: 'casual',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-${Date.now()}-3`,
|
||||
platform: 'instagram',
|
||||
content: `🔥 IT'S HAPPENING! ${event.title} is coming to ${event.venue}! 🎉✨ Mark your calendars for ${eventDate} - this is THE event you don't want to miss! Limited tickets available! 🎫🏃♀️`,
|
||||
hashtags: [...baseHashtags, '#instagood', '#eventlife', '#mustattend'],
|
||||
tone: 'exciting',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-${Date.now()}-4`,
|
||||
platform: 'twitter',
|
||||
content: `📅 Learn more: ${event.title} features world-class entertainment on ${eventDate} at ${event.venue}. An evening of culture and sophistication awaits. Details: [link]`,
|
||||
hashtags: [...baseHashtags, '#culture', '#arts'],
|
||||
tone: 'informative',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-${Date.now()}-5`,
|
||||
platform: 'facebook',
|
||||
content: `⏰ LAST CHANCE! Only a few tickets left for ${event.title}! Don't be the one who misses out on the event of the season. ${eventDate} at ${event.venue} - GET YOUR TICKETS NOW! 🎟️💨`,
|
||||
hashtags: [...baseHashtags, '#lastchance', '#limitedtickets', '#FOMO'],
|
||||
tone: 'urgent',
|
||||
votes: 0
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id?: string;
|
||||
subject: string;
|
||||
html_content: string;
|
||||
text_content: string;
|
||||
preview_text: string;
|
||||
tone?: string;
|
||||
votes?: number;
|
||||
generated_at?: string;
|
||||
}
|
||||
|
||||
export interface FlyerData {
|
||||
id?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
style: string;
|
||||
theme: string;
|
||||
votes?: number;
|
||||
generated_at?: string;
|
||||
}
|
||||
|
||||
// Generate AI-powered email templates
|
||||
export async function generateEmailTemplatesWithAI(event: any): Promise<EmailTemplate[]> {
|
||||
if (!OPENAI_API_KEY) {
|
||||
|
||||
return generateFallbackEmailTemplates(event);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const prompt = `Generate 3 different email templates for an event with these details:
|
||||
Event: ${event.title}
|
||||
Date: ${eventDate}
|
||||
Venue: ${event.venue}
|
||||
Description: ${event.description || 'A special event'}
|
||||
|
||||
Create 3 variations with different tones:
|
||||
1. Professional/corporate tone
|
||||
2. Friendly/casual tone
|
||||
3. Urgent/promotional tone
|
||||
|
||||
For each email template:
|
||||
- Create an engaging subject line
|
||||
- Write a compelling preview text (50-80 characters)
|
||||
- Generate HTML email content with proper formatting
|
||||
- Create a text version for accessibility
|
||||
- Include a clear call-to-action
|
||||
|
||||
Format as JSON with an array called "templates", each containing:
|
||||
{
|
||||
"tone": "professional|casual|urgent",
|
||||
"subject": "subject line",
|
||||
"preview_text": "preview text",
|
||||
"html_content": "HTML email content",
|
||||
"text_content": "plain text version"
|
||||
}`;
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are an email marketing expert specializing in event promotion. Create compelling email templates that drive ticket sales and engagement. Always respond with valid JSON.'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.8,
|
||||
max_tokens: 3000
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
let parsedContent;
|
||||
try {
|
||||
parsedContent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
|
||||
return generateFallbackEmailTemplates(event);
|
||||
}
|
||||
|
||||
const templates = parsedContent.templates || parsedContent;
|
||||
const templatesArray = Array.isArray(templates) ? templates : [templates];
|
||||
|
||||
return templatesArray.map((template: any, index: number) => ({
|
||||
id: `ai-email-${Date.now()}-${index}`,
|
||||
subject: template.subject || '',
|
||||
html_content: template.html_content || '',
|
||||
text_content: template.text_content || '',
|
||||
preview_text: template.preview_text || '',
|
||||
tone: template.tone || 'professional',
|
||||
votes: 0,
|
||||
generated_at: new Date().toISOString()
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return generateFallbackEmailTemplates(event);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFallbackEmailTemplates(event: any): EmailTemplate[] {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
id: `fallback-email-${Date.now()}-1`,
|
||||
subject: `${event.title} - ${eventDate}`,
|
||||
preview_text: `Join us for ${event.title} at ${event.venue}`,
|
||||
html_content: `<h1>${event.title}</h1><p>We cordially invite you to ${event.title} on ${eventDate} at ${event.venue}.</p><p>${event.description || 'An exceptional event awaits you.'}</p><a href="#">Reserve Your Tickets</a>`,
|
||||
text_content: `${event.title}\n\nWe cordially invite you to ${event.title} on ${eventDate} at ${event.venue}.\n\n${event.description || 'An exceptional event awaits you.'}\n\nReserve Your Tickets: [LINK]`,
|
||||
tone: 'professional',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-email-${Date.now()}-2`,
|
||||
subject: `🎉 Don't miss ${event.title}!`,
|
||||
preview_text: `Hey there! ${event.title} is coming up fast`,
|
||||
html_content: `<h1>🎉 ${event.title}</h1><p>Hey there! We're so excited to invite you to ${event.title} on ${eventDate} at ${event.venue}.</p><p>${event.description || 'It\'s going to be amazing!'}</p><a href="#">Get Your Tickets Now!</a>`,
|
||||
text_content: `🎉 ${event.title}\n\nHey there! We're so excited to invite you to ${event.title} on ${eventDate} at ${event.venue}.\n\n${event.description || 'It\'s going to be amazing!'}\n\nGet Your Tickets Now: [LINK]`,
|
||||
tone: 'casual',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-email-${Date.now()}-3`,
|
||||
subject: `⏰ LAST CHANCE: ${event.title} tickets selling fast!`,
|
||||
preview_text: `Limited tickets remaining for ${event.title}`,
|
||||
html_content: `<h1>⏰ LAST CHANCE!</h1><p>Limited tickets remaining for ${event.title} on ${eventDate} at ${event.venue}.</p><p>Don't miss out on this exclusive event!</p><a href="#">SECURE YOUR SPOT NOW</a>`,
|
||||
text_content: `⏰ LAST CHANCE!\n\nLimited tickets remaining for ${event.title} on ${eventDate} at ${event.venue}.\n\nDon't miss out on this exclusive event!\n\nSECURE YOUR SPOT NOW: [LINK]`,
|
||||
tone: 'urgent',
|
||||
votes: 0
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Generate AI-powered flyer/asset data
|
||||
export async function generateFlyerDataWithAI(event: any): Promise<FlyerData[]> {
|
||||
if (!OPENAI_API_KEY) {
|
||||
|
||||
return generateFallbackFlyerData(event);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const prompt = `Generate 3 different flyer designs for an event:
|
||||
Event: ${event.title}
|
||||
Date: ${eventDate}
|
||||
Venue: ${event.venue}
|
||||
Description: ${event.description || 'A special event'}
|
||||
|
||||
Create 3 variations with different styles:
|
||||
1. Elegant/sophisticated style
|
||||
2. Modern/contemporary style
|
||||
3. Bold/energetic style
|
||||
|
||||
For each flyer design:
|
||||
- Create compelling headline text
|
||||
- Write engaging body content
|
||||
- Suggest design theme and colors
|
||||
- Include style guidelines
|
||||
|
||||
Format as JSON with an array called "flyers", each containing:
|
||||
{
|
||||
"style": "elegant|modern|bold",
|
||||
"title": "main headline",
|
||||
"content": "body content for flyer",
|
||||
"theme": "theme description",
|
||||
"colors": "color scheme suggestions"
|
||||
}`;
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a graphic design expert specializing in event flyers. Create compelling flyer concepts that attract attendees. Always respond with valid JSON.'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.8,
|
||||
max_tokens: 2000
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
let parsedContent;
|
||||
try {
|
||||
parsedContent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
|
||||
return generateFallbackFlyerData(event);
|
||||
}
|
||||
|
||||
const flyers = parsedContent.flyers || parsedContent;
|
||||
const flyersArray = Array.isArray(flyers) ? flyers : [flyers];
|
||||
|
||||
return flyersArray.map((flyer: any, index: number) => ({
|
||||
id: `ai-flyer-${Date.now()}-${index}`,
|
||||
title: flyer.title || event.title,
|
||||
content: flyer.content || '',
|
||||
style: flyer.style || 'modern',
|
||||
theme: flyer.theme || 'Default theme',
|
||||
votes: 0,
|
||||
generated_at: new Date().toISOString()
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return generateFallbackFlyerData(event);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFallbackFlyerData(event: any): FlyerData[] {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
id: `fallback-flyer-${Date.now()}-1`,
|
||||
title: `${event.title}`,
|
||||
content: `Join us for an elegant evening on ${eventDate} at ${event.venue}. ${event.description || 'An unforgettable experience awaits.'}`,
|
||||
style: 'elegant',
|
||||
theme: 'Sophisticated black and gold theme with elegant typography',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-flyer-${Date.now()}-2`,
|
||||
title: `${event.title}`,
|
||||
content: `Experience ${event.title} like never before. ${eventDate} • ${event.venue}. ${event.description || 'Modern entertainment for the contemporary audience.'}`,
|
||||
style: 'modern',
|
||||
theme: 'Clean minimalist design with bold blues and whites',
|
||||
votes: 0
|
||||
},
|
||||
{
|
||||
id: `fallback-flyer-${Date.now()}-3`,
|
||||
title: `${event.title.toUpperCase()}`,
|
||||
content: `🎉 THE EVENT OF THE YEAR! ${eventDate} at ${event.venue}. ${event.description || 'Get ready for an explosive experience!'}`,
|
||||
style: 'bold',
|
||||
theme: 'High-energy design with bright colors and dynamic typography',
|
||||
votes: 0
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Function to save user vote/preference
|
||||
export async function saveContentVote(contentId: string, vote: 'up' | 'down'): Promise<void> {
|
||||
// In a real implementation, this would save to a database
|
||||
// For now, we'll just log it
|
||||
|
||||
// You could store this in localStorage for learning
|
||||
const votes = JSON.parse(localStorage.getItem('marketing_content_votes') || '{}');
|
||||
votes[contentId] = vote;
|
||||
localStorage.setItem('marketing_content_votes', JSON.stringify(votes));
|
||||
}
|
||||
|
||||
// Function to get voting data for learning
|
||||
export function getVotingData(): Record<string, 'up' | 'down'> {
|
||||
return JSON.parse(localStorage.getItem('marketing_content_votes') || '{}');
|
||||
}
|
||||
|
||||
// Generate a single new social media post for a specific platform
|
||||
export async function regenerateSocialMediaPost(event: any, platform: string, tone?: string): Promise<SocialMediaContent | null> {
|
||||
if (!OPENAI_API_KEY) {
|
||||
|
||||
return generateFallbackSocialPost(event, platform, tone);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const selectedTone = tone || 'engaging';
|
||||
|
||||
const prompt = `Generate a new social media post for ${platform} with the following details:
|
||||
Event: ${event.title}
|
||||
Date: ${eventDate}
|
||||
Venue: ${event.venue}
|
||||
Description: ${event.description || 'A special event'}
|
||||
|
||||
Create a ${selectedTone} post that:
|
||||
- Is optimized for ${platform}
|
||||
- Uses a ${selectedTone} tone
|
||||
- Includes relevant emojis
|
||||
- Has a clear call-to-action
|
||||
- Is different from previous attempts (be creative and unique)
|
||||
- Suggests 3-5 relevant hashtags
|
||||
|
||||
Format as JSON with:
|
||||
{
|
||||
"platform": "${platform}",
|
||||
"content": "post text with emojis",
|
||||
"hashtags": ["#tag1", "#tag2", "#tag3"],
|
||||
"tone": "${selectedTone}"
|
||||
}`;
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a social media expert specializing in ${platform} content. Create engaging, platform-specific posts that drive ticket sales. Always respond with valid JSON.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.9, // Higher temperature for more creativity
|
||||
max_tokens: 500
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
let parsedContent;
|
||||
try {
|
||||
parsedContent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
|
||||
return generateFallbackSocialPost(event, platform, tone);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `ai-regen-${Date.now()}`,
|
||||
platform: parsedContent.platform || platform,
|
||||
content: parsedContent.content || '',
|
||||
hashtags: Array.isArray(parsedContent.hashtags) ? parsedContent.hashtags : [],
|
||||
tone: parsedContent.tone || selectedTone,
|
||||
votes: 0,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return generateFallbackSocialPost(event, platform, tone);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFallbackSocialPost(event: any, platform: string, tone?: string): SocialMediaContent {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const fallbackContent = {
|
||||
facebook: `🎉 Join us for ${event.title} on ${eventDate} at ${event.venue}! This is going to be an incredible experience. Get your tickets now!`,
|
||||
twitter: `🎫 ${event.title} - ${eventDate} at ${event.venue}. Don't miss out! Tickets available now.`,
|
||||
instagram: `✨ ${event.title} ✨\n📅 ${eventDate}\n📍 ${event.venue}\n\nThis is THE event you've been waiting for! 🎟️`,
|
||||
linkedin: `We're excited to announce ${event.title} on ${eventDate} at ${event.venue}. Join us for this professional networking and entertainment event.`
|
||||
};
|
||||
|
||||
return {
|
||||
id: `fallback-regen-${Date.now()}`,
|
||||
platform: platform as any,
|
||||
content: fallbackContent[platform as keyof typeof fallbackContent] || fallbackContent.facebook,
|
||||
hashtags: ['#event', '#tickets', '#liveentertainment'],
|
||||
tone: tone || 'engaging',
|
||||
votes: 0,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a single new email template
|
||||
export async function regenerateEmailTemplate(event: any, tone: string): Promise<EmailTemplate | null> {
|
||||
if (!OPENAI_API_KEY) {
|
||||
|
||||
return generateFallbackEmailTemplate(event, tone);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const prompt = `Generate a new email template with a ${tone} tone for this event:
|
||||
Event: ${event.title}
|
||||
Date: ${eventDate}
|
||||
Venue: ${event.venue}
|
||||
Description: ${event.description || 'A special event'}
|
||||
|
||||
Create a ${tone} email template that:
|
||||
- Has an engaging subject line
|
||||
- Includes compelling preview text (50-80 characters)
|
||||
- Contains HTML email content with proper formatting
|
||||
- Has a plain text version for accessibility
|
||||
- Is different from previous attempts (be creative and unique)
|
||||
- Includes a clear call-to-action
|
||||
|
||||
Format as JSON with:
|
||||
{
|
||||
"tone": "${tone}",
|
||||
"subject": "subject line",
|
||||
"preview_text": "preview text",
|
||||
"html_content": "HTML email content",
|
||||
"text_content": "plain text version"
|
||||
}`;
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are an email marketing expert. Create compelling email templates that drive engagement and ticket sales. Always respond with valid JSON.'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.9,
|
||||
max_tokens: 2000
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
let parsedContent;
|
||||
try {
|
||||
parsedContent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
|
||||
return generateFallbackEmailTemplate(event, tone);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `ai-email-regen-${Date.now()}`,
|
||||
subject: parsedContent.subject || '',
|
||||
html_content: parsedContent.html_content || '',
|
||||
text_content: parsedContent.text_content || '',
|
||||
preview_text: parsedContent.preview_text || '',
|
||||
tone: parsedContent.tone || tone,
|
||||
votes: 0,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return generateFallbackEmailTemplate(event, tone);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFallbackEmailTemplate(event: any, tone: string): EmailTemplate {
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const templates = {
|
||||
professional: {
|
||||
subject: `${event.title} - ${eventDate}`,
|
||||
preview_text: `Join us for ${event.title} at ${event.venue}`,
|
||||
html_content: `<h1>${event.title}</h1><p>We cordially invite you to ${event.title} on ${eventDate} at ${event.venue}.</p><p>${event.description || 'An exceptional event awaits you.'}</p><a href="#">Reserve Your Tickets</a>`,
|
||||
text_content: `${event.title}\n\nWe cordially invite you to ${event.title} on ${eventDate} at ${event.venue}.\n\n${event.description || 'An exceptional event awaits you.'}\n\nReserve Your Tickets: [LINK]`
|
||||
},
|
||||
casual: {
|
||||
subject: `🎉 Don't miss ${event.title}!`,
|
||||
preview_text: `Hey there! ${event.title} is coming up fast`,
|
||||
html_content: `<h1>🎉 ${event.title}</h1><p>Hey there! We're so excited to invite you to ${event.title} on ${eventDate} at ${event.venue}.</p><p>${event.description || 'It\'s going to be amazing!'}</p><a href="#">Get Your Tickets Now!</a>`,
|
||||
text_content: `🎉 ${event.title}\n\nHey there! We're so excited to invite you to ${event.title} on ${eventDate} at ${event.venue}.\n\n${event.description || 'It\'s going to be amazing!'}\n\nGet Your Tickets Now: [LINK]`
|
||||
},
|
||||
urgent: {
|
||||
subject: `⏰ LAST CHANCE: ${event.title} tickets selling fast!`,
|
||||
preview_text: `Limited tickets remaining for ${event.title}`,
|
||||
html_content: `<h1>⏰ LAST CHANCE!</h1><p>Limited tickets remaining for ${event.title} on ${eventDate} at ${event.venue}.</p><p>Don't miss out on this exclusive event!</p><a href="#">SECURE YOUR SPOT NOW</a>`,
|
||||
text_content: `⏰ LAST CHANCE!\n\nLimited tickets remaining for ${event.title} on ${eventDate} at ${event.venue}.\n\nDon't miss out on this exclusive event!\n\nSECURE YOUR SPOT NOW: [LINK]`
|
||||
}
|
||||
};
|
||||
|
||||
const template = templates[tone as keyof typeof templates] || templates.professional;
|
||||
|
||||
return {
|
||||
id: `fallback-email-regen-${Date.now()}`,
|
||||
subject: template.subject,
|
||||
html_content: template.html_content,
|
||||
text_content: template.text_content,
|
||||
preview_text: template.preview_text,
|
||||
tone,
|
||||
votes: 0,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -106,7 +106,7 @@ class MarketingKitService {
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating marketing kit:', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ const supabase = createClient<Database>(
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
// OpenAI configuration
|
||||
const OPENAI_API_KEY = import.meta.env.OPENAI_API_KEY;
|
||||
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
export interface MarketingAsset {
|
||||
id: string;
|
||||
event_id: string;
|
||||
@@ -20,7 +24,7 @@ export interface MarketingKitData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
venue: string;
|
||||
image_url?: string;
|
||||
};
|
||||
@@ -34,10 +38,14 @@ export interface MarketingKitData {
|
||||
}
|
||||
|
||||
export interface SocialMediaContent {
|
||||
id?: string;
|
||||
platform: 'facebook' | 'twitter' | 'instagram' | 'linkedin';
|
||||
content: string;
|
||||
hashtags: string[];
|
||||
image_url?: string;
|
||||
tone?: 'professional' | 'casual' | 'exciting' | 'informative' | 'urgent';
|
||||
votes?: number;
|
||||
generated_at?: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
@@ -52,34 +60,29 @@ export async function loadMarketingKit(eventId: string): Promise<MarketingKitDat
|
||||
// Load event data
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select('id, title, description, date, venue, image_url, social_links')
|
||||
.select('id, title, description, start_time, venue, image_url')
|
||||
.eq('id', eventId)
|
||||
.single();
|
||||
|
||||
if (eventError) {
|
||||
console.error('Error loading event for marketing kit:', eventError);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load existing marketing assets
|
||||
const { data: assets, error: assetsError } = await supabase
|
||||
.from('marketing_kit_assets')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (assetsError) {
|
||||
console.error('Error loading marketing assets:', assetsError);
|
||||
return null;
|
||||
}
|
||||
// Since marketing_kit_assets table doesn't exist, return empty assets
|
||||
// This can be implemented later when the table is created
|
||||
|
||||
return {
|
||||
event,
|
||||
assets: assets || [],
|
||||
social_links: event.social_links || {}
|
||||
event: {
|
||||
...event,
|
||||
start_time: event.start_time || '',
|
||||
description: event.description || ''
|
||||
},
|
||||
assets: [], // Empty assets for now
|
||||
social_links: {}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading marketing kit:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -100,32 +103,26 @@ export async function generateMarketingKit(eventId: string): Promise<MarketingKi
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error generating marketing kit:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMarketingAsset(eventId: string, assetType: string, assetData: any): Promise<MarketingAsset | null> {
|
||||
try {
|
||||
const { data: asset, error } = await supabase
|
||||
.from('marketing_kit_assets')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
asset_type: assetType,
|
||||
asset_data: assetData,
|
||||
asset_url: assetData.url || ''
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
// Since marketing_kit_assets table doesn't exist, return a mock asset
|
||||
// This can be implemented later when the table is created
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving marketing asset:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset;
|
||||
return {
|
||||
id: `temp-${Date.now()}`,
|
||||
event_id: eventId,
|
||||
asset_type: assetType as any,
|
||||
asset_url: assetData.url || '',
|
||||
asset_data: assetData,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error saving marketing asset:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -138,13 +135,13 @@ export async function updateSocialLinks(eventId: string, socialLinks: Record<str
|
||||
.eq('id', eventId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating social links:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating social links:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -311,7 +308,7 @@ export async function downloadAsset(assetUrl: string, filename: string): Promise
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Error downloading asset:', error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
125
src/lib/performance.js
Normal file
125
src/lib/performance.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Performance detection and optimization for glassmorphism effects
|
||||
* Automatically reduces effects on low-end devices
|
||||
*/
|
||||
|
||||
// Device capability detection
|
||||
export function detectDeviceCapabilities() {
|
||||
const capabilities = {
|
||||
isLowEnd: false,
|
||||
isMobile: false,
|
||||
hasReducedMotion: false,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 2,
|
||||
deviceMemory: navigator.deviceMemory || 2,
|
||||
connection: navigator.connection || null
|
||||
};
|
||||
|
||||
// Detect low-end devices
|
||||
if (capabilities.hardwareConcurrency <= 2 || capabilities.deviceMemory <= 2) {
|
||||
capabilities.isLowEnd = true;
|
||||
}
|
||||
|
||||
// Check for slow network connection
|
||||
if (capabilities.connection && capabilities.connection.effectiveType) {
|
||||
const slowConnections = ['slow-2g', '2g', '3g'];
|
||||
if (slowConnections.includes(capabilities.connection.effectiveType)) {
|
||||
capabilities.isLowEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect mobile devices
|
||||
capabilities.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
// Check for reduced motion preference
|
||||
capabilities.hasReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
// Apply performance optimizations based on device capabilities
|
||||
export function applyPerformanceOptimizations() {
|
||||
const capabilities = detectDeviceCapabilities();
|
||||
const body = document.body;
|
||||
|
||||
// Add CSS classes for optimization
|
||||
if (capabilities.isLowEnd) {
|
||||
body.classList.add('low-end-device');
|
||||
}
|
||||
|
||||
if (capabilities.isMobile) {
|
||||
body.classList.add('mobile-device');
|
||||
}
|
||||
|
||||
if (capabilities.hasReducedMotion) {
|
||||
body.classList.add('reduced-motion');
|
||||
}
|
||||
|
||||
// Log performance optimization decisions
|
||||
console.log('Performance optimizations applied:', {
|
||||
isLowEnd: capabilities.isLowEnd,
|
||||
isMobile: capabilities.isMobile,
|
||||
hasReducedMotion: capabilities.hasReducedMotion,
|
||||
hardwareConcurrency: capabilities.hardwareConcurrency,
|
||||
deviceMemory: capabilities.deviceMemory
|
||||
});
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
// Monitor performance and adjust effects dynamically
|
||||
export function monitorPerformance() {
|
||||
if (!window.performance || !window.performance.getEntriesByType) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for frame rate issues
|
||||
let frameCount = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
function checkFrameRate() {
|
||||
frameCount++;
|
||||
const currentTime = performance.now();
|
||||
|
||||
if (currentTime - lastTime >= 1000) {
|
||||
const fps = frameCount;
|
||||
frameCount = 0;
|
||||
lastTime = currentTime;
|
||||
|
||||
// If FPS is consistently low, reduce effects
|
||||
if (fps < 30) {
|
||||
document.body.classList.add('performance-degraded');
|
||||
console.warn('Low FPS detected, reducing glassmorphism effects');
|
||||
} else if (fps > 50) {
|
||||
document.body.classList.remove('performance-degraded');
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(checkFrameRate);
|
||||
}
|
||||
|
||||
requestAnimationFrame(checkFrameRate);
|
||||
}
|
||||
|
||||
// Initialize performance optimizations
|
||||
export function initializePerformanceOptimizations() {
|
||||
// Apply initial optimizations
|
||||
const capabilities = applyPerformanceOptimizations();
|
||||
|
||||
// Start performance monitoring
|
||||
if (!capabilities.isLowEnd) {
|
||||
monitorPerformance();
|
||||
}
|
||||
|
||||
// Listen for connection changes
|
||||
if (navigator.connection) {
|
||||
navigator.connection.addEventListener('change', () => {
|
||||
applyPerformanceOptimizations();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for reduced motion changes
|
||||
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotionQuery.addEventListener('change', () => {
|
||||
applyPerformanceOptimizations();
|
||||
});
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export class DatabaseMonitor {
|
||||
|
||||
// Log slow queries
|
||||
if (duration > 1000) { // Queries over 1 second
|
||||
console.warn(`Slow query detected: ${query} took ${duration}ms`);
|
||||
|
||||
addBreadcrumb(`Slow query: ${query.substring(0, 100)}...`, 'database', 'warning', {
|
||||
duration,
|
||||
table
|
||||
@@ -158,7 +158,7 @@ export class APIMonitor {
|
||||
|
||||
// Log slow API calls
|
||||
if (duration > 5000) { // API calls over 5 seconds
|
||||
console.warn(`Slow API call: ${key} took ${duration}ms`);
|
||||
|
||||
addBreadcrumb(`Slow API call: ${key}`, 'http', 'warning', {
|
||||
duration,
|
||||
statusCode
|
||||
@@ -211,7 +211,7 @@ export class MemoryMonitor {
|
||||
// Log memory warning if usage is high
|
||||
const heapUsedMB = usage.heapUsed / 1024 / 1024;
|
||||
if (heapUsedMB > 512) { // Over 512MB
|
||||
console.warn(`High memory usage: ${heapUsedMB.toFixed(2)}MB`);
|
||||
|
||||
addBreadcrumb(`High memory usage: ${heapUsedMB.toFixed(2)}MB`, 'memory', 'warning', {
|
||||
heapUsed: usage.heapUsed,
|
||||
heapTotal: usage.heapTotal,
|
||||
@@ -288,7 +288,7 @@ export const WebVitalsMonitor = {
|
||||
addBreadcrumb(`LCP: ${entry.startTime.toFixed(2)}ms`, 'performance', 'info');
|
||||
|
||||
if (entry.startTime > 2500) { // LCP > 2.5s is poor
|
||||
console.warn(`Poor LCP: ${entry.startTime.toFixed(2)}ms`);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ export const WebVitalsMonitor = {
|
||||
addBreadcrumb(`FID: ${fid.toFixed(2)}ms`, 'performance', 'info');
|
||||
|
||||
if (fid > 100) { // FID > 100ms is poor
|
||||
console.warn(`Poor FID: ${fid.toFixed(2)}ms`);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ export const WebVitalsMonitor = {
|
||||
}
|
||||
|
||||
if (clsValue > 0.1) { // CLS > 0.1 is poor
|
||||
console.warn(`Poor CLS: ${clsValue.toFixed(4)}`);
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
@@ -350,7 +350,7 @@ export const WebVitalsMonitor = {
|
||||
|
||||
// Log slow page loads
|
||||
if (metrics.loadComplete > 3000) { // Over 3 seconds
|
||||
console.warn(`Slow page load: ${metrics.loadComplete}ms`);
|
||||
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export class QRCodeGenerator {
|
||||
size: mergedOptions.size || this.defaultOptions.size!
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
|
||||
throw new Error('Failed to generate QR code');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function generateQRCode(ticketData: TicketData): Promise<string> {
|
||||
|
||||
return qrCodeDataURL;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
|
||||
throw new Error('Failed to generate QR code');
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ export function parseQRCode(qrData: string): { uuid: string; eventId: string; ty
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error parsing QR code:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,16 @@ export interface SalesData {
|
||||
id: string;
|
||||
event_id: string;
|
||||
ticket_type_id: string;
|
||||
price_paid: number;
|
||||
status: string;
|
||||
price: number;
|
||||
refund_status: string | null;
|
||||
checked_in: boolean;
|
||||
customer_email: string;
|
||||
customer_name: string;
|
||||
purchaser_email: string;
|
||||
purchaser_name: string | null;
|
||||
created_at: string;
|
||||
ticket_uuid: string;
|
||||
uuid: string;
|
||||
ticket_types: {
|
||||
name: string;
|
||||
price_cents: number;
|
||||
price: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,16 +64,16 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
||||
id,
|
||||
event_id,
|
||||
ticket_type_id,
|
||||
price_paid,
|
||||
status,
|
||||
price,
|
||||
refund_status,
|
||||
checked_in,
|
||||
customer_email,
|
||||
customer_name,
|
||||
purchaser_email,
|
||||
purchaser_name,
|
||||
created_at,
|
||||
ticket_uuid,
|
||||
uuid,
|
||||
ticket_types (
|
||||
name,
|
||||
price_cents
|
||||
price
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
@@ -85,7 +85,15 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
||||
}
|
||||
|
||||
if (filters?.status) {
|
||||
query = query.eq('status', filters.status);
|
||||
if (filters.status === 'confirmed') {
|
||||
query = query.or('refund_status.is.null,refund_status.eq.null');
|
||||
} else if (filters.status === 'refunded') {
|
||||
query = query.eq('refund_status', 'completed');
|
||||
} else if (filters.status === 'pending') {
|
||||
query = query.eq('refund_status', 'pending');
|
||||
} else if (filters.status === 'cancelled') {
|
||||
query = query.eq('refund_status', 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (filters?.checkedIn !== undefined) {
|
||||
@@ -93,7 +101,7 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
||||
}
|
||||
|
||||
if (filters?.searchTerm) {
|
||||
query = query.or(`customer_email.ilike.%${filters.searchTerm}%,customer_name.ilike.%${filters.searchTerm}%`);
|
||||
query = query.or(`purchaser_email.ilike.%${filters.searchTerm}%,purchaser_name.ilike.%${filters.searchTerm}%`);
|
||||
}
|
||||
|
||||
if (filters?.dateFrom) {
|
||||
@@ -113,16 +121,16 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
||||
|
||||
return sales || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading sales data:', error);
|
||||
console.error('Error in loadSalesData:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateSalesMetrics(salesData: SalesData[]): SalesMetrics {
|
||||
const confirmedSales = salesData.filter(sale => sale.status === 'confirmed');
|
||||
const refundedSales = salesData.filter(sale => sale.status === 'refunded');
|
||||
const confirmedSales = salesData.filter(sale => !sale.refund_status || sale.refund_status === null);
|
||||
const refundedSales = salesData.filter(sale => sale.refund_status === 'completed');
|
||||
|
||||
const totalRevenue = confirmedSales.reduce((sum, sale) => sum + sale.price_paid, 0);
|
||||
const totalRevenue = confirmedSales.reduce((sum, sale) => sum + sale.price, 0);
|
||||
const netRevenue = totalRevenue * 0.97; // Assuming 3% platform fee
|
||||
const ticketsSold = confirmedSales.length;
|
||||
const averageTicketPrice = ticketsSold > 0 ? totalRevenue / ticketsSold : 0;
|
||||
@@ -142,7 +150,7 @@ export function generateTimeSeries(salesData: SalesData[], groupBy: 'day' | 'wee
|
||||
const groupedData = new Map<string, { revenue: number; tickets: number }>();
|
||||
|
||||
salesData.forEach(sale => {
|
||||
if (sale.status !== 'confirmed') return;
|
||||
if (sale.refund_status && sale.refund_status !== null) return;
|
||||
|
||||
const date = new Date(sale.created_at);
|
||||
let key: string;
|
||||
@@ -151,11 +159,12 @@ export function generateTimeSeries(salesData: SalesData[], groupBy: 'day' | 'wee
|
||||
case 'day':
|
||||
key = date.toISOString().split('T')[0];
|
||||
break;
|
||||
case 'week':
|
||||
case 'week': {
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay());
|
||||
key = weekStart.toISOString().split('T')[0];
|
||||
break;
|
||||
}
|
||||
case 'month':
|
||||
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
break;
|
||||
@@ -164,7 +173,7 @@ export function generateTimeSeries(salesData: SalesData[], groupBy: 'day' | 'wee
|
||||
}
|
||||
|
||||
const existing = groupedData.get(key) || { revenue: 0, tickets: 0 };
|
||||
existing.revenue += sale.price_paid;
|
||||
existing.revenue += sale.price;
|
||||
existing.tickets += 1;
|
||||
groupedData.set(key, existing);
|
||||
});
|
||||
@@ -195,10 +204,10 @@ export function generateTicketTypeBreakdown(salesData: SalesData[]): TicketTypeB
|
||||
refunded: 0
|
||||
};
|
||||
|
||||
if (sale.status === 'confirmed') {
|
||||
if (!sale.refund_status || sale.refund_status === null) {
|
||||
existing.sold += 1;
|
||||
existing.revenue += sale.price_paid;
|
||||
} else if (sale.status === 'refunded') {
|
||||
existing.revenue += sale.price;
|
||||
} else if (sale.refund_status === 'completed') {
|
||||
existing.refunded += 1;
|
||||
}
|
||||
|
||||
@@ -230,8 +239,8 @@ export async function exportSalesData(eventId: string, format: 'csv' | 'json' =
|
||||
// CSV format
|
||||
const headers = [
|
||||
'Order ID',
|
||||
'Customer Name',
|
||||
'Customer Email',
|
||||
'Purchaser Name',
|
||||
'Purchaser Email',
|
||||
'Ticket Type',
|
||||
'Price Paid',
|
||||
'Status',
|
||||
@@ -242,14 +251,16 @@ export async function exportSalesData(eventId: string, format: 'csv' | 'json' =
|
||||
|
||||
const rows = salesData.map(sale => [
|
||||
sale.id,
|
||||
sale.customer_name,
|
||||
sale.customer_email,
|
||||
sale.purchaser_name || '',
|
||||
sale.purchaser_email,
|
||||
sale.ticket_types.name,
|
||||
formatCurrency(sale.price_paid),
|
||||
sale.status,
|
||||
formatCurrency(sale.price),
|
||||
(!sale.refund_status || sale.refund_status === null) ? 'confirmed' :
|
||||
sale.refund_status === 'completed' ? 'refunded' :
|
||||
sale.refund_status === 'pending' ? 'pending' : 'failed',
|
||||
sale.checked_in ? 'Yes' : 'No',
|
||||
new Date(sale.created_at).toLocaleDateString(),
|
||||
sale.ticket_uuid
|
||||
sale.uuid
|
||||
]);
|
||||
|
||||
return [
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function verifyPin(pin: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await bcrypt.compare(pin, hash);
|
||||
} catch (error) {
|
||||
console.error('PIN verification error:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ export async function loadSeatingMaps(organizationId: string): Promise<SeatingMa
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading seating maps:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return seatingMaps || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading seating maps:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -64,13 +64,13 @@ export async function getSeatingMap(seatingMapId: string): Promise<SeatingMap |
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading seating map:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return seatingMap;
|
||||
} catch (error) {
|
||||
console.error('Error loading seating map:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -87,13 +87,13 @@ export async function createSeatingMap(organizationId: string, seatingMapData: S
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating seating map:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return seatingMap;
|
||||
} catch (error) {
|
||||
console.error('Error creating seating map:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -106,13 +106,13 @@ export async function updateSeatingMap(seatingMapId: string, updates: Partial<Se
|
||||
.eq('id', seatingMapId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating seating map:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating seating map:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -136,13 +136,13 @@ export async function deleteSeatingMap(seatingMapId: string): Promise<boolean> {
|
||||
.eq('id', seatingMapId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting seating map:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting seating map:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -155,13 +155,13 @@ export async function applySeatingMapToEvent(eventId: string, seatingMapId: stri
|
||||
.eq('id', eventId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error applying seating map to event:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error applying seating map to event:', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,8 @@ if (SENTRY_CONFIG.DSN) {
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Sentry initialized successfully');
|
||||
} else {
|
||||
console.warn('Sentry DSN not configured. Error monitoring disabled.');
|
||||
// Sentry not configured - will use console logging instead
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +102,7 @@ export function captureException(error: Error, context?: {
|
||||
additionalData?: Record<string, any>;
|
||||
}) {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
console.error('Sentry not configured, logging error locally:', error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,7 +133,7 @@ export function captureMessage(message: string, level: 'fatal' | 'error' | 'warn
|
||||
additionalData?: Record<string, any>;
|
||||
}) {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
console.log('Sentry not configured, logging message locally:', message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
126
src/lib/simple-auth.ts
Normal file
126
src/lib/simple-auth.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { supabase } from './supabase';
|
||||
import { createSupabaseServerClientFromRequest } from './supabase-ssr';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
* Parse cookie header into key-value pairs
|
||||
*/
|
||||
function parseCookies(cookieHeader: string): Record<string, string> {
|
||||
return cookieHeader.split(';').reduce((acc, cookie) => {
|
||||
const [key, value] = cookie.trim().split('=');
|
||||
if (key && value) {
|
||||
acc[key] = decodeURIComponent(value);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}
|
||||
|
||||
export interface AuthContext {
|
||||
user: User;
|
||||
session: Session;
|
||||
isAdmin?: boolean;
|
||||
isSuperAdmin?: boolean;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified authentication verification without logging
|
||||
* Uses Supabase's built-in server-side session parsing
|
||||
*/
|
||||
export async function verifyAuthSimple(request: Request): Promise<AuthContext | null> {
|
||||
try {
|
||||
// Create SSR Supabase client
|
||||
const supabaseSSR = createSupabaseServerClientFromRequest(request);
|
||||
|
||||
// Get the session from cookies
|
||||
const { data: { session }, error } = await supabaseSSR.auth.getSession();
|
||||
|
||||
console.log('Session check:', session ? 'Session found' : 'No session', error?.message);
|
||||
|
||||
if (error || !session) {
|
||||
// Try Authorization header as fallback
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
console.log('Auth header:', authHeader ? 'Present' : 'Not present');
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const accessToken = authHeader.substring(7);
|
||||
|
||||
// Verify the token with Supabase
|
||||
const { data, error } = await supabase.auth.getUser(accessToken);
|
||||
|
||||
if (error || !data.user) {
|
||||
console.log('Bearer token verification failed:', error?.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await buildAuthContext(data.user, accessToken, supabaseSSR);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await buildAuthContext(session.user, session.access_token, supabaseSSR);
|
||||
} catch (error) {
|
||||
console.error('Auth verification failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build auth context with user data
|
||||
*/
|
||||
async function buildAuthContext(user: any, accessToken: string, supabaseClient: any): Promise<AuthContext> {
|
||||
// Get user role from database
|
||||
const { data: userRecord, error: dbError } = await supabaseClient
|
||||
.from('users')
|
||||
.select('role, organization_id, is_super_admin')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (dbError) {
|
||||
console.log('Database user lookup failed:', dbError.message);
|
||||
// Continue without role data - better than failing entirely
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
session: { access_token: accessToken } as Session,
|
||||
isAdmin: userRecord?.role === 'admin' || false,
|
||||
isSuperAdmin: userRecord?.role === 'admin' && userRecord?.is_super_admin === true,
|
||||
organizationId: userRecord?.organization_id
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified admin requirement check
|
||||
*/
|
||||
export async function requireAdminSimple(request: Request): Promise<AuthContext> {
|
||||
const auth = await verifyAuthSimple(request);
|
||||
|
||||
if (!auth) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
if (!auth.isAdmin) {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Super admin requirement check
|
||||
*/
|
||||
export async function requireSuperAdminSimple(request: Request): Promise<AuthContext> {
|
||||
const auth = await verifyAuthSimple(request);
|
||||
|
||||
if (!auth) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
if (!auth.isSuperAdmin) {
|
||||
throw new Error('Super admin access required');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
242
src/lib/stripe-account-switching.ts
Normal file
242
src/lib/stripe-account-switching.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import Stripe from 'stripe';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Main platform Stripe instance
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2023-10-16',
|
||||
});
|
||||
|
||||
// Interface for payment processing options
|
||||
interface PaymentOptions {
|
||||
eventId: string;
|
||||
userId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
customerId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Interface for platform fee calculation
|
||||
interface FeeCalculation {
|
||||
platformFee: number;
|
||||
feeType: 'percentage' | 'fixed' | 'none';
|
||||
feePercentage?: number;
|
||||
feeFixed?: number;
|
||||
useCustomStripe: boolean;
|
||||
stripeAccountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate Stripe account and fee structure for an event
|
||||
*/
|
||||
export async function getPaymentConfiguration(eventId: string, userId: string): Promise<FeeCalculation> {
|
||||
try {
|
||||
// Check if user has custom pricing profile
|
||||
const { data: profile } = await supabase
|
||||
.from('custom_pricing_profiles')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (!profile) {
|
||||
// Use default platform settings
|
||||
return {
|
||||
platformFee: 0,
|
||||
feeType: 'percentage',
|
||||
feePercentage: 2.9, // Default platform fee
|
||||
useCustomStripe: false
|
||||
};
|
||||
}
|
||||
|
||||
// Check for event-specific overrides
|
||||
const { data: override } = await supabase
|
||||
.from('event_pricing_overrides')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.eq('custom_pricing_profile_id', profile.id)
|
||||
.single();
|
||||
|
||||
if (override) {
|
||||
// Use event-specific override
|
||||
const platformFee = calculatePlatformFee(100, override.platform_fee_type, override.platform_fee_percentage, override.platform_fee_fixed);
|
||||
|
||||
return {
|
||||
platformFee,
|
||||
feeType: override.platform_fee_type || 'percentage',
|
||||
feePercentage: override.platform_fee_percentage,
|
||||
feeFixed: override.platform_fee_fixed,
|
||||
useCustomStripe: override.use_custom_stripe_account && !!profile.stripe_account_id,
|
||||
stripeAccountId: override.use_custom_stripe_account ? profile.stripe_account_id : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Use profile defaults
|
||||
const platformFee = calculatePlatformFee(100, profile.custom_platform_fee_type, profile.custom_platform_fee_percentage, profile.custom_platform_fee_fixed);
|
||||
|
||||
return {
|
||||
platformFee,
|
||||
feeType: profile.custom_platform_fee_type || 'percentage',
|
||||
feePercentage: profile.custom_platform_fee_percentage,
|
||||
feeFixed: profile.custom_platform_fee_fixed,
|
||||
useCustomStripe: profile.use_personal_stripe && !!profile.stripe_account_id,
|
||||
stripeAccountId: profile.use_personal_stripe ? profile.stripe_account_id : undefined
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
|
||||
// Return default configuration on error
|
||||
return {
|
||||
platformFee: 0,
|
||||
feeType: 'percentage',
|
||||
feePercentage: 2.9,
|
||||
useCustomStripe: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates platform fee based on type and configuration
|
||||
*/
|
||||
function calculatePlatformFee(
|
||||
amount: number,
|
||||
feeType?: string,
|
||||
feePercentage?: number,
|
||||
feeFixed?: number
|
||||
): number {
|
||||
switch (feeType) {
|
||||
case 'percentage':
|
||||
return Math.round((amount * (feePercentage || 0)) / 100);
|
||||
case 'fixed':
|
||||
return feeFixed || 0;
|
||||
case 'none':
|
||||
return 0;
|
||||
default:
|
||||
return Math.round((amount * 2.9) / 100); // Default 2.9%
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a payment intent with the appropriate Stripe account
|
||||
*/
|
||||
export async function createPaymentIntent(options: PaymentOptions): Promise<Stripe.PaymentIntent> {
|
||||
const config = await getPaymentConfiguration(options.eventId, options.userId);
|
||||
|
||||
const platformFee = calculatePlatformFee(options.amount, config.feeType, config.feePercentage, config.feeFixed);
|
||||
|
||||
if (config.useCustomStripe && config.stripeAccountId) {
|
||||
// Use custom Stripe account - payment goes directly to organizer
|
||||
const customStripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2023-10-16',
|
||||
stripeAccount: config.stripeAccountId
|
||||
});
|
||||
|
||||
return await customStripe.paymentIntents.create({
|
||||
amount: options.amount,
|
||||
currency: options.currency,
|
||||
customer: options.customerId,
|
||||
metadata: {
|
||||
event_id: options.eventId,
|
||||
user_id: options.userId,
|
||||
platform_fee: platformFee.toString(),
|
||||
custom_stripe_account: 'true',
|
||||
...options.metadata
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Use platform Stripe account with application fee
|
||||
const applicationFee = platformFee > 0 ? platformFee : undefined;
|
||||
|
||||
return await stripe.paymentIntents.create({
|
||||
amount: options.amount,
|
||||
currency: options.currency,
|
||||
customer: options.customerId,
|
||||
application_fee_amount: applicationFee,
|
||||
metadata: {
|
||||
event_id: options.eventId,
|
||||
user_id: options.userId,
|
||||
platform_fee: platformFee.toString(),
|
||||
custom_stripe_account: 'false',
|
||||
...options.metadata
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a checkout session with the appropriate Stripe account
|
||||
*/
|
||||
export async function createCheckoutSession(options: {
|
||||
eventId: string;
|
||||
userId: string;
|
||||
lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<Stripe.Checkout.Session> {
|
||||
const config = await getPaymentConfiguration(options.eventId, options.userId);
|
||||
|
||||
const sessionParams: Stripe.Checkout.SessionCreateParams = {
|
||||
payment_method_types: ['card'],
|
||||
line_items: options.lineItems,
|
||||
mode: 'payment',
|
||||
success_url: options.successUrl,
|
||||
cancel_url: options.cancelUrl,
|
||||
metadata: {
|
||||
event_id: options.eventId,
|
||||
user_id: options.userId,
|
||||
custom_stripe_account: config.useCustomStripe ? 'true' : 'false',
|
||||
...options.metadata
|
||||
}
|
||||
};
|
||||
|
||||
if (config.useCustomStripe && config.stripeAccountId) {
|
||||
// Use custom Stripe account
|
||||
const customStripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2023-10-16',
|
||||
stripeAccount: config.stripeAccountId
|
||||
});
|
||||
|
||||
return await customStripe.checkout.sessions.create(sessionParams);
|
||||
} else {
|
||||
// Use platform Stripe account
|
||||
const totalAmount = options.lineItems.reduce((sum, item) => {
|
||||
return sum + (item.price_data?.unit_amount || 0) * (item.quantity || 1);
|
||||
}, 0);
|
||||
|
||||
const platformFee = calculatePlatformFee(totalAmount, config.feeType, config.feePercentage, config.feeFixed);
|
||||
|
||||
if (platformFee > 0) {
|
||||
sessionParams.payment_intent_data = {
|
||||
application_fee_amount: platformFee
|
||||
};
|
||||
}
|
||||
|
||||
return await stripe.checkout.sessions.create(sessionParams);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a custom Stripe account is properly set up
|
||||
*/
|
||||
export async function validateStripeAccount(stripeAccountId: string): Promise<boolean> {
|
||||
try {
|
||||
const account = await stripe.accounts.retrieve(stripeAccountId);
|
||||
return account.charges_enabled && account.payouts_enabled;
|
||||
} catch (error) {
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display name for a Stripe account
|
||||
*/
|
||||
export async function getStripeAccountName(stripeAccountId: string): Promise<string> {
|
||||
try {
|
||||
const account = await stripe.accounts.retrieve(stripeAccountId);
|
||||
return account.display_name || account.email || 'Custom Account';
|
||||
} catch (error) {
|
||||
|
||||
return 'Custom Account';
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,47 @@ import Stripe from 'stripe';
|
||||
export const STRIPE_CONFIG = {
|
||||
// Stripe Connect settings
|
||||
CONNECT_CLIENT_ID: import.meta.env.STRIPE_CONNECT_CLIENT_ID,
|
||||
PUBLISHABLE_KEY: import.meta.env.STRIPE_PUBLISHABLE_KEY,
|
||||
PUBLISHABLE_KEY: import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || import.meta.env.STRIPE_PUBLISHABLE_KEY,
|
||||
SECRET_KEY: import.meta.env.STRIPE_SECRET_KEY,
|
||||
WEBHOOK_SECRET: import.meta.env.STRIPE_WEBHOOK_SECRET,
|
||||
};
|
||||
|
||||
// Validate required environment variables (only warn in development)
|
||||
if (!STRIPE_CONFIG.SECRET_KEY && typeof window === 'undefined') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Missing STRIPE_SECRET_KEY environment variable - Stripe functionality will be disabled');
|
||||
// Validate required environment variables
|
||||
function validateStripeConfig() {
|
||||
const errors = [];
|
||||
|
||||
if (!STRIPE_CONFIG.SECRET_KEY && typeof window === 'undefined') {
|
||||
errors.push('STRIPE_SECRET_KEY is required for server-side operations');
|
||||
}
|
||||
|
||||
if (!STRIPE_CONFIG.PUBLISHABLE_KEY) {
|
||||
errors.push('PUBLIC_STRIPE_PUBLISHABLE_KEY (or STRIPE_PUBLISHABLE_KEY) is required for client-side operations');
|
||||
}
|
||||
|
||||
if (!STRIPE_CONFIG.WEBHOOK_SECRET && typeof window === 'undefined') {
|
||||
errors.push('STRIPE_WEBHOOK_SECRET is required for webhook validation');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = `Stripe configuration errors:\n${errors.map(e => ` - ${e}`).join('\n')}`;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('🔥 Stripe Configuration Errors:');
|
||||
errors.forEach(error => console.error(` ❌ ${error}`));
|
||||
console.error('\n📖 Check your .env file and ensure these variables are set correctly.');
|
||||
} else {
|
||||
// In production, throw errors for missing config
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} else if (import.meta.env.DEV) {
|
||||
console.log('✅ Stripe configuration validated successfully');
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
}
|
||||
|
||||
if (!STRIPE_CONFIG.PUBLISHABLE_KEY) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Missing STRIPE_PUBLISHABLE_KEY environment variable - Stripe functionality will be disabled');
|
||||
}
|
||||
}
|
||||
// Run validation
|
||||
const _isConfigValid = validateStripeConfig();
|
||||
|
||||
// Initialize Stripe instance (server-side only)
|
||||
export const stripe = typeof window === 'undefined' && STRIPE_CONFIG.SECRET_KEY
|
||||
@@ -67,17 +91,21 @@ export function calculatePlatformFee(ticketPrice: number, feeStructure?: FeeStru
|
||||
let fee = 0;
|
||||
|
||||
switch (fees.fee_type) {
|
||||
case 'percentage':
|
||||
case 'percentage': {
|
||||
fee = Math.round(priceInCents * fees.fee_percentage);
|
||||
break;
|
||||
case 'fixed':
|
||||
}
|
||||
case 'fixed': {
|
||||
fee = fees.fee_fixed;
|
||||
break;
|
||||
case 'percentage_plus_fixed':
|
||||
}
|
||||
case 'percentage_plus_fixed': {
|
||||
fee = Math.round(priceInCents * fees.fee_percentage) + fees.fee_fixed;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
fee = Math.round(priceInCents * DEFAULT_FEE_STRUCTURE.fee_percentage) + DEFAULT_FEE_STRUCTURE.fee_fixed;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, fee); // Ensure fee is never negative
|
||||
@@ -93,14 +121,18 @@ export function calculateOrganizerNet(ticketPrice: number, feeStructure?: FeeStr
|
||||
// Format fee structure for display
|
||||
export function formatFeeStructure(feeStructure: FeeStructure): string {
|
||||
switch (feeStructure.fee_type) {
|
||||
case 'percentage':
|
||||
case 'percentage': {
|
||||
return `${(feeStructure.fee_percentage * 100).toFixed(2)}%`;
|
||||
case 'fixed':
|
||||
}
|
||||
case 'fixed': {
|
||||
return `$${(feeStructure.fee_fixed / 100).toFixed(2)}`;
|
||||
case 'percentage_plus_fixed':
|
||||
}
|
||||
case 'percentage_plus_fixed': {
|
||||
return `${(feeStructure.fee_percentage * 100).toFixed(2)}% + $${(feeStructure.fee_fixed / 100).toFixed(2)}`;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
return 'Unknown fee structure';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
src/lib/supabase-admin.ts
Normal file
26
src/lib/supabase-admin.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './database.types';
|
||||
|
||||
// Function to create admin client (lazy initialization)
|
||||
export function createSupabaseAdmin() {
|
||||
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
|
||||
const serviceRoleKey = import.meta.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error('Missing PUBLIC_SUPABASE_URL environment variable');
|
||||
}
|
||||
|
||||
if (!serviceRoleKey) {
|
||||
throw new Error('Missing SUPABASE_SERVICE_KEY environment variable. This is required for admin operations.');
|
||||
}
|
||||
|
||||
return createClient<Database>(supabaseUrl, serviceRoleKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export a function to get the admin client
|
||||
export const getSupabaseAdmin = () => createSupabaseAdmin();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user