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
|
# Database
|
||||||
node setup-schema.js # Initialize database schema (run once)
|
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
|
## Tech Stack
|
||||||
@@ -82,6 +86,14 @@ node setup-schema.js # Initialize database schema (run once)
|
|||||||
- **Platform Fees**: Automatically split from each transaction
|
- **Platform Fees**: Automatically split from each transaction
|
||||||
- **Webhooks**: Payment confirmation and dispute handling
|
- **Webhooks**: Payment confirmation and dispute handling
|
||||||
- **Environment**: Uses publishable/secret key pairs
|
- **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
|
### Design System
|
||||||
- **Theme**: Glassmorphism with dark gradients (see DESIGN_SYSTEM.md)
|
- **Theme**: Glassmorphism with dark gradients (see DESIGN_SYSTEM.md)
|
||||||
@@ -134,9 +146,35 @@ src/
|
|||||||
- **Form State**: Native form handling with progressive enhancement
|
- **Form State**: Native form handling with progressive enhancement
|
||||||
- **Auth State**: Supabase auth context with organization data
|
- **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
|
### API Design
|
||||||
- **RESTful**: Standard HTTP methods with proper status codes
|
- **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
|
- **Error Handling**: Consistent error responses with user-friendly messages
|
||||||
- **Rate Limiting**: Built-in protection against abuse
|
- **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
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
integrations: [
|
integrations: [
|
||||||
react(),
|
react(),
|
||||||
sentry({
|
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": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro check && astro build",
|
"build": "NODE_OPTIONS='--max-old-space-size=8192' astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"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": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@astrojs/node": "^9.3.0",
|
"@astrojs/node": "^9.3.0",
|
||||||
"@astrojs/react": "^4.3.0",
|
"@astrojs/react": "^4.3.0",
|
||||||
"@astrojs/tailwind": "^6.0.2",
|
"@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/astro": "^9.35.0",
|
||||||
"@sentry/node": "^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",
|
"@supabase/supabase-js": "^2.50.3",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
@@ -29,7 +38,9 @@
|
|||||||
"dotenv": "^17.1.0",
|
"dotenv": "^17.1.0",
|
||||||
"node-cron": "^4.2.0",
|
"node-cron": "^4.2.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"ramda": "^0.31.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-contenteditable": "^3.3.7",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-easy-crop": "^5.4.2",
|
"react-easy-crop": "^5.4.2",
|
||||||
"resend": "^4.6.0",
|
"resend": "^4.6.0",
|
||||||
@@ -40,8 +51,12 @@
|
|||||||
"zod": "^3.25.75"
|
"zod": "^3.25.75"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.31.0",
|
||||||
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@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',
|
'001_initial_schema.sql',
|
||||||
'002_add_fee_structure.sql',
|
'002_add_fee_structure.sql',
|
||||||
'003_add_seating_and_ticket_types.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) {
|
for (const migration of migrations) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function setupSuperAdmins() {
|
|||||||
console.log(`User ${email} not found. Creating user record...`);
|
console.log(`User ${email} not found. Creating user record...`);
|
||||||
|
|
||||||
// Create user record (they need to sign up first via Supabase Auth)
|
// 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')
|
.from('users')
|
||||||
.insert({
|
.insert({
|
||||||
email: email,
|
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>
|
</div>
|
||||||
|
|
||||||
<script define:vars={{ minimumAge, onVerified }}>
|
<script define:vars={{ minimumAge, onVerified }}>
|
||||||
|
// Variables passed from define:vars
|
||||||
|
// minimumAge: number
|
||||||
|
// onVerified: string | undefined
|
||||||
|
|
||||||
class AgeVerification {
|
class AgeVerification {
|
||||||
private modal: HTMLElement;
|
modal;
|
||||||
private dateInput: HTMLInputElement;
|
dateInput;
|
||||||
private confirmButton: HTMLButtonElement;
|
confirmButton;
|
||||||
private errorDiv: HTMLElement;
|
errorDiv;
|
||||||
private errorText: HTMLElement;
|
errorText;
|
||||||
private coppaNotice: HTMLElement;
|
coppaNotice;
|
||||||
private isVerified: boolean = false;
|
isVerified = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.modal = document.getElementById('age-verification-modal')!;
|
this.modal = document.getElementById('age-verification-modal');
|
||||||
this.dateInput = document.getElementById('date-of-birth') as HTMLInputElement;
|
this.dateInput = document.getElementById('date-of-birth');
|
||||||
this.confirmButton = document.getElementById('age-verification-confirm') as HTMLButtonElement;
|
this.confirmButton = document.getElementById('age-verification-confirm');
|
||||||
this.errorDiv = document.getElementById('age-verification-error')!;
|
this.errorDiv = document.getElementById('age-verification-error');
|
||||||
this.errorText = document.getElementById('age-verification-error-text')!;
|
this.errorText = document.getElementById('age-verification-error-text');
|
||||||
this.coppaNotice = document.getElementById('coppa-notice')!;
|
this.coppaNotice = document.getElementById('coppa-notice');
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents() {
|
bindEvents() {
|
||||||
// Date input change
|
// Date input change
|
||||||
this.dateInput.addEventListener('change', () => {
|
this.dateInput.addEventListener('change', () => {
|
||||||
this.validateAge();
|
this.validateAge();
|
||||||
@@ -149,7 +153,7 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateAge() {
|
validateAge() {
|
||||||
this.hideError();
|
this.hideError();
|
||||||
this.hideCoppaNotice();
|
this.hideCoppaNotice();
|
||||||
|
|
||||||
@@ -187,7 +191,7 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
|||||||
this.confirmButton.disabled = false;
|
this.confirmButton.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private confirmAge() {
|
confirmAge() {
|
||||||
if (this.confirmButton.disabled) return;
|
if (this.confirmButton.disabled) return;
|
||||||
|
|
||||||
// Mark as verified
|
// Mark as verified
|
||||||
@@ -198,7 +202,7 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
|||||||
sessionStorage.setItem('age_verified_timestamp', Date.now().toString());
|
sessionStorage.setItem('age_verified_timestamp', Date.now().toString());
|
||||||
|
|
||||||
// Call the callback function if provided
|
// Call the callback function if provided
|
||||||
if (typeof window[onVerified] === 'function') {
|
if (typeof onVerified === 'string' && typeof window[onVerified] === 'function') {
|
||||||
window[onVerified]();
|
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.errorText.textContent = message;
|
||||||
this.errorDiv.classList.remove('hidden');
|
this.errorDiv.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideError() {
|
hideError() {
|
||||||
this.errorDiv.classList.add('hidden');
|
this.errorDiv.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
private showCoppaNotice() {
|
showCoppaNotice() {
|
||||||
this.coppaNotice.classList.remove('hidden');
|
this.coppaNotice.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideCoppaNotice() {
|
hideCoppaNotice() {
|
||||||
this.coppaNotice.classList.add('hidden');
|
this.coppaNotice.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
public show() {
|
show() {
|
||||||
// Check if already verified in this session
|
// Check if already verified in this session
|
||||||
const verified = sessionStorage.getItem('age_verified');
|
const verified = sessionStorage.getItem('age_verified');
|
||||||
const timestamp = sessionStorage.getItem('age_verified_timestamp');
|
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);
|
const verificationAge = Date.now() - parseInt(timestamp);
|
||||||
if (verificationAge < 60 * 60 * 1000) { // 1 hour
|
if (verificationAge < 60 * 60 * 1000) { // 1 hour
|
||||||
this.isVerified = true;
|
this.isVerified = true;
|
||||||
if (typeof window[onVerified] === 'function') {
|
if (typeof onVerified === 'string' && typeof window[onVerified] === 'function') {
|
||||||
window[onVerified]();
|
window[onVerified]();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -254,20 +258,20 @@ const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified"
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
public hide() {
|
hide() {
|
||||||
this.modal.style.display = 'none';
|
this.modal.style.display = 'none';
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public isAgeVerified(): boolean {
|
isAgeVerified() {
|
||||||
return this.isVerified;
|
return this.isVerified;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize and expose globally
|
// Initialize and expose globally
|
||||||
const ageVerification = new AgeVerification();
|
const ageVerification = new AgeVerification();
|
||||||
(window as any).ageVerification = ageVerification;
|
window.ageVerification = ageVerification;
|
||||||
(window as any).showAgeVerification = () => ageVerification.show();
|
window.showAgeVerification = () => ageVerification.show();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
setNearbyEvents(nearby);
|
setNearbyEvents(nearby);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error loading location and trending:', error);
|
console.error('Failed to load location and trending data:', _error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -142,17 +142,17 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 justify-between">
|
||||||
<div className="flex items-center space-x-2 md:space-x-4">
|
<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}`}
|
{isMobile ? `${monthNames[currentMonth].slice(0, 3)} ${currentYear}` : `${monthNames[currentMonth]} ${currentYear}`}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={goToToday}
|
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
|
Today
|
||||||
</button>
|
</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 ${
|
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium rounded-l-md border ${
|
||||||
view === 'month'
|
view === 'month'
|
||||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
? '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'}
|
{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 ${
|
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium border-t border-r border-b ${
|
||||||
view === 'week'
|
view === 'week'
|
||||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
? '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'}
|
{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 ${
|
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'
|
view === 'list'
|
||||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
? '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'}
|
{isMobile ? 'L' : 'List'}
|
||||||
@@ -199,7 +199,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
onClick={previousMonth}
|
onClick={previousMonth}
|
||||||
className="p-1 rounded-md hover:bg-gray-100"
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -207,7 +207,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
onClick={nextMonth}
|
onClick={nextMonth}
|
||||||
className="p-1 rounded-md hover:bg-gray-100"
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -221,8 +221,8 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
<div className="p-3 md:p-6">
|
<div className="p-3 md:p-6">
|
||||||
{/* Day Headers */}
|
{/* Day Headers */}
|
||||||
<div className="grid grid-cols-7 gap-px md:gap-1 mb-2">
|
<div className="grid grid-cols-7 gap-px md:gap-1 mb-2">
|
||||||
{(isMobile ? dayNamesShort : dayNames).map((day, index) => (
|
{(isMobile ? dayNamesShort : dayNames).map((day, _index) => (
|
||||||
<div key={day} className="text-center text-xs md:text-sm font-medium text-gray-500 py-2">
|
<div key={day} className="text-center text-xs md:text-sm font-semibold text-gray-700 dark:text-gray-300 py-2">
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -241,12 +241,12 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
className={`aspect-square border rounded-lg p-1 hover:bg-gray-50 ${
|
className={`aspect-square border-2 rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
||||||
isCurrentDay ? 'bg-indigo-50 border-indigo-200' : 'border-gray-200'
|
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 ${
|
<div className={`text-xs md:text-sm font-bold mb-1 ${
|
||||||
isCurrentDay ? 'text-indigo-700' : 'text-gray-900'
|
isCurrentDay ? 'text-indigo-800 dark:text-indigo-200' : 'text-gray-900 dark:text-gray-100'
|
||||||
}`}>
|
}`}>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
@@ -257,7 +257,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
onClick={() => onEventClick?.(event)}
|
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}`}
|
title={`${event.title} at ${event.venue}`}
|
||||||
>
|
>
|
||||||
{isMobile ? event.title.slice(0, 8) + (event.title.length > 8 ? '...' : '') : event.title}
|
{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) && (
|
{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
|
+{dayEvents.length - (isMobile ? 1 : 2)} more
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -290,25 +290,25 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
onClick={() => onEventClick?.(event)}
|
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-1">
|
||||||
<div className="flex items-center space-x-2">
|
<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 && (
|
{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
|
Featured
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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.venue}
|
||||||
{event.distanceMiles && (
|
{event.distanceMiles && (
|
||||||
<span className="ml-2">• {event.distanceMiles.toFixed(1)} miles</span>
|
<span className="ml-2">• {event.distanceMiles.toFixed(1)} miles</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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', {
|
{eventDate.toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -327,9 +327,9 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
{showTrending && trendingEvents.length > 0 && view !== 'list' && (
|
{showTrending && trendingEvents.length > 0 && view !== 'list' && (
|
||||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
<div className="border-t border-gray-200 p-3 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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 && (
|
{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>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<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-1 min-w-0">
|
||||||
<div className="flex items-center space-x-2">
|
<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 && (
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -354,7 +354,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
<span className="ml-2">• {event.distanceMiles.toFixed(1)} mi</span>
|
<span className="ml-2">• {event.distanceMiles.toFixed(1)} mi</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
{event.ticketsSold} tickets sold
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,9 +374,9 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
{showLocationFeatures && nearbyEvents.length > 0 && view !== 'list' && (
|
{showLocationFeatures && nearbyEvents.length > 0 && view !== 'list' && (
|
||||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
<div className="border-t border-gray-200 p-3 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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 && (
|
{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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -407,7 +407,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
{/* Upcoming Events List */}
|
{/* Upcoming Events List */}
|
||||||
{view !== 'list' && (
|
{view !== 'list' && (
|
||||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
<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">
|
<div className="space-y-2">
|
||||||
{events
|
{events
|
||||||
.filter(event => new Date(event.start_time) >= today)
|
.filter(event => new Date(event.start_time) >= today)
|
||||||
@@ -419,11 +419,11 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
onClick={() => onEventClick?.(event)}
|
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="flex-1 min-w-0">
|
||||||
<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>
|
||||||
<div className="text-xs text-gray-500 truncate">{event.venue}</div>
|
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium truncate">{event.venue}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 text-right ml-2">
|
<div className="text-xs text-gray-500 text-right ml-2">
|
||||||
{eventDate.toLocaleDateString('en-US', {
|
{eventDate.toLocaleDateString('en-US', {
|
||||||
@@ -439,7 +439,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{events.filter(event => new Date(event.start_time) >= today).length === 0 && (
|
{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
|
No upcoming events
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -451,7 +451,7 @@ const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationF
|
|||||||
<div className="border-t border-gray-200 p-6">
|
<div className="border-t border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ const ChatWidget: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setMessages(prev => [...prev, assistantMessage]);
|
setMessages(prev => [...prev, assistantMessage]);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error sending message:', error);
|
console.error('Chat API error:', _error);
|
||||||
const errorMessage: Message = {
|
const errorMessage: Message = {
|
||||||
id: (Date.now() + 1).toString(),
|
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.',
|
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">
|
<section class="relative py-16 lg:py-24 overflow-hidden">
|
||||||
<!-- Background gradients -->
|
<!-- 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" style="background: var(--bg-gradient);"></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: linear-gradient(to right, var(--bg-orb-2), var(--bg-orb-1), var(--bg-orb-2));"></div>
|
||||||
|
|
||||||
<!-- Glassmorphism overlay -->
|
<!-- 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">
|
<div class="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-16">
|
<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">
|
<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-blue-400 text-sm font-medium">Built by Event Professionals</span>
|
<span class="text-sm font-medium" style="color: var(--glass-text-accent);">Built by Event Professionals</span>
|
||||||
</div>
|
</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
|
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
|
The Other Guys
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</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.
|
Built by people who've actually run gates — not just coded them.
|
||||||
Experience real ticketing without the headaches.
|
Experience real ticketing without the headaches.
|
||||||
</p>
|
</p>
|
||||||
@@ -36,21 +36,21 @@
|
|||||||
<!-- Built from Experience -->
|
<!-- Built from Experience -->
|
||||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||||
<span class="text-gray-300 text-sm">Created by actual event professionals who've worked ticket gates</span>
|
<span class="text-sm" style="color: var(--glass-text-secondary);">Created by actual event professionals who've worked ticket gates</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||||
<span class="text-gray-400 text-sm">Built by disconnected tech teams who've never run an event</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,21 +58,21 @@
|
|||||||
<!-- Faster Payouts -->
|
<!-- Faster Payouts -->
|
||||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||||
<span class="text-gray-300 text-sm">Stripe deposits go straight to you — no delays or fund holds</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>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||||
<span class="text-gray-400 text-sm">Hold your money for days or weeks before releasing funds</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,21 +80,21 @@
|
|||||||
<!-- Transparent Fees -->
|
<!-- Transparent Fees -->
|
||||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
<span class="font-bold text-sm" style="color: var(--success-color);">✅ 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="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>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||||
<span class="text-gray-400 text-sm">Hidden platform fees, surprise charges, and confusing pricing</span>
|
<span class="text-sm" style="color: var(--glass-text-tertiary);">Hidden platform fees, surprise charges, and confusing pricing</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,22 +102,22 @@
|
|||||||
<!-- Modern Platform -->
|
<!-- Modern Platform -->
|
||||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="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>
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||||
<span class="text-gray-300 text-sm">Custom-built from scratch based on real-world event needs</span>
|
<span class="text-sm" style="color: var(--glass-text-secondary);">Custom-built from scratch based on real-world event needs</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||||
<span class="text-gray-400 text-sm">Bloated, recycled platforms with outdated interfaces</span>
|
<span class="text-sm" style="color: var(--glass-text-tertiary);">Bloated, recycled platforms with outdated interfaces</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,21 +125,21 @@
|
|||||||
<!-- Hands-On Support -->
|
<!-- Hands-On Support -->
|
||||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||||
<span class="text-gray-300 text-sm">Real humans help you before and during your event</span>
|
<span class="text-sm" style="color: var(--glass-text-secondary);">Real humans help you before and during your event</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||||
<span class="text-gray-400 text-sm">Outsourced support desks and endless ticket systems</span>
|
<span class="text-sm" style="color: var(--glass-text-tertiary);">Outsourced support desks and endless ticket systems</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,21 +147,21 @@
|
|||||||
<!-- Performance & Reliability -->
|
<!-- Performance & Reliability -->
|
||||||
<div class="glassmorphism p-6 lg:p-8 rounded-2xl hover:scale-105 transition-all duration-300">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div class="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: var(--success-bg);">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-green-400 font-bold text-sm">✅ US:</span>
|
<span class="font-bold text-sm" style="color: var(--success-color);">✅ US:</span>
|
||||||
<span class="text-gray-300 text-sm">Built for upscale events with enterprise-grade performance</span>
|
<span class="text-sm" style="color: var(--glass-text-secondary);">Built for upscale events with enterprise-grade performance</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-red-400 font-bold text-sm">❌ THEM:</span>
|
<span class="font-bold text-sm" style="color: var(--error-color);">❌ THEM:</span>
|
||||||
<span class="text-gray-400 text-sm">Crashes during sales rushes when you need them most</span>
|
<span class="text-sm" style="color: var(--glass-text-tertiary);">Crashes during sales rushes when you need them most</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,17 +172,17 @@
|
|||||||
<!-- Call to Action -->
|
<!-- Call to Action -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="inline-flex flex-col sm:flex-row gap-4">
|
<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>
|
<span>Switch to Black Canyon</span>
|
||||||
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</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
|
Compare Fees
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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.
|
Ready to experience real ticketing? Join event professionals who've made the switch.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,10 +191,10 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.glassmorphism {
|
.glassmorphism {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid var(--glass-border);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px var(--glass-shadow-lg);
|
||||||
}
|
}
|
||||||
</style>
|
</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;
|
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="backdrop-blur-xl rounded-3xl shadow-2xl mb-8 overflow-hidden ring-1 transition-all duration-200 hover:shadow-3xl"
|
||||||
<div class="px-8 py-12 text-white">
|
style="background: var(--glass-bg-lg); border: 1px solid var(--glass-border-subtle); ring-color: var(--glass-ring-dark);"
|
||||||
<div class="flex justify-between items-start">
|
data-theme-card="true">
|
||||||
<div class="flex-1">
|
<div class="px-8 py-12" style="color: var(--glass-text-primary);">
|
||||||
<h1 id="event-title" class="text-4xl font-light mb-3 tracking-wide">Loading...</h1>
|
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center">
|
||||||
<div class="flex items-center space-x-6 text-slate-200 mb-4">
|
<div class="flex-1 mb-6 lg:mb-0">
|
||||||
<div class="flex items-center space-x-2">
|
<h1 id="event-title" class="text-3xl font-light mb-2 tracking-wide" style="color: var(--glass-text-primary);">Loading...</h1>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="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>
|
<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>
|
</svg>
|
||||||
<span id="event-venue">--</span>
|
<span id="event-venue">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center gap-1">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
<span id="event-date">--</span>
|
<span id="event-date">--</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<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
|
<a
|
||||||
id="preview-link"
|
id="preview-link"
|
||||||
href="#"
|
href="#"
|
||||||
target="_blank"
|
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="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>
|
<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>
|
</svg>
|
||||||
Preview Page
|
Preview
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
id="embed-code-btn"
|
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>
|
<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>
|
</svg>
|
||||||
Get Embed Code
|
Embed
|
||||||
</button>
|
</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
|
<a
|
||||||
href="/scan"
|
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>
|
<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>
|
</svg>
|
||||||
Scanner
|
Scanner
|
||||||
</a>
|
</a>
|
||||||
<button
|
<a
|
||||||
id="edit-event-btn"
|
id="kiosk-link"
|
||||||
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"
|
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">
|
<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>
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,50 +126,141 @@ const { eventId } = Astro.props;
|
|||||||
|
|
||||||
async function loadEventHeader() {
|
async function loadEventHeader() {
|
||||||
try {
|
try {
|
||||||
const { createClient } = await import('@supabase/supabase-js');
|
const { api } = await import('/src/lib/api-router.js');
|
||||||
const supabase = createClient(
|
|
||||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
|
||||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load event data
|
// Load event details and stats using the new API system
|
||||||
const { data: event, error } = await supabase
|
const result = await api.loadEventPage(eventId);
|
||||||
.from('events')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', eventId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (!result.event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update UI
|
// Update event details
|
||||||
document.getElementById('event-title').textContent = event.title;
|
document.getElementById('event-title').textContent = result.event.title;
|
||||||
document.getElementById('event-venue').textContent = event.venue;
|
document.getElementById('event-venue').textContent = result.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}`;
|
|
||||||
|
|
||||||
// Load stats
|
// Use start_time from database
|
||||||
const { data: tickets } = await supabase
|
document.getElementById('event-date').textContent = api.formatDate(result.event.start_time);
|
||||||
.from('tickets')
|
|
||||||
.select('price_paid')
|
|
||||||
.eq('event_id', eventId)
|
|
||||||
.eq('status', 'confirmed');
|
|
||||||
|
|
||||||
const totalRevenue = tickets?.reduce((sum, ticket) => sum + ticket.price_paid, 0) || 0;
|
// Handle description truncation
|
||||||
document.getElementById('total-revenue').textContent = new Intl.NumberFormat('en-US', {
|
const descriptionEl = document.getElementById('event-description');
|
||||||
style: 'currency',
|
const toggleBtn = document.getElementById('description-toggle');
|
||||||
currency: 'USD'
|
const fullDescription = result.event.description;
|
||||||
}).format(totalRevenue / 100);
|
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) {
|
} 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>
|
</script>
|
||||||
@@ -1,127 +1,189 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import TabNavigation from './manage/TabNavigation';
|
import TabNavigation from './manage/TabNavigation';
|
||||||
import TicketsTab from './manage/TicketsTab';
|
import TicketingAccessTab from './manage/TicketingAccessTab';
|
||||||
import VenueTab from './manage/VenueTab';
|
|
||||||
import OrdersTab from './manage/OrdersTab';
|
import OrdersTab from './manage/OrdersTab';
|
||||||
import AttendeesTab from './manage/AttendeesTab';
|
import AttendeesTab from './manage/AttendeesTab';
|
||||||
import PresaleTab from './manage/PresaleTab';
|
import EventSettingsTab from './manage/EventSettingsTab';
|
||||||
import DiscountTab from './manage/DiscountTab';
|
import CustomPageTab from './manage/CustomPageTab';
|
||||||
import AddonsTab from './manage/AddonsTab';
|
import { api } from '../lib/api-router.js';
|
||||||
import PrintedTab from './manage/PrintedTab';
|
import type { EventData } from '../lib/event-management.js';
|
||||||
import SettingsTab from './manage/SettingsTab';
|
|
||||||
import MarketingTab from './manage/MarketingTab';
|
|
||||||
import PromotionsTab from './manage/PromotionsTab';
|
|
||||||
import EmbedCodeModal from './modals/EmbedCodeModal';
|
|
||||||
|
|
||||||
interface EventManagementProps {
|
interface EventManagementProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
organizationId: string;
|
organizationId?: string;
|
||||||
eventSlug: string;
|
eventSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventManagement({ eventId, organizationId, eventSlug }: EventManagementProps) {
|
export default function EventManagement({ eventId, _organizationId, eventSlug }: EventManagementProps) {
|
||||||
const [activeTab, setActiveTab] = useState('tickets');
|
const [activeTab, setActiveTab] = useState('ticketing');
|
||||||
const [showEmbedModal, setShowEmbedModal] = useState(false);
|
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 = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: 'tickets',
|
id: 'ticketing',
|
||||||
name: 'Tickets & Pricing',
|
name: 'Ticketing & Access',
|
||||||
icon: '🎫',
|
icon: (
|
||||||
component: TicketsTab
|
<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',
|
id: 'custom-pages',
|
||||||
name: 'Venue & Seating',
|
name: 'Custom Pages',
|
||||||
icon: '🏛️',
|
icon: (
|
||||||
component: VenueTab
|
<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',
|
id: 'sales',
|
||||||
name: 'Orders & Sales',
|
name: 'Sales',
|
||||||
icon: '📊',
|
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
|
component: OrdersTab
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'attendees',
|
id: 'attendees',
|
||||||
name: 'Attendees & Check-in',
|
name: 'Attendees',
|
||||||
icon: '👥',
|
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
|
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',
|
id: 'settings',
|
||||||
name: 'Event Settings',
|
name: 'Event Settings',
|
||||||
icon: '⚙️',
|
icon: (
|
||||||
component: SettingsTab
|
<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" />
|
||||||
id: 'marketing',
|
</svg>
|
||||||
name: 'Marketing Kit',
|
),
|
||||||
icon: '📈',
|
component: EventSettingsTab
|
||||||
component: MarketingTab
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'promotions',
|
|
||||||
name: 'Promotions',
|
|
||||||
icon: '🎯',
|
|
||||||
component: PromotionsTab
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set up embed code button listener
|
// Check authentication and load data
|
||||||
const embedBtn = document.getElementById('embed-code-btn');
|
const initializeComponent = async () => {
|
||||||
if (embedBtn) {
|
try {
|
||||||
embedBtn.addEventListener('click', () => setShowEmbedModal(true));
|
setLoading(true);
|
||||||
}
|
setError(null);
|
||||||
|
|
||||||
return () => {
|
// Check authentication status
|
||||||
if (embedBtn) {
|
const authStatus = await api.checkAuth();
|
||||||
embedBtn.removeEventListener('click', () => setShowEmbedModal(true));
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<TabNavigation
|
||||||
<TabNavigation
|
tabs={tabs}
|
||||||
tabs={tabs}
|
activeTab={activeTab}
|
||||||
activeTab={activeTab}
|
onTabChange={setActiveTab}
|
||||||
onTabChange={setActiveTab}
|
eventId={eventId}
|
||||||
eventId={eventId}
|
organizationId={userOrganizationId || ''}
|
||||||
organizationId={organizationId}
|
eventData={eventData}
|
||||||
/>
|
eventSlug={actualEventSlug}
|
||||||
|
/>
|
||||||
<EmbedCodeModal
|
|
||||||
isOpen={showEmbedModal}
|
|
||||||
onClose={() => setShowEmbedModal(false)}
|
|
||||||
eventId={eventId}
|
|
||||||
eventSlug={eventSlug}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ interface ImageUploadCropperProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CropData {
|
interface _CropData {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -108,7 +108,7 @@ export default function ImageUploadCropper({
|
|||||||
setCroppedAreaPixels(croppedAreaPixels);
|
setCroppedAreaPixels(croppedAreaPixels);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts and scroll locking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if (showCropper && e.key === 'Escape' && !isUploading) {
|
if (showCropper && e.key === 'Escape' && !isUploading) {
|
||||||
@@ -117,8 +117,15 @@ export default function ImageUploadCropper({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (showCropper) {
|
if (showCropper) {
|
||||||
|
// Lock body scroll when modal is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
document.addEventListener('keydown', handleKeydown);
|
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]);
|
}, [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`);
|
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: _dataUrl } = await getCroppedImg(imageSrc, croppedAreaPixels, originalFileName);
|
||||||
const { file, dataUrl } = await getCroppedImg(imageSrc, croppedAreaPixels, originalFileName);
|
|
||||||
console.log('Cropped image created, size:', file.size, 'bytes');
|
|
||||||
|
|
||||||
// Validate final file size
|
// Validate final file size
|
||||||
if (file.size > MAX_FINAL_SIZE) {
|
if (file.size > MAX_FINAL_SIZE) {
|
||||||
@@ -184,7 +189,6 @@ export default function ImageUploadCropper({
|
|||||||
throw new Error('Authentication required. Please sign in again.');
|
throw new Error('Authentication required. Please sign in again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Uploading to server...');
|
|
||||||
const response = await fetch('/api/upload-event-image', {
|
const response = await fetch('/api/upload-event-image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -193,15 +197,12 @@ export default function ImageUploadCropper({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Upload response status:', response.status);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({ error: 'Upload failed' }));
|
const errorData = await response.json().catch(() => ({ error: 'Upload failed' }));
|
||||||
throw new Error(errorData.error || `Upload failed with status ${response.status}`);
|
throw new Error(errorData.error || `Upload failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { imageUrl } = await response.json();
|
const { imageUrl } = await response.json();
|
||||||
console.log('Upload successful, image URL:', imageUrl);
|
|
||||||
|
|
||||||
onImageChange(imageUrl);
|
onImageChange(imageUrl);
|
||||||
setShowCropper(false);
|
setShowCropper(false);
|
||||||
@@ -211,7 +212,6 @@ export default function ImageUploadCropper({
|
|||||||
setCroppedAreaPixels(null);
|
setCroppedAreaPixels(null);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
|
||||||
setError(error instanceof Error ? error.message : 'An error occurred');
|
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
@@ -260,7 +260,7 @@ export default function ImageUploadCropper({
|
|||||||
|
|
||||||
{/* File Input */}
|
{/* File Input */}
|
||||||
{!currentImageUrl && !showCropper && (
|
{!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
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -273,11 +273,11 @@ export default function ImageUploadCropper({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={disabled}
|
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
|
Upload Image
|
||||||
</button>
|
</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
|
JPG, PNG, or WebP • Max 10MB • Recommended: 1200×628px
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +289,7 @@ export default function ImageUploadCropper({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={disabled}
|
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
|
Replace Image
|
||||||
</button>
|
</button>
|
||||||
@@ -297,8 +297,8 @@ export default function ImageUploadCropper({
|
|||||||
|
|
||||||
{/* Cropper Modal */}
|
{/* Cropper Modal */}
|
||||||
{showCropper && (
|
{showCropper && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/90 backdrop-blur-xl 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="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">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-semibold text-white">Crop Your Image</h3>
|
<h3 className="text-lg font-semibold text-white">Crop Your Image</h3>
|
||||||
<button
|
<button
|
||||||
@@ -314,7 +314,7 @@ export default function ImageUploadCropper({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{imageSrc && (
|
||||||
<Cropper
|
<Cropper
|
||||||
image={imageSrc}
|
image={imageSrc}
|
||||||
@@ -332,7 +332,7 @@ export default function ImageUploadCropper({
|
|||||||
|
|
||||||
{/* Zoom Control */}
|
{/* Zoom Control */}
|
||||||
<div className="mt-4">
|
<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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -340,7 +340,7 @@ export default function ImageUploadCropper({
|
|||||||
step={0.1}
|
step={0.1}
|
||||||
value={zoom}
|
value={zoom}
|
||||||
onChange={(e) => setZoom(Number(e.target.value))}
|
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}
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,7 +351,7 @@ export default function ImageUploadCropper({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleCropCancel}
|
onClick={handleCropCancel}
|
||||||
disabled={isUploading}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -359,7 +359,7 @@ export default function ImageUploadCropper({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleCropSave}
|
onClick={handleCropSave}
|
||||||
disabled={isUploading || !croppedAreaPixels}
|
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'}
|
{isUploading ? 'Uploading...' : 'Save & Upload'}
|
||||||
</button>
|
</button>
|
||||||
@@ -370,7 +370,7 @@ export default function ImageUploadCropper({
|
|||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,46 +12,262 @@ const {
|
|||||||
backLinkUrl = "/dashboard",
|
backLinkUrl = "/dashboard",
|
||||||
backLinkText = "← Back"
|
backLinkText = "← Back"
|
||||||
} = Astro.props;
|
} = 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 -->
|
<!-- White Navigation Bar - Consistent across all pages -->
|
||||||
<nav class="sticky top-0 z-50 bg-white/90 backdrop-blur-lg shadow-xl border-b border-slate-200/50">
|
<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="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">
|
<div class="flex items-center space-x-8">
|
||||||
<a href="/dashboard" class="flex items-center">
|
<!-- Brand -->
|
||||||
<span class="text-xl font-light text-gray-900">
|
<a href="/dashboard" class="flex items-center space-x-3 group">
|
||||||
<span class="font-bold">P</span>ortal
|
<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">
|
||||||
</span>
|
<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>
|
</a>
|
||||||
|
|
||||||
|
<!-- Navigation Items -->
|
||||||
<div class="hidden md:flex items-center space-x-6">
|
<div class="hidden md:flex items-center space-x-6">
|
||||||
{showBackLink && (
|
{showBackLink && (
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<a
|
<a
|
||||||
href={backLinkUrl}
|
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>
|
</a>
|
||||||
<span class="text-slate-400">|</span>
|
<span class="text-gray-300">|</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span class="text-slate-900 font-semibold">{title}</span>
|
<span class="font-semibold text-gray-900">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-3">
|
||||||
<a
|
{isAuthPage && !shouldHideThemeToggle && (
|
||||||
id="admin-dashboard-link"
|
<div class="mr-1">
|
||||||
href="/admin/dashboard"
|
<ThemeToggle client:load />
|
||||||
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"
|
</div>
|
||||||
>
|
)}
|
||||||
Admin Dashboard
|
|
||||||
|
<!-- 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>
|
</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
|
<button
|
||||||
id="logout-btn"
|
id="mobile-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"
|
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
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,9 +279,23 @@ const {
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
// Initialize navigation functionality
|
// 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 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
|
// Check authentication and load user info
|
||||||
async function initializeNavigation() {
|
async function initializeNavigation() {
|
||||||
@@ -78,9 +308,22 @@ const {
|
|||||||
// Load user info
|
// Load user info
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (user) {
|
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
|
const { data: userProfile } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('role')
|
.select('role')
|
||||||
@@ -88,17 +331,82 @@ const {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (userProfile?.role === 'admin') {
|
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
|
// Logout functionality
|
||||||
logoutBtn?.addEventListener('click', async () => {
|
const handleLogout = async () => {
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
window.location.href = '/';
|
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
|
// Initialize when the page loads
|
||||||
initializeNavigation();
|
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>
|
</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
|
// Simple protected route component
|
||||||
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('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
requireAdmin?: boolean;
|
requireAdmin?: boolean;
|
||||||
@@ -33,51 +8,132 @@ export interface Props {
|
|||||||
const { title = "Protected Page", requireAdmin = false } = Astro.props;
|
const { title = "Protected Page", requireAdmin = false } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<div class="auth-wrapper">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
// Client-side auth verification as backup
|
// State tracking to prevent loops
|
||||||
async function verifyAuth() {
|
let authVerificationInProgress = false;
|
||||||
const { data: { session }, error } = await supabase.auth.getSession();
|
let redirectInProgress = false;
|
||||||
|
|
||||||
if (error || !session) {
|
console.log('[PROTECTED] ProtectedRoute mounted on:', window.location.pathname);
|
||||||
console.warn('Authentication verification failed');
|
|
||||||
window.location.pathname = '/';
|
// Safe redirect with loop prevention
|
||||||
|
function safeRedirectToLogin() {
|
||||||
|
if (redirectInProgress) {
|
||||||
|
console.log('[PROTECTED] Redirect already in progress, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store auth token for API calls
|
// Don't redirect if we're already on login page
|
||||||
const authToken = session.access_token;
|
if (window.location.pathname === '/login') {
|
||||||
if (authToken) {
|
console.log('[PROTECTED] Already on login page, not redirecting');
|
||||||
// Set default authorization header for fetch requests
|
return;
|
||||||
const originalFetch = window.fetch;
|
}
|
||||||
window.fetch = function(url, options = {}) {
|
|
||||||
if (!options.headers) {
|
|
||||||
options.headers = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add auth header to API calls
|
redirectInProgress = true;
|
||||||
if (typeof url === 'string' && url.startsWith('/api/')) {
|
console.log('[PROTECTED] Redirecting to login...');
|
||||||
options.headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Delayed auth verification to prevent race conditions
|
||||||
verifyAuth();
|
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) => {
|
supabase.auth.onAuthStateChange((event, session) => {
|
||||||
if (event === 'SIGNED_OUT' || !session) {
|
console.log('[PROTECTED] Auth state change:', event);
|
||||||
window.location.pathname = '/';
|
|
||||||
|
// 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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.auth-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add loading state styles */
|
/* Add loading state styles */
|
||||||
.auth-loading {
|
.auth-loading {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -7,38 +7,36 @@ export interface Props {
|
|||||||
const { showCalendarNav = false } = Astro.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="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">
|
||||||
<!-- Logo and Branding -->
|
<!-- Logo and Branding -->
|
||||||
<div class="flex items-center space-x-8">
|
<div class="flex items-center space-x-8">
|
||||||
<a href="/" class="flex items-center space-x-2">
|
<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));" />
|
<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 class="font-bold">Black Canyon</span> Tickets
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Clean Navigation -->
|
<!-- Clean Navigation -->
|
||||||
{showCalendarNav && (
|
{showCalendarNav && (
|
||||||
<nav class="hidden md:flex items-center space-x-1">
|
<nav class="hidden md:flex items-center space-x-6">
|
||||||
<div class="flex items-center space-x-1 bg-slate-50 rounded-xl p-1">
|
<a href="/calendar" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-primary);">
|
||||||
<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
|
||||||
All Events
|
</a>
|
||||||
</a>
|
<a href="/calendar?featured=true" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||||
<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
|
||||||
Featured
|
</a>
|
||||||
</a>
|
<a href="/calendar?category=music" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||||
<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
|
||||||
Music
|
</a>
|
||||||
</a>
|
<a href="/calendar?category=arts" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||||
<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
|
||||||
Arts
|
</a>
|
||||||
</a>
|
<a href="/calendar?category=community" class="text-sm font-medium transition-all duration-200 hover:scale-105" style="color: var(--glass-text-secondary);">
|
||||||
<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
|
||||||
Community
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +46,8 @@ const { showCalendarNav = false } = Astro.props;
|
|||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
{showCalendarNav && (
|
{showCalendarNav && (
|
||||||
<button
|
<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()"
|
onclick="toggleMobileMenu()"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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 -->
|
<!-- 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
|
Login
|
||||||
</a>
|
</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
|
Create Events
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,28 +68,28 @@ const { showCalendarNav = false } = Astro.props;
|
|||||||
|
|
||||||
<!-- Clean Mobile Navigation -->
|
<!-- Clean Mobile Navigation -->
|
||||||
{showCalendarNav && (
|
{showCalendarNav && (
|
||||||
<div id="mobile-menu" class="hidden md:hidden border-t border-slate-200 py-4">
|
<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-2">
|
<div class="grid grid-cols-1 gap-3">
|
||||||
<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">
|
<a href="/calendar" class="px-4 py-3 font-medium transition-all duration-200" style="color: var(--glass-text-primary);">
|
||||||
All Events
|
All Events
|
||||||
</a>
|
</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
|
Featured Events
|
||||||
</a>
|
</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
|
Music
|
||||||
</a>
|
</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
|
Arts
|
||||||
</a>
|
</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
|
Community
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Login -->
|
<!-- Mobile Login -->
|
||||||
<div class="mt-4 pt-4 border-t border-slate-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 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
<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
|
Organizer Login
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,57 +6,113 @@ interface Props {
|
|||||||
const { eventId } = Astro.props;
|
const { eventId } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg: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">
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Tickets Sold</p>
|
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--success-color);">Tickets Sold</p>
|
||||||
<p id="tickets-sold" class="text-3xl font-light text-white mt-1">0</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>
|
||||||
<div class="w-12 h-12 bg-emerald-500/20 rounded-xl flex items-center justify-center">
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
<svg class="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Available</p>
|
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--glass-text-secondary);">Available</p>
|
||||||
<p id="tickets-available" class="text-3xl font-light text-white mt-1">--</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>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Check-ins</p>
|
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--warning-color);">Check-ins</p>
|
||||||
<p id="checked-in" class="text-3xl font-light text-white mt-1">0</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>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center">
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wide">Net Revenue</p>
|
<p class="text-sm font-semibold uppercase tracking-wide" style="color: var(--glass-text-accent);">Net Revenue</p>
|
||||||
<p id="net-revenue" class="text-3xl font-light text-white mt-1">$0</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>
|
||||||
<div class="w-12 h-12 bg-amber-500/20 rounded-xl flex items-center justify-center">
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
<svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,55 +126,114 @@ const { eventId } = Astro.props;
|
|||||||
await loadQuickStats();
|
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() {
|
async function loadQuickStats() {
|
||||||
try {
|
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
|
// Show skeleton loading states
|
||||||
const { data: tickets } = await supabase
|
showSkeleton('tickets-sold');
|
||||||
.from('tickets')
|
showSkeleton('tickets-available');
|
||||||
.select(`
|
showSkeleton('checked-in');
|
||||||
id,
|
showSkeleton('net-revenue');
|
||||||
price_paid,
|
|
||||||
checked_in,
|
|
||||||
ticket_types (
|
|
||||||
id,
|
|
||||||
quantity
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.eq('event_id', eventId)
|
|
||||||
.eq('status', 'confirmed');
|
|
||||||
|
|
||||||
// Load ticket types for capacity calculation
|
const { api } = await import('/src/lib/api-router.js');
|
||||||
const { data: ticketTypes } = await supabase
|
|
||||||
.from('ticket_types')
|
|
||||||
.select('id, quantity')
|
|
||||||
.eq('event_id', eventId)
|
|
||||||
.eq('is_active', true);
|
|
||||||
|
|
||||||
// Calculate stats
|
// Load event statistics using the new API system
|
||||||
const ticketsSold = tickets?.length || 0;
|
const stats = await api.loadEventStats(eventId);
|
||||||
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;
|
|
||||||
|
|
||||||
// Update UI
|
if (!stats) {
|
||||||
document.getElementById('tickets-sold').textContent = ticketsSold.toString();
|
return;
|
||||||
document.getElementById('tickets-available').textContent = ticketsAvailable.toString();
|
}
|
||||||
document.getElementById('checked-in').textContent = checkedIn.toString();
|
|
||||||
document.getElementById('net-revenue').textContent = new Intl.NumberFormat('en-US', {
|
// Hide skeleton loading and animate values
|
||||||
style: 'currency',
|
setTimeout(() => {
|
||||||
currency: 'USD'
|
hideSkeleton('tickets-sold');
|
||||||
}).format(netRevenue / 100);
|
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) {
|
} 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>
|
</script>
|
||||||
@@ -66,8 +66,8 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
if (activeTicketTypes.length > 0) {
|
if (activeTicketTypes.length > 0) {
|
||||||
setSelectedTicketType(activeTicketTypes[0].id);
|
setSelectedTicketType(activeTicketTypes[0].id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.error('Error loading ticket types:', err);
|
console.error('Failed to load ticket options:', _err);
|
||||||
setError('Failed to load ticket options');
|
setError('Failed to load ticket options');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -109,14 +109,17 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
const availableQuantity = selectedTicket ? getAvailableQuantity(selectedTicket) : 0;
|
const availableQuantity = selectedTicket ? getAvailableQuantity(selectedTicket) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 ${className}`}>
|
<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="bg-white rounded-lg shadow-xl max-w-md w-full max-h-screen overflow-y-auto">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6" style={{borderBottom: '1px solid var(--ui-border-secondary)'}}>
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Quick Purchase</h2>
|
<h2 className="text-xl font-semibold" style={{color: 'var(--ui-text-primary)'}}>Quick Purchase</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -125,9 +128,9 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Info */}
|
{/* Event Info */}
|
||||||
<div className="p-6 border-b bg-gray-50">
|
<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 text-gray-900 mb-2">{event.title}</h3>
|
<h3 className="text-lg font-medium mb-2" style={{color: 'var(--ui-text-primary)'}}>{event.title}</h3>
|
||||||
<div className="space-y-1 text-sm text-gray-600">
|
<div className="space-y-1 text-sm" style={{color: 'var(--ui-text-secondary)'}}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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">
|
<div className="p-6">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="text-center py-8">
|
<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>
|
<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 text-gray-600">Loading ticket options...</p>
|
<p className="mt-2" style={{color: 'var(--ui-text-secondary)'}}>Loading ticket options...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-red-800">{error}</span>
|
<span style={{color: 'var(--error-color)'}}>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -168,11 +171,11 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
<>
|
<>
|
||||||
{ticketTypes.length === 0 ? (
|
{ticketTypes.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No tickets available</h3>
|
<h3 className="mt-2 text-sm font-medium" style={{color: 'var(--ui-text-primary)'}}>No tickets available</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<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.
|
This event is currently sold out or tickets are not yet on sale.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +183,7 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* Ticket Type Selection */}
|
{/* Ticket Type Selection */}
|
||||||
<div className="mb-6">
|
<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
|
Select Ticket Type
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -191,13 +194,31 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ticketType.id}
|
key={ticketType.id}
|
||||||
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
|
className="border rounded-lg p-3 cursor-pointer transition-colors backdrop-blur-lg"
|
||||||
selectedTicketType === ticketType.id
|
style={{
|
||||||
? 'border-indigo-500 bg-indigo-50'
|
borderColor: selectedTicketType === ticketType.id
|
||||||
|
? 'var(--glass-border-focus)'
|
||||||
: isUnavailable
|
: isUnavailable
|
||||||
? 'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed'
|
? 'var(--ui-border-secondary)'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: '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)}
|
onClick={() => !isUnavailable && setSelectedTicketType(ticketType.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -210,17 +231,17 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
className="mr-3"
|
className="mr-3"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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 && (
|
{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>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-semibold text-gray-900">
|
<div className="font-semibold" style={{color: 'var(--ui-text-primary)'}}>
|
||||||
{formatPrice(ticketType.price)}
|
{formatPrice(ticketType.price)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm" style={{color: 'var(--ui-text-tertiary)'}}>
|
||||||
{isUnavailable ? 'Sold Out' :
|
{isUnavailable ? 'Sold Out' :
|
||||||
available < 10 ? `${available} left` : 'Available'}
|
available < 10 ? `${available} left` : 'Available'}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,31 +256,59 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
{/* Quantity Selection */}
|
{/* Quantity Selection */}
|
||||||
{selectedTicket && (
|
{selectedTicket && (
|
||||||
<div className="mb-6">
|
<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
|
Quantity
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||||
disabled={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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setQuantity(Math.min(availableQuantity, quantity + 1))}
|
onClick={() => setQuantity(Math.min(availableQuantity, quantity + 1))}
|
||||||
disabled={quantity >= availableQuantity}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
Max {availableQuantity} tickets available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,14 +316,14 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
|
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
{selectedTicket && (
|
{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">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-medium text-gray-900">Total</span>
|
<span className="font-medium" style={{color: 'var(--ui-text-primary)'}}>Total</span>
|
||||||
<span className="text-xl font-bold text-gray-900">
|
<span className="text-xl font-bold" style={{color: 'var(--ui-text-primary)'}}>
|
||||||
{formatPrice(totalPrice)}
|
{formatPrice(totalPrice)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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)}
|
{quantity} × {selectedTicket.name} @ {formatPrice(selectedTicket.price)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,17 +336,38 @@ const QuickTicketPurchase: React.FC<QuickTicketPurchaseProps> = ({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{!isLoading && !error && ticketTypes.length > 0 && (
|
{!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
|
<button
|
||||||
onClick={onClose}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePurchase}
|
onClick={handlePurchase}
|
||||||
disabled={!selectedTicketType || availableQuantity <= 0}
|
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
|
Continue to Checkout
|
||||||
</button>
|
</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 { inventoryManager } from '../lib/inventory';
|
||||||
import { calculateFeeBreakdown } from '../lib/stripe';
|
import { calculateFeeBreakdown } from '../lib/stripe';
|
||||||
import {
|
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 {
|
interface Props {
|
||||||
event: EventData;
|
event: EventData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TicketCheckout({ event }: Props) {
|
export default function TicketCheckout({ event }: Props) {
|
||||||
const [selectedTickets, setSelectedTickets] = useState<Map<string, any>>(new Map());
|
const [selectedTickets, setSelectedTickets] = useState<Map<string, SelectedTicket>>(new Map());
|
||||||
const [currentReservations, setCurrentReservations] = useState<Map<string, any>>(new Map());
|
const [currentReservations, setCurrentReservations] = useState<Map<string, Reservation>>(new Map());
|
||||||
const [availability, setAvailability] = useState<Map<string, AvailabilityInfo>>(new Map());
|
const [availability, setAvailability] = useState<Map<string, AvailabilityInfo>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||||
@@ -57,7 +80,7 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [presaleCode, setPresaleCode] = useState('');
|
const [presaleCode, setPresaleCode] = useState('');
|
||||||
const [presaleCodeValidated, setPresaleCodeValidated] = useState(false);
|
const [presaleCodeValidated, setPresaleCodeValidated] = useState(false);
|
||||||
const [presaleCodeData, setPresaleCodeData] = useState<any>(null);
|
const [presaleCodeData, setPresaleCodeData] = useState<PresaleCodeData | null>(null);
|
||||||
const [presaleCodeError, setPresaleCodeError] = useState('');
|
const [presaleCodeError, setPresaleCodeError] = useState('');
|
||||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set());
|
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);
|
const avail = await inventoryManager.getAvailability(ticketType.id);
|
||||||
availabilityMap.set(ticketType.id, avail);
|
availabilityMap.set(ticketType.id, avail);
|
||||||
} catch (error) {
|
} 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 });
|
availabilityMap.set(ticketType.id, { is_available: false, error: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,28 +170,54 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [currentReservations]);
|
}, [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 handleQuantityChange = async (ticketTypeId: string, newQuantity: number) => {
|
||||||
const currentQuantity = selectedTickets.get(ticketTypeId)?.quantity || 0;
|
const currentQuantity = selectedTickets.get(ticketTypeId)?.quantity || 0;
|
||||||
|
|
||||||
if (newQuantity === currentQuantity) return;
|
if (newQuantity === currentQuantity) return;
|
||||||
|
|
||||||
console.log('Quantity change:', { ticketTypeId, currentQuantity, newQuantity });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Release existing reservation if any
|
// Release existing reservation if any
|
||||||
if (currentReservations.has(ticketTypeId)) {
|
if (currentReservations.has(ticketTypeId)) {
|
||||||
console.log('Releasing existing reservation...');
|
const existingReservation = currentReservations.get(ticketTypeId);
|
||||||
await inventoryManager.releaseReservation(currentReservations.get(ticketTypeId).id);
|
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);
|
const newReservations = new Map(currentReservations);
|
||||||
newReservations.delete(ticketTypeId);
|
newReservations.delete(ticketTypeId);
|
||||||
setCurrentReservations(newReservations);
|
setCurrentReservations(newReservations);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newQuantity > 0) {
|
if (newQuantity > 0) {
|
||||||
console.log('Reserving tickets:', { ticketTypeId, quantity: newQuantity });
|
|
||||||
// Reserve new tickets
|
// Reserve new tickets
|
||||||
const reservation = await inventoryManager.reserveTickets(ticketTypeId, newQuantity, 15);
|
const reservation = await inventoryManager.reserveTickets(ticketTypeId, newQuantity, 15);
|
||||||
console.log('Reservation successful:', reservation);
|
|
||||||
|
|
||||||
const newReservations = new Map(currentReservations);
|
const newReservations = new Map(currentReservations);
|
||||||
newReservations.set(ticketTypeId, reservation);
|
newReservations.set(ticketTypeId, reservation);
|
||||||
@@ -191,9 +240,28 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
setSelectedTickets(newSelected);
|
setSelectedTickets(newSelected);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating reservation:', error);
|
// If it's a reservation error, still update the UI but show a warning
|
||||||
console.error('Error details:', error);
|
if (error.message && error.message.includes('Reservation not found')) {
|
||||||
alert(error.message || 'Error reserving tickets. Please try again.');
|
|
||||||
|
// 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 totals = calculateTotals();
|
||||||
|
|
||||||
const purchaseAttempt = await inventoryManager.createPurchaseAttempt(
|
const _purchaseAttempt = await inventoryManager.createPurchaseAttempt(
|
||||||
event.id,
|
event.id,
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
@@ -244,11 +312,10 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
alert('Checkout integration coming soon! Your tickets are reserved.');
|
alert('Checkout integration coming soon! Your tickets are reserved.');
|
||||||
console.log('Purchase attempt created:', purchaseAttempt);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating purchase:', error);
|
const errorMessage = error instanceof Error ? error.message : 'Error processing purchase. Please try again.';
|
||||||
alert(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');
|
setPresaleCodeError(data.error || 'Invalid presale code');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error validating presale code:', error);
|
console.error('Presale code validation error:', error);
|
||||||
setPresaleCodeError('Error validating code. Please try again.');
|
setPresaleCodeError('Error validating code. Please try again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -309,7 +376,7 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
const totals = calculateTotals();
|
const totals = calculateTotals();
|
||||||
|
|
||||||
if (loading) {
|
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 (
|
return (
|
||||||
@@ -318,10 +385,10 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
|
|
||||||
{/* Presale Code Entry - Only show if presale is active */}
|
{/* Presale Code Entry - Only show if presale is active */}
|
||||||
{hasActivePresale && !presaleCodeValidated && (
|
{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="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 items-end gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
|
||||||
<div className="flex-1">
|
<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
|
Presale Code Required
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -333,16 +400,46 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
setPresaleCodeError('');
|
setPresaleCodeError('');
|
||||||
}}
|
}}
|
||||||
placeholder="Enter your presale code"
|
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 && (
|
{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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={validatePresaleCode}
|
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
|
Apply Code
|
||||||
</button>
|
</button>
|
||||||
@@ -352,13 +449,13 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
|
|
||||||
{/* Presale Code Success - Compact version */}
|
{/* Presale Code Success - Compact version */}
|
||||||
{presaleCodeValidated && presaleCodeData && (
|
{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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-sm font-medium text-green-900">
|
<span className="text-sm font-medium" style={{color: 'var(--success-color)'}}>
|
||||||
Presale access granted
|
Presale access granted
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +466,10 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
setPresaleCodeData(null);
|
setPresaleCodeData(null);
|
||||||
setPresaleCode('');
|
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
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -394,7 +494,7 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
}
|
}
|
||||||
// Check if presale code gives access to this ticket type
|
// Check if presale code gives access to this ticket type
|
||||||
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
||||||
(accessibleType: any) => accessibleType.id === ticketType.id
|
(accessibleType: { id: string; name: string }) => accessibleType.id === ticketType.id
|
||||||
);
|
);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return false;
|
return false;
|
||||||
@@ -410,34 +510,59 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
// Get formatted availability display
|
// Get formatted availability display
|
||||||
const availabilityDisplay = avail
|
const availabilityDisplay = avail
|
||||||
? formatAvailabilityDisplay(avail, availabilitySettings)
|
? 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 (
|
return (
|
||||||
<div key={ticketType.id} className={`border-2 rounded-2xl p-6 transition-all duration-200 ${
|
<div
|
||||||
availabilityDisplay.isSoldOut
|
key={ticketType.id}
|
||||||
? 'bg-slate-50 opacity-75 border-slate-200'
|
className="border-2 rounded-2xl p-4 sm:p-6 transition-all duration-200 backdrop-blur-lg"
|
||||||
: selectedQuantity > 0
|
style={{
|
||||||
? 'bg-gradient-to-br from-emerald-50 to-green-50 border-emerald-300 shadow-lg'
|
background: availabilityDisplay.isSoldOut
|
||||||
: 'bg-white border-slate-200 hover:border-slate-300 hover:shadow-md'
|
? 'var(--ui-bg-secondary)'
|
||||||
}`}>
|
: selectedQuantity > 0
|
||||||
<div className="flex justify-between items-start">
|
? '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-1">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-3">
|
||||||
<h3 className="text-xl font-semibold text-slate-900">{ticketType.name}</h3>
|
<h3 className="text-lg sm:text-xl font-semibold" style={{color: 'var(--ui-text-primary)'}}>{ticketType.name}</h3>
|
||||||
{availabilityDisplay.isLowStock && (
|
<div className="flex gap-2">
|
||||||
<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">
|
{availabilityDisplay.isLowStock && (
|
||||||
Low Stock
|
<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)'}}>
|
||||||
</span>
|
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 > 0 && (
|
||||||
{selectedQuantity} Selected
|
<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)'}}>
|
||||||
</span>
|
{selectedQuantity} Selected
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ticketType.description && (
|
{ticketType.description && (
|
||||||
<div className="mb-4 p-3 bg-slate-50 rounded-xl border border-slate-200">
|
<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 text-slate-700 leading-relaxed whitespace-pre-line">
|
<p className="text-sm leading-relaxed whitespace-pre-line" style={{color: 'var(--ui-text-secondary)'}}>
|
||||||
{expandedDescriptions.has(ticketType.id)
|
{expandedDescriptions.has(ticketType.id)
|
||||||
? ticketType.description
|
? ticketType.description
|
||||||
: truncateDescription(ticketType.description)
|
: truncateDescription(ticketType.description)
|
||||||
@@ -447,52 +572,99 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleDescription(ticketType.id)}
|
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'}
|
{expandedDescriptions.has(ticketType.id) ? 'Show less' : 'Show more'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<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)}
|
${price.toFixed(2)}
|
||||||
</span>
|
</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}
|
{availabilityDisplay.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center justify-center sm:justify-start space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleQuantityChange(ticketType.id, Math.max(0, selectedQuantity - 1))}
|
onClick={() => handleQuantityChange(ticketType.id, Math.max(0, selectedQuantity - 1))}
|
||||||
disabled={selectedQuantity <= 0 || availabilityDisplay.isSoldOut}
|
disabled={selectedQuantity <= 0 || availabilityDisplay.isSoldOut}
|
||||||
className={`w-10 h-10 rounded-xl border-2 font-bold text-lg transition-all duration-200 ${
|
className="w-12 h-12 rounded-xl border-2 font-bold text-lg transition-all duration-200 touch-manipulation"
|
||||||
selectedQuantity <= 0 || availabilityDisplay.isSoldOut
|
style={{
|
||||||
? 'border-slate-200 text-slate-300 cursor-not-allowed bg-slate-50'
|
borderColor: selectedQuantity <= 0 || availabilityDisplay.isSoldOut
|
||||||
: 'border-slate-300 text-slate-600 hover:border-red-400 hover:text-red-600 hover:bg-red-50 active:scale-95'
|
? '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>
|
</button>
|
||||||
|
|
||||||
<div className="w-12 text-center">
|
<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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleQuantityChange(ticketType.id, selectedQuantity + 1)}
|
onClick={() => handleQuantityChange(ticketType.id, selectedQuantity + 1)}
|
||||||
disabled={selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut}
|
disabled={selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut}
|
||||||
className={`w-10 h-10 rounded-xl border-2 font-bold text-lg transition-all duration-200 ${
|
className="w-12 h-12 rounded-xl border-2 font-bold text-lg transition-all duration-200 touch-manipulation"
|
||||||
selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut
|
style={{
|
||||||
? 'border-slate-200 text-slate-300 cursor-not-allowed bg-slate-50'
|
borderColor: selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut
|
||||||
: 'border-slate-300 text-slate-600 hover:border-green-400 hover:text-green-600 hover:bg-green-50 active:scale-95'
|
? '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>
|
</button>
|
||||||
@@ -516,7 +688,7 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
||||||
(accessibleType: any) => accessibleType.id === ticketType.id
|
(accessibleType: { id: string; name: string }) => accessibleType.id === ticketType.id
|
||||||
);
|
);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return false;
|
return false;
|
||||||
@@ -524,14 +696,14 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}).length === 0 && (
|
}).length === 0 && (
|
||||||
<div className="text-center py-6 bg-yellow-50 border border-yellow-200 rounded-lg">
|
<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 text-yellow-400 mb-3">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-yellow-900 mb-2">Presale Access Required</h3>
|
<h3 className="text-lg font-medium mb-2" style={{color: 'var(--warning-color)'}}>Presale Access Required</h3>
|
||||||
<p className="text-yellow-700 text-sm">
|
<p className="text-sm" style={{color: 'var(--ui-text-secondary)'}}>
|
||||||
This event is currently in presale. Enter your presale code above to access tickets.
|
This event is currently in presale. Enter your presale code above to access tickets.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,12 +712,12 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
|
|
||||||
{/* Reservation Timer */}
|
{/* Reservation Timer */}
|
||||||
{currentReservations.size > 0 && (
|
{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">
|
<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" />
|
<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>
|
</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}
|
Tickets reserved for {timeRemaining}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,39 +726,39 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
|
|
||||||
{/* Order Summary */}
|
{/* Order Summary */}
|
||||||
{selectedTickets.size > 0 && (
|
{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">
|
<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-xl font-semibold text-slate-900 mb-4 flex items-center">
|
<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 bg-gradient-to-r from-emerald-500 to-green-500 rounded-full mr-3"></div>
|
<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
|
Order Summary
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{Array.from(selectedTickets.entries()).map(([ticketTypeId, ticket]) => (
|
{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">
|
<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 text-slate-900">{ticket.quantity}x {ticket.name}</span>
|
<span className="font-medium truncate mr-2" style={{color: 'var(--ui-text-primary)'}}>{ticket.quantity}x {ticket.name}</span>
|
||||||
<span className="font-semibold text-slate-900">${((ticket.quantity * ticket.price) / 100).toFixed(2)}</span>
|
<span className="font-semibold whitespace-nowrap" style={{color: 'var(--ui-text-primary)'}}>${((ticket.quantity * ticket.price) / 100).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t-2 border-slate-200 pt-4">
|
<div className="pt-4" style={{borderTop: '2px solid var(--ui-border-secondary)'}}>
|
||||||
<div className="flex justify-between text-slate-600 mb-2">
|
<div className="flex justify-between mb-2" style={{color: 'var(--ui-text-secondary)'}}>
|
||||||
<span>Subtotal:</span>
|
<span>Subtotal:</span>
|
||||||
<span>${(totals.subtotal / 100).toFixed(2)}</span>
|
<span>${(totals.subtotal / 100).toFixed(2)}</span>
|
||||||
</div>
|
</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>Platform fee:</span>
|
||||||
<span>${(totals.platformFee / 100).toFixed(2)}</span>
|
<span>${(totals.platformFee / 100).toFixed(2)}</span>
|
||||||
</div>
|
</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>Total:</span>
|
||||||
<span>${(totals.total / 100).toFixed(2)}</span>
|
<span>${(totals.total / 100).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Customer Information - Only show when tickets are selected */}
|
{/* 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 className="space-y-4">
|
||||||
<div>
|
<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
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -595,13 +767,29 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
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"
|
placeholder="your@email.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Full Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -610,7 +798,23 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
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"
|
placeholder="Your Name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -618,7 +822,19 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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
|
Complete Purchase
|
||||||
</button>
|
</button>
|
||||||
@@ -628,19 +844,19 @@ export default function TicketCheckout({ event }: Props) {
|
|||||||
|
|
||||||
{/* Call to Action - Show when no tickets selected */}
|
{/* Call to Action - Show when no tickets selected */}
|
||||||
{selectedTickets.size === 0 && (
|
{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="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 bg-gradient-to-br from-slate-400 to-slate-500 rounded-full flex items-center justify-center">
|
<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 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">Select Your Tickets</h3>
|
<h3 className="text-lg font-semibold mb-2" style={{color: 'var(--ui-text-secondary)'}}>Select Your Tickets</h3>
|
||||||
<p className="text-slate-500">Choose your preferred seating and quantity above to continue</p>
|
<p style={{color: 'var(--ui-text-tertiary)'}}>Choose your preferred seating and quantity above to continue</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<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
|
Secure checkout powered by Stripe • Tickets reserved for 15 minutes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
|
|
||||||
setTrendingEvents(trending);
|
setTrendingEvents(trending);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Trending events loading error:', err);
|
||||||
setError('Failed to load trending events');
|
setError('Failed to load trending events');
|
||||||
console.error('Error loading trending events:', err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -101,18 +102,18 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPopularityBadge = (score: number) => {
|
const getPopularityBadge = (score: number) => {
|
||||||
if (score >= 100) return { text: 'Super Hot', color: 'bg-red-500' };
|
if (score >= 100) return { text: 'Super Hot', color: 'var(--error-color)' };
|
||||||
if (score >= 50) return { text: 'Hot', color: 'bg-orange-500' };
|
if (score >= 50) return { text: 'Hot', color: 'var(--warning-color)' };
|
||||||
if (score >= 25) return { text: 'Trending', color: 'bg-yellow-500' };
|
if (score >= 25) return { text: 'Trending', color: 'var(--premium-gold)' };
|
||||||
return { text: 'Popular', color: 'bg-blue-500' };
|
return { text: 'Popular', color: 'var(--glass-text-accent)' };
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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="flex items-center justify-center h-32">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{borderColor: 'var(--glass-text-accent)'}}></div>
|
||||||
<span className="ml-3 text-gray-600">Loading hot events...</span>
|
<span className="ml-3" style={{color: 'var(--ui-text-secondary)'}}>Loading hot events...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -120,16 +121,19 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Unable to load events</h3>
|
<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 text-gray-500">{error}</p>
|
<p className="mt-1 text-sm" style={{color: 'var(--ui-text-secondary)'}}>{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadTrendingEvents}
|
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
|
Try again
|
||||||
</button>
|
</button>
|
||||||
@@ -140,13 +144,13 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
|
|
||||||
if (trendingEvents.length === 0) {
|
if (trendingEvents.length === 0) {
|
||||||
return (
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No trending events found</h3>
|
<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 text-gray-500">
|
<p className="mt-1 text-sm" style={{color: 'var(--ui-text-secondary)'}}>
|
||||||
Try expanding your search radius or check back later
|
Try expanding your search radius or check back later
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,16 +159,16 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="text-2xl">🔥</div>
|
<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>
|
</div>
|
||||||
{userLocation && (
|
{userLocation && (
|
||||||
<span className="text-orange-100 text-sm">
|
<span className="text-sm" style={{color: 'var(--glass-text-secondary)'}}>
|
||||||
Within {radius} miles
|
Within {radius} miles
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -174,22 +178,34 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
{/* Events Grid */}
|
{/* Events Grid */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<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);
|
const popularityBadge = getPopularityBadge(event.popularityScore);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.eventId}
|
key={event.eventId}
|
||||||
onClick={() => handleEventClick(event)}
|
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 */}
|
{/* 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}
|
{popularityBadge.text}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Image */}
|
{/* Event Image */}
|
||||||
{event.imageUrl && (
|
{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
|
<img
|
||||||
src={event.imageUrl}
|
src={event.imageUrl}
|
||||||
alt={event.title}
|
alt={event.title}
|
||||||
@@ -201,12 +217,12 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
{/* Event Content */}
|
{/* Event Content */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-start justify-between">
|
<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}
|
{event.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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">
|
<div className="flex items-center">
|
||||||
<svg className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Event Stats */}
|
{/* 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 space-x-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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 && (
|
{event.isFeature && (
|
||||||
<div className="flex items-center">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-yellow-600 font-medium">Featured</span>
|
<span className="font-medium" style={{color: 'var(--premium-gold)'}}>Featured</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +285,13 @@ const WhatsHotEvents: React.FC<WhatsHotEventsProps> = ({
|
|||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/calendar'}
|
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
|
View All Events
|
||||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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 || []);
|
setAvailableAddons(addonsData || []);
|
||||||
setEventAddons(eventAddonsData || []);
|
setEventAddons(eventAddonsData || []);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error loading addons:', error);
|
// Handle error silently
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -112,8 +112,8 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
setEventAddons(prev => [...prev, data]);
|
setEventAddons(prev => [...prev, data]);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error adding addon:', error);
|
// Handle error silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,8 +127,8 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
setEventAddons(prev => prev.filter(ea => ea.id !== eventAddon.id));
|
setEventAddons(prev => prev.filter(ea => ea.id !== eventAddon.id));
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error removing addon:', error);
|
// Handle error silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,8 +144,8 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
|||||||
setEventAddons(prev => prev.map(ea =>
|
setEventAddons(prev => prev.map(ea =>
|
||||||
ea.id === eventAddon.id ? { ...ea, is_active: !ea.is_active } : ea
|
ea.id === eventAddon.id ? { ...ea, is_active: !ea.is_active } : ea
|
||||||
));
|
));
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error toggling addon:', error);
|
// Handle error silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,7 +343,10 @@ export default function AddonsTab({ eventId, organizationId }: AddonsTabProps) {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddAddon(addon)}
|
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
|
Add
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
try {
|
try {
|
||||||
const ordersData = await loadSalesData(eventId);
|
const ordersData = await loadSalesData(eventId);
|
||||||
setOrders(ordersData);
|
setOrders(ordersData);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error loading attendees data:', error);
|
// Handle error silently
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -50,9 +50,9 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
const attendeeMap = new Map<string, AttendeeData>();
|
const attendeeMap = new Map<string, AttendeeData>();
|
||||||
|
|
||||||
orders.forEach(order => {
|
orders.forEach(order => {
|
||||||
const existing = attendeeMap.get(order.customer_email) || {
|
const existing = attendeeMap.get(order.purchaser_email) || {
|
||||||
email: order.customer_email,
|
email: order.purchaser_email,
|
||||||
name: order.customer_name,
|
name: order.purchaser_name,
|
||||||
ticketCount: 0,
|
ticketCount: 0,
|
||||||
totalSpent: 0,
|
totalSpent: 0,
|
||||||
checkedInCount: 0,
|
checkedInCount: 0,
|
||||||
@@ -60,19 +60,22 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
existing.tickets.push(order);
|
existing.tickets.push(order);
|
||||||
if (order.status === 'confirmed') {
|
if (!order.refund_status || order.refund_status === null) {
|
||||||
existing.ticketCount += 1;
|
existing.ticketCount += 1;
|
||||||
existing.totalSpent += order.price_paid;
|
existing.totalSpent += order.price;
|
||||||
if (order.checked_in) {
|
if (order.checked_in) {
|
||||||
existing.checkedInCount += 1;
|
existing.checkedInCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attendeeMap.set(order.customer_email, existing);
|
attendeeMap.set(order.purchaser_email, existing);
|
||||||
});
|
});
|
||||||
|
|
||||||
let processedAttendees = Array.from(attendeeMap.values());
|
let processedAttendees = Array.from(attendeeMap.values());
|
||||||
|
|
||||||
|
// Only show attendees with active tickets (ticketCount > 0)
|
||||||
|
processedAttendees = processedAttendees.filter(attendee => attendee.ticketCount > 0);
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
@@ -103,7 +106,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
|
|
||||||
const handleCheckInAttendee = async (attendee: AttendeeData) => {
|
const handleCheckInAttendee = async (attendee: AttendeeData) => {
|
||||||
const unCheckedTickets = attendee.tickets.filter(ticket =>
|
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;
|
if (unCheckedTickets.length === 0) return;
|
||||||
@@ -120,7 +123,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
|
|
||||||
const handleRefundAttendee = async (attendee: AttendeeData) => {
|
const handleRefundAttendee = async (attendee: AttendeeData) => {
|
||||||
const confirmedTickets = attendee.tickets.filter(ticket =>
|
const confirmedTickets = attendee.tickets.filter(ticket =>
|
||||||
ticket.status === 'confirmed'
|
(!ticket.refund_status || ticket.refund_status === null)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmedTickets.length === 0) return;
|
if (confirmedTickets.length === 0) return;
|
||||||
@@ -134,7 +137,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
|
|
||||||
setOrders(prev => prev.map(order =>
|
setOrders(prev => prev.map(order =>
|
||||||
confirmedTickets.some(t => t.id === order.id)
|
confirmedTickets.some(t => t.id === order.id)
|
||||||
? { ...order, status: 'refunded' }
|
? { ...order, refund_status: 'completed' }
|
||||||
: order
|
: order
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -142,7 +145,7 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
|
|
||||||
const handleBulkCheckIn = async () => {
|
const handleBulkCheckIn = async () => {
|
||||||
const unCheckedTickets = orders.filter(order =>
|
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) {
|
if (unCheckedTickets.length === 0) {
|
||||||
@@ -198,7 +201,11 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkCheckIn}
|
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">
|
<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" />
|
<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()}
|
Purchased: {new Date(ticket.created_at).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/60 text-sm font-mono">
|
<div className="text-white/60 text-sm font-mono">
|
||||||
ID: {ticket.ticket_uuid}
|
ID: {ticket.uuid}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<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">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
<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.refund_status || ticket.refund_status === null) ? 'status-pill status-success' :
|
||||||
ticket.status === 'refunded' ? 'bg-red-500/20 text-red-300 border border-red-500/30' :
|
ticket.refund_status === 'completed' ? 'status-pill status-error' :
|
||||||
'bg-yellow-500/20 text-yellow-300 border border-yellow-500/30'
|
'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>
|
</span>
|
||||||
{ticket.checked_in ? (
|
{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
|
Checked In
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -378,7 +387,10 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
handleCheckInAttendee(selectedAttendee);
|
handleCheckInAttendee(selectedAttendee);
|
||||||
setShowAttendeeDetails(false);
|
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
|
Check In
|
||||||
</button>
|
</button>
|
||||||
@@ -389,7 +401,10 @@ export default function AttendeesTab({ eventId }: AttendeesTabProps) {
|
|||||||
handleRefundAttendee(selectedAttendee);
|
handleRefundAttendee(selectedAttendee);
|
||||||
setShowAttendeeDetails(false);
|
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
|
Refund All
|
||||||
</button>
|
</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) {
|
export default function DiscountTab({ eventId }: DiscountTabProps) {
|
||||||
const [discountCodes, setDiscountCodes] = useState<DiscountCode[]>([]);
|
const [discountCodes, setDiscountCodes] = useState<DiscountCode[]>([]);
|
||||||
const [ticketTypes, setTicketTypes] = useState<any[]>([]);
|
const [ticketTypes, setTicketTypes] = useState<Record<string, unknown>[]>([]);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingCode, setEditingCode] = useState<DiscountCode | null>(null);
|
const [editingCode, setEditingCode] = useState<DiscountCode | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -67,8 +67,8 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
|||||||
|
|
||||||
setDiscountCodes(discountData.data || []);
|
setDiscountCodes(discountData.data || []);
|
||||||
setTicketTypes(ticketTypesData.data || []);
|
setTicketTypes(ticketTypesData.data || []);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error loading discount codes:', error);
|
// Handle error silently
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -142,8 +142,8 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
|||||||
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error saving discount code:', error);
|
// Handle error silently
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting discount code:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -174,8 +174,8 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Error toggling discount code:', 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>
|
<h2 className="text-2xl font-light text-white">Discount Codes</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateCode}
|
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">
|
<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" />
|
<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>
|
<p className="text-white/60 mb-4">No discount codes created yet</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateCode}
|
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
|
Create Your First Discount Code
|
||||||
</button>
|
</button>
|
||||||
@@ -502,7 +510,11 @@ export default function DiscountTab({ eventId }: DiscountTabProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveCode}
|
onClick={handleSaveCode}
|
||||||
disabled={saving}
|
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'}
|
{saving ? 'Saving...' : editingCode ? 'Update' : 'Create'}
|
||||||
</button>
|
</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,
|
copyToClipboard,
|
||||||
downloadAsset
|
downloadAsset
|
||||||
} from '../../lib/marketing-kit';
|
} 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 { loadEventData } from '../../lib/event-management';
|
||||||
import type { MarketingKitData, SocialMediaContent, EmailTemplate } from '../../lib/marketing-kit';
|
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 [marketingKit, setMarketingKit] = useState<MarketingKitData | null>(null);
|
||||||
const [socialContent, setSocialContent] = useState<SocialMediaContent[]>([]);
|
const [socialContent, setSocialContent] = useState<SocialMediaContent[]>([]);
|
||||||
const [emailTemplate, setEmailTemplate] = useState<EmailTemplate | null>(null);
|
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 [activeTab, setActiveTab] = useState<'overview' | 'social' | 'email' | 'assets'>('overview');
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -36,16 +53,28 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
|||||||
if (kitData) {
|
if (kitData) {
|
||||||
setMarketingKit(kitData);
|
setMarketingKit(kitData);
|
||||||
|
|
||||||
// Generate social media content
|
// Load saved AI content from localStorage
|
||||||
const socialData = generateSocialMediaContent(kitData.event);
|
const savedSocialContent = localStorage.getItem(`social_content_${eventId}`);
|
||||||
setSocialContent(socialData);
|
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);
|
const emailData = generateEmailTemplate(kitData.event);
|
||||||
setEmailTemplate(emailData);
|
setEmailTemplate(emailData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading marketing kit:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,15 +87,13 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
|||||||
if (newKit) {
|
if (newKit) {
|
||||||
setMarketingKit(newKit);
|
setMarketingKit(newKit);
|
||||||
|
|
||||||
// Refresh social and email content
|
// Don't auto-generate, wait for user to click generate button
|
||||||
const socialData = generateSocialMediaContent(newKit.event);
|
|
||||||
setSocialContent(socialData);
|
|
||||||
|
|
||||||
const emailData = generateEmailTemplate(newKit.event);
|
const emailData = generateEmailTemplate(newKit.event);
|
||||||
setEmailTemplate(emailData);
|
setEmailTemplate(emailData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating marketing kit:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
@@ -77,7 +104,7 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
|||||||
await copyToClipboard(content);
|
await copyToClipboard(content);
|
||||||
alert('Content copied to clipboard!');
|
alert('Content copied to clipboard!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error copying content:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +112,164 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
|||||||
try {
|
try {
|
||||||
await downloadAsset(assetUrl, filename);
|
await downloadAsset(assetUrl, filename);
|
||||||
} catch (error) {
|
} 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
|
<button
|
||||||
onClick={handleGenerateKit}
|
onClick={handleGenerateKit}
|
||||||
disabled={generating}
|
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">
|
<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" />
|
<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
|
<button
|
||||||
onClick={handleGenerateKit}
|
onClick={handleGenerateKit}
|
||||||
disabled={generating}
|
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'}
|
{generating ? 'Generating...' : 'Generate Marketing Kit'}
|
||||||
</button>
|
</button>
|
||||||
@@ -191,18 +383,22 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white/5 border border-white/20 rounded-xl p-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>
|
<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-center">
|
||||||
<div className="text-3xl font-bold text-blue-400 mb-2">{marketingKit.assets.length}</div>
|
<div className="text-3xl font-bold text-blue-400 mb-2">{socialContent.length}</div>
|
||||||
<div className="text-white/60">Assets Generated</div>
|
<div className="text-white/60">Social Posts</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-green-400 mb-2">{socialContent.length}</div>
|
<div className="text-3xl font-bold text-green-400 mb-2">{aiEmailTemplates.length}</div>
|
||||||
<div className="text-white/60">Social Templates</div>
|
<div className="text-white/60">Email Templates</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-purple-400 mb-2">1</div>
|
<div className="text-3xl font-bold text-purple-400 mb-2">{flyerData.length}</div>
|
||||||
<div className="text-white/60">Email Template</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,158 +432,419 @@ export default function MarketingTab({ eventId, organizationId }: MarketingTabPr
|
|||||||
|
|
||||||
{activeTab === 'social' && (
|
{activeTab === 'social' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
{socialContent.map((content) => (
|
<h3 className="text-xl font-semibold text-white">Social Media Content</h3>
|
||||||
<div key={content.platform} className="bg-white/5 border border-white/20 rounded-xl p-6">
|
<button
|
||||||
<div className="flex items-center gap-3 mb-4">
|
onClick={handleGenerateAISocialContent}
|
||||||
<div className="text-blue-400">
|
disabled={generatingAI || !marketingKit}
|
||||||
{getPlatformIcon(content.platform)}
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
|
||||||
</div>
|
generatingAI || !marketingKit
|
||||||
<h3 className="text-lg font-semibold text-white capitalize">{content.platform}</h3>
|
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||||
</div>
|
: ''
|
||||||
|
}`}
|
||||||
<div className="space-y-4">
|
style={generatingAI || !marketingKit ? {} : {
|
||||||
<div>
|
background: 'var(--glass-text-accent)',
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">Content</label>
|
color: 'white'
|
||||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm whitespace-pre-wrap">
|
}}
|
||||||
{content.content}
|
>
|
||||||
</div>
|
{generatingAI ? (
|
||||||
</div>
|
<>
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
<div>
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">Hashtags</label>
|
<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>
|
||||||
<div className="flex flex-wrap gap-2">
|
</svg>
|
||||||
{content.hashtags.map((hashtag, index) => (
|
Generating with AI...
|
||||||
<span key={index} className="px-2 py-1 bg-blue-500/20 text-blue-300 rounded-lg text-xs">
|
</>
|
||||||
{hashtag}
|
) : (
|
||||||
</span>
|
<>
|
||||||
))}
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</div>
|
</svg>
|
||||||
|
Generate AI Content
|
||||||
<div className="flex justify-end">
|
</>
|
||||||
<button
|
)}
|
||||||
onClick={() => handleCopyContent(`${content.content}\n\n${content.hashtags.join(' ')}`)}
|
</button>
|
||||||
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'email' && emailTemplate && (
|
{activeTab === 'email' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white/5 border border-white/20 rounded-xl p-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Email Template</h3>
|
<h3 className="text-xl font-semibold text-white">Email Templates</h3>
|
||||||
|
<button
|
||||||
<div className="space-y-4">
|
onClick={handleGenerateAIEmailTemplates}
|
||||||
<div>
|
disabled={generatingEmails || !marketingKit}
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">Subject Line</label>
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
|
||||||
<div className="bg-white/10 rounded-lg p-3 text-white">
|
generatingEmails || !marketingKit
|
||||||
{emailTemplate.subject}
|
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||||
</div>
|
: ''
|
||||||
<div className="flex justify-end mt-2">
|
}`}
|
||||||
<button
|
style={generatingEmails || !marketingKit ? {} : {
|
||||||
onClick={() => handleCopyContent(emailTemplate.subject)}
|
background: 'var(--glass-text-accent)',
|
||||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
color: 'white'
|
||||||
>
|
}}
|
||||||
Copy Subject
|
>
|
||||||
</button>
|
{generatingEmails ? (
|
||||||
</div>
|
<>
|
||||||
</div>
|
<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>
|
||||||
<div>
|
<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>
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">Preview Text</label>
|
</svg>
|
||||||
<div className="bg-white/10 rounded-lg p-3 text-white">
|
Generating with AI...
|
||||||
{emailTemplate.preview_text}
|
</>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">HTML Content</label>
|
</svg>
|
||||||
<div className="bg-white/10 rounded-lg p-3 text-white text-sm max-h-64 overflow-y-auto">
|
Generate AI Email Templates
|
||||||
<pre className="whitespace-pre-wrap">{emailTemplate.html_content}</pre>
|
</>
|
||||||
</div>
|
)}
|
||||||
<div className="flex justify-end mt-2">
|
</button>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'assets' && (
|
{activeTab === 'assets' && (
|
||||||
<div className="space-y-6">
|
<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">
|
<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-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" />
|
<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>
|
</svg>
|
||||||
<p className="text-white/60 mb-4">No assets generated yet</p>
|
<p className="text-white/60 mb-4">No AI flyers generated yet</p>
|
||||||
<button
|
<p className="text-white/40 text-sm">Click "Generate AI Flyers" to create 3 unique flyer concepts</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{marketingKit.assets.map((asset) => (
|
{flyerData.map((flyer) => (
|
||||||
<div key={asset.id} className="bg-white/5 border border-white/20 rounded-xl p-6">
|
<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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-white capitalize">
|
<h3 className="text-lg font-semibold text-white capitalize">{flyer.style} Style</h3>
|
||||||
{asset.asset_type.replace('_', ' ')}
|
<div className="flex gap-2">
|
||||||
</h3>
|
<button
|
||||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-300 rounded-lg text-xs">
|
onClick={() => handleVoteContent(flyer.id || '', 'up')}
|
||||||
{asset.asset_type}
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
</span>
|
(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>
|
</div>
|
||||||
|
|
||||||
{asset.asset_url && (
|
<div className="space-y-4">
|
||||||
<div className="mb-4">
|
<div>
|
||||||
<img
|
<label className="block text-sm font-medium text-white/80 mb-2">Title</label>
|
||||||
src={asset.asset_url}
|
<div className="bg-white/10 rounded-lg p-3 text-white font-semibold">
|
||||||
alt={asset.asset_type}
|
{flyer.title}
|
||||||
className="w-full h-32 object-cover rounded-lg bg-white/10"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div>
|
||||||
<button
|
<label className="block text-sm font-medium text-white/80 mb-2">Content</label>
|
||||||
onClick={() => handleDownloadAsset(asset.asset_url, `${asset.asset_type}-${asset.id}`)}
|
<div className="bg-white/10 rounded-lg p-3 text-white text-sm">
|
||||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm transition-colors"
|
{flyer.content}
|
||||||
>
|
</div>
|
||||||
Download
|
</div>
|
||||||
</button>
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
setOrders(ordersData);
|
setOrders(ordersData);
|
||||||
setTicketTypes(ticketTypesData);
|
setTicketTypes(ticketTypesData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading orders data:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,15 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
|
|
||||||
// Apply status filter
|
// Apply status filter
|
||||||
if (filters.status) {
|
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
|
// Apply check-in filter
|
||||||
@@ -67,9 +75,9 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
filtered = filtered.filter(order =>
|
filtered = filtered.filter(order =>
|
||||||
order.customer_name.toLowerCase().includes(term) ||
|
order.purchaser_name.toLowerCase().includes(term) ||
|
||||||
order.customer_email.toLowerCase().includes(term) ||
|
order.purchaser_email.toLowerCase().includes(term) ||
|
||||||
order.ticket_uuid.toLowerCase().includes(term)
|
order.uuid.toLowerCase().includes(term)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +99,11 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefundOrder = async (order: SalesData) => {
|
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);
|
const success = await refundTicket(order.id);
|
||||||
if (success) {
|
if (success) {
|
||||||
setOrders(prev => prev.map(o =>
|
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);
|
window.URL.revokeObjectURL(url);
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error exporting data:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -134,12 +142,12 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
|
|
||||||
const getOrderStats = () => {
|
const getOrderStats = () => {
|
||||||
const totalOrders = filteredOrders.length;
|
const totalOrders = filteredOrders.length;
|
||||||
const confirmedOrders = filteredOrders.filter(o => o.status === 'confirmed').length;
|
const confirmedOrders = filteredOrders.filter(o => !o.refund_status || o.refund_status === null).length;
|
||||||
const refundedOrders = filteredOrders.filter(o => o.status === 'refunded').length;
|
const refundedOrders = filteredOrders.filter(o => o.refund_status === 'completed').length;
|
||||||
const checkedInOrders = filteredOrders.filter(o => o.checked_in).length;
|
const checkedInOrders = filteredOrders.filter(o => o.checked_in).length;
|
||||||
const totalRevenue = filteredOrders
|
const totalRevenue = filteredOrders
|
||||||
.filter(o => o.status === 'confirmed')
|
.filter(o => !o.refund_status || o.refund_status === null)
|
||||||
.reduce((sum, o) => sum + o.price_paid, 0);
|
.reduce((sum, o) => sum + o.price, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalOrders,
|
totalOrders,
|
||||||
@@ -302,11 +310,11 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white/60 text-sm">Name:</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white/60 text-sm">Email:</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,7 +328,7 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white/60 text-sm">Ticket ID:</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white/60 text-sm">Purchase Date:</span>
|
<span className="text-white/60 text-sm">Purchase Date:</span>
|
||||||
@@ -340,16 +348,18 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white/60 text-sm">Price Paid:</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white/60 text-sm">Status:</span>
|
<span className="text-white/60 text-sm">Status:</span>
|
||||||
<div className={`font-medium ${
|
<div className={`font-medium ${
|
||||||
selectedOrder.status === 'confirmed' ? 'text-green-400' :
|
(!selectedOrder.refund_status || selectedOrder.refund_status === null) ? 'text-green-400' :
|
||||||
selectedOrder.status === 'refunded' ? 'text-red-400' :
|
selectedOrder.refund_status === 'completed' ? 'text-red-400' :
|
||||||
'text-yellow-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,13 +384,17 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
Not Checked In
|
Not Checked In
|
||||||
</div>
|
</div>
|
||||||
{selectedOrder.status === 'confirmed' && (
|
{(!selectedOrder.refund_status || selectedOrder.refund_status === null) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleCheckInOrder(selectedOrder);
|
handleCheckInOrder(selectedOrder);
|
||||||
setShowOrderDetails(false);
|
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
|
Check In Now
|
||||||
</button>
|
</button>
|
||||||
@@ -403,7 +417,11 @@ export default function OrdersTab({ eventId }: OrdersTabProps) {
|
|||||||
handleRefundOrder(selectedOrder);
|
handleRefundOrder(selectedOrder);
|
||||||
setShowOrderDetails(false);
|
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
|
Refund Order
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setPresaleCodes(data || []);
|
setPresaleCodes(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading presale codes:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
|||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
loadPresaleCodes();
|
loadPresaleCodes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving presale code:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
loadPresaleCodes();
|
loadPresaleCodes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting presale code:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -155,7 +155,7 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
loadPresaleCodes();
|
loadPresaleCodes();
|
||||||
} catch (error) {
|
} 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>
|
<h2 className="text-2xl font-light text-white">Presale Codes</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateCode}
|
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">
|
<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" />
|
<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>
|
<p className="text-white/60 mb-4">No presale codes created yet</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateCode}
|
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
|
Create Your First Presale Code
|
||||||
</button>
|
</button>
|
||||||
@@ -402,7 +410,11 @@ export default function PresaleTab({ eventId }: PresaleTabProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveCode}
|
onClick={handleSaveCode}
|
||||||
disabled={saving}
|
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'}
|
{saving ? 'Saving...' : editingCode ? 'Update' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
import { formatCurrency } from '../../lib/event-management';
|
import { formatCurrency } from '../../lib/event-management';
|
||||||
|
import TicketPreviewModal from '../modals/TicketPreviewModal';
|
||||||
|
|
||||||
const supabase = createClient(
|
const supabase = createClient(
|
||||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||||
@@ -19,11 +20,13 @@ interface PrintedTicket {
|
|||||||
|
|
||||||
interface PrintedTabProps {
|
interface PrintedTabProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
organizationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PrintedTab({ eventId }: PrintedTabProps) {
|
export default function PrintedTab({ eventId, organizationId }: PrintedTabProps) {
|
||||||
const [printedTickets, setPrintedTickets] = useState<PrintedTicket[]>([]);
|
const [printedTickets, setPrintedTickets] = useState<PrintedTicket[]>([]);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||||
const [barcodeMethod, setBarcodeMethod] = useState<'generate' | 'manual'>('generate');
|
const [barcodeMethod, setBarcodeMethod] = useState<'generate' | 'manual'>('generate');
|
||||||
const [barcodeData, setBarcodeData] = useState({
|
const [barcodeData, setBarcodeData] = useState({
|
||||||
startNumber: 1,
|
startNumber: 1,
|
||||||
@@ -53,7 +56,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setPrintedTickets(data || []);
|
setPrintedTickets(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading printed tickets:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -127,7 +130,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
|||||||
});
|
});
|
||||||
setManualBarcodes('');
|
setManualBarcodes('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating printed tickets:', error);
|
|
||||||
alert('Failed to create printed tickets');
|
alert('Failed to create printed tickets');
|
||||||
} finally {
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
@@ -159,7 +162,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
|||||||
setEditingTicket(null);
|
setEditingTicket(null);
|
||||||
loadPrintedTickets();
|
loadPrintedTickets();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating printed ticket:', error);
|
|
||||||
alert('Failed to update printed ticket');
|
alert('Failed to update printed ticket');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -175,7 +178,7 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
loadPrintedTickets();
|
loadPrintedTickets();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting printed ticket:', error);
|
|
||||||
alert('Failed to delete printed ticket');
|
alert('Failed to delete printed ticket');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,16 +254,35 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-2xl font-light text-white">Printed Tickets</h2>
|
<div>
|
||||||
<button
|
<h2 className="text-2xl font-light text-white mb-2">Printed Tickets</h2>
|
||||||
onClick={() => setShowModal(true)}
|
<p className="text-white/80">Manage barcodes for printed tickets</p>
|
||||||
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"
|
</div>
|
||||||
>
|
<div className="flex gap-3">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
onClick={() => setShowPreviewModal(true)}
|
||||||
</svg>
|
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"
|
||||||
Add Printed Tickets
|
>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
@@ -568,6 +590,14 @@ export default function PrintedTab({ eventId }: PrintedTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ticket Preview Modal */}
|
||||||
|
<TicketPreviewModal
|
||||||
|
isOpen={showPreviewModal}
|
||||||
|
onClose={() => setShowPreviewModal(false)}
|
||||||
|
eventId={eventId}
|
||||||
|
organizationId={organizationId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setPromotions(data || []);
|
setPromotions(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading promotions:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
loadPromotions();
|
loadPromotions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving promotion:', error);
|
|
||||||
alert('Failed to save promotion');
|
alert('Failed to save promotion');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -145,7 +145,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
loadPromotions();
|
loadPromotions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting promotion:', error);
|
|
||||||
alert('Failed to delete promotion');
|
alert('Failed to delete promotion');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
loadPromotions();
|
loadPromotions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling promotion:', error);
|
|
||||||
alert('Failed to toggle promotion');
|
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>
|
<h2 className="text-2xl font-light text-white">Promotions & Campaigns</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreatePromotion}
|
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">
|
<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" />
|
<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>
|
<p className="text-white/60 mb-4">No promotions created yet</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreatePromotion}
|
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
|
Create Your First Promotion
|
||||||
</button>
|
</button>
|
||||||
@@ -424,7 +432,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
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"
|
placeholder="Early Bird Special"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,7 +457,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
<select
|
<select
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value as any }))}
|
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="early_bird">Early Bird</option>
|
||||||
<option value="flash_sale">Flash Sale</option>
|
<option value="flash_sale">Flash Sale</option>
|
||||||
@@ -462,7 +476,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
type="number"
|
type="number"
|
||||||
value={formData.discount_percentage}
|
value={formData.discount_percentage}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, discount_percentage: parseInt(e.target.value) || 0 }))}
|
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"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
/>
|
/>
|
||||||
@@ -476,7 +493,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
type="date"
|
type="date"
|
||||||
value={formData.start_date}
|
value={formData.start_date}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, start_date: e.target.value }))}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -486,7 +506,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
type="date"
|
type="date"
|
||||||
value={formData.end_date}
|
value={formData.end_date}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, end_date: e.target.value }))}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,7 +520,10 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
type="number"
|
type="number"
|
||||||
value={formData.max_uses}
|
value={formData.max_uses}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 0 }))}
|
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"
|
min="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,7 +539,11 @@ export default function PromotionsTab({ eventId }: PromotionsTabProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSavePromotion}
|
onClick={handleSavePromotion}
|
||||||
disabled={saving}
|
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'}
|
{saving ? 'Saving...' : editingPromotion ? 'Update' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function SettingsTab({ eventId, organizationId }: SettingsTabProp
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading event settings:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ export default function SettingsTab({ eventId, organizationId }: SettingsTabProp
|
|||||||
alert('Failed to save settings');
|
alert('Failed to save settings');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving settings:', error);
|
|
||||||
alert('Failed to save settings');
|
alert('Failed to save settings');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -241,7 +241,11 @@ export default function SettingsTab({ eventId, organizationId }: SettingsTabProp
|
|||||||
<h3 className="text-lg font-semibold text-white">Custom Fields</h3>
|
<h3 className="text-lg font-semibold text-white">Custom Fields</h3>
|
||||||
<button
|
<button
|
||||||
onClick={addCustomField}
|
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
|
Add Field
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import type { EventData } from '../../lib/event-management';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: React.ReactNode;
|
||||||
component: React.ComponentType<any>;
|
component: React.ComponentType<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabNavigationProps {
|
interface TabNavigationProps {
|
||||||
@@ -13,6 +14,8 @@ interface TabNavigationProps {
|
|||||||
onTabChange: (tabId: string) => void;
|
onTabChange: (tabId: string) => void;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
eventData: EventData | null;
|
||||||
|
eventSlug?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabNavigation({
|
export default function TabNavigation({
|
||||||
@@ -20,7 +23,9 @@ export default function TabNavigation({
|
|||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
eventId,
|
eventId,
|
||||||
organizationId
|
organizationId,
|
||||||
|
eventData,
|
||||||
|
eventSlug
|
||||||
}: TabNavigationProps) {
|
}: TabNavigationProps) {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
@@ -32,11 +37,14 @@ export default function TabNavigation({
|
|||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="border-b border-white/20">
|
<div className="border-b border-white/20">
|
||||||
{/* Mobile Tab Dropdown */}
|
{/* Mobile Tab Dropdown */}
|
||||||
<div className="md:hidden px-4 py-3">
|
<div className="lg:hidden px-4 py-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
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 className="flex items-center gap-2">
|
||||||
<span>{currentTab?.icon}</span>
|
<span>{currentTab?.icon}</span>
|
||||||
@@ -75,16 +83,20 @@ export default function TabNavigation({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Tab Navigation */}
|
{/* 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) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => onTabChange(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 ${
|
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
|
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'
|
: '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.icon}</span>
|
||||||
<span>{tab.name}</span>
|
<span>{tab.name}</span>
|
||||||
@@ -95,11 +107,23 @@ export default function TabNavigation({
|
|||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="p-6 min-h-[600px]">
|
<div className="p-6 min-h-[600px]">
|
||||||
{CurrentTabComponent && (
|
{CurrentTabComponent && eventData ? (
|
||||||
<CurrentTabComponent
|
<CurrentTabComponent
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
organizationId={organizationId}
|
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>
|
||||||
</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);
|
setTicketTypes(ticketTypesData);
|
||||||
setSalesData(salesDataResult);
|
setSalesData(salesDataResult);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading tickets data:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -48,132 +48,137 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTicketType = async (ticketType: TicketType) => {
|
const handleDeleteTicketType = async (ticketTypeId: string) => {
|
||||||
if (confirm(`Are you sure you want to delete "${ticketType.name}"?`)) {
|
if (!confirm('Are you sure you want to delete this ticket type? This action cannot be undone.')) {
|
||||||
const success = await deleteTicketType(ticketType.id);
|
return;
|
||||||
if (success) {
|
}
|
||||||
setTicketTypes(prev => prev.filter(t => t.id !== ticketType.id));
|
|
||||||
}
|
try {
|
||||||
|
await deleteTicketType(ticketTypeId);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleTicketType = async (ticketType: TicketType) => {
|
const handleToggleStatus = async (ticketType: TicketType) => {
|
||||||
const success = await toggleTicketTypeStatus(ticketType.id, !ticketType.is_active);
|
try {
|
||||||
if (success) {
|
await toggleTicketTypeStatus(ticketType.id, !ticketType.is_active);
|
||||||
setTicketTypes(prev => prev.map(t =>
|
await loadData();
|
||||||
t.id === ticketType.id ? { ...t, is_active: !t.is_active } : t
|
} catch (error) {
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalSave = (ticketType: TicketType) => {
|
const getSalesStats = (ticketTypeId: string) => {
|
||||||
if (editingTicketType) {
|
const salesMetrics = calculateSalesMetrics(salesData.filter(sale => sale.ticket_type_id === ticketTypeId));
|
||||||
setTicketTypes(prev => prev.map(t =>
|
return {
|
||||||
t.id === ticketType.id ? ticketType : t
|
sold: salesMetrics.totalTickets,
|
||||||
));
|
revenue: salesMetrics.totalRevenue,
|
||||||
} else {
|
available: ticketTypes.find(tt => tt.id === ticketTypeId)?.available || 0
|
||||||
setTicketTypes(prev => [...prev, ticketType]);
|
};
|
||||||
}
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTicketTypeStats = (ticketType: TicketType) => {
|
const renderTicketCard = (ticketType: TicketType) => {
|
||||||
const typeSales = salesData.filter(sale =>
|
const stats = getSalesStats(ticketType.id);
|
||||||
sale.ticket_type_id === ticketType.id && sale.status === 'confirmed'
|
const percentage = ticketType.quantity > 0
|
||||||
);
|
? (stats.sold / ticketType.quantity) * 100
|
||||||
const sold = typeSales.length;
|
: 0;
|
||||||
const revenue = typeSales.reduce((sum, sale) => sum + sale.price_paid, 0);
|
|
||||||
const available = ticketType.quantity - sold;
|
|
||||||
|
|
||||||
return { sold, revenue, available };
|
const isActive = ticketType.is_active && ticketType.sale_start <= new Date() &&
|
||||||
};
|
(!ticketType.sale_end || ticketType.sale_end >= new Date());
|
||||||
|
|
||||||
const renderTicketTypeCard = (ticketType: TicketType) => {
|
|
||||||
const stats = getTicketTypeStats(ticketType);
|
|
||||||
const percentage = ticketType.quantity > 0 ? (stats.sold / ticketType.quantity) * 100 : 0;
|
|
||||||
|
|
||||||
return (
|
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 justify-between items-start mb-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="text-xl font-semibold text-white">{ticketType.name}</h3>
|
<h3 className="text-xl font-semibold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
{ticketType.name}
|
||||||
ticketType.is_active
|
</h3>
|
||||||
? 'bg-green-500/20 text-green-300 border border-green-500/30'
|
<span
|
||||||
: 'bg-red-500/20 text-red-300 border border-red-500/30'
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
}`}>
|
isActive ? 'premium-success' : 'premium-error'
|
||||||
{ticketType.is_active ? 'Active' : 'Inactive'}
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ticketType.description && (
|
{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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-2xl font-bold mb-2" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
<button
|
{formatCurrency(ticketType.price)}
|
||||||
onClick={() => handleEditTicketType(ticketType)}
|
{ticketType.fees_included && <span className="text-sm font-normal" style={{ color: 'var(--glass-text-tertiary)' }}> (fees included)</span>}
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="flex gap-2 mb-4">
|
||||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
<button
|
||||||
<div>
|
onClick={() => handleEditTicketType(ticketType)}
|
||||||
<div className="text-white/60">Sold</div>
|
className="p-2 transition-colors hover:opacity-80"
|
||||||
<div className="text-white font-semibold">{stats.sold}</div>
|
style={{ color: 'var(--glass-text-tertiary)' }}
|
||||||
</div>
|
>
|
||||||
<div>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div className="text-white/60">Available</div>
|
<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" />
|
||||||
<div className="text-white font-semibold">{stats.available}</div>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
<div>
|
<button
|
||||||
<div className="text-white/60">Revenue</div>
|
onClick={() => handleToggleStatus(ticketType)}
|
||||||
<div className="text-white font-semibold">{formatCurrency(stats.revenue)}</div>
|
className="p-2 transition-colors hover:opacity-80"
|
||||||
</div>
|
style={{ color: 'var(--glass-text-tertiary)' }}
|
||||||
</div>
|
>
|
||||||
|
{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
|
<div
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full transition-all duration-300"
|
className="h-2 rounded-full bg-gradient-to-r from-blue-600 to-purple-600"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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})
|
{percentage.toFixed(1)}% sold ({stats.sold} of {ticketType.quantity})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,81 +186,84 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTicketTypeList = (ticketType: TicketType) => {
|
const renderTicketRow = (ticketType: TicketType) => {
|
||||||
const stats = getTicketTypeStats(ticketType);
|
const stats = getSalesStats(ticketType.id);
|
||||||
const percentage = ticketType.quantity > 0 ? (stats.sold / ticketType.quantity) * 100 : 0;
|
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 (
|
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">
|
<td className="py-4 px-4">
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<div>
|
<div className="font-semibold" style={{ color: 'var(--glass-text-primary)' }}>{ticketType.name}</div>
|
||||||
<div className="font-semibold text-white">{ticketType.name}</div>
|
{ticketType.description && (
|
||||||
{ticketType.description && (
|
<div className="text-sm" style={{ color: 'var(--glass-text-secondary)' }}>{ticketType.description}</div>
|
||||||
<div className="text-white/60 text-sm">{ticketType.description}</div>
|
)}
|
||||||
)}
|
<span
|
||||||
</div>
|
className={`inline-block mt-1 px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
isActive ? 'premium-success' : 'premium-error'
|
||||||
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'
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
}`}>
|
|
||||||
{ticketType.is_active ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-4 text-white font-semibold">
|
<td className="py-4 px-4 font-semibold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
{formatCurrency(ticketType.price_cents)}
|
{formatCurrency(ticketType.price)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-4 text-white">{stats.sold}</td>
|
<td className="py-4 px-4" style={{ color: 'var(--glass-text-primary)' }}>{stats.sold}</td>
|
||||||
<td className="py-4 px-4 text-white">{stats.available}</td>
|
<td className="py-4 px-4" style={{ color: 'var(--glass-text-primary)' }}>{stats.available}</td>
|
||||||
<td className="py-4 px-4 text-white font-semibold">
|
<td className="py-4 px-4 font-semibold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
{formatCurrency(stats.revenue)}
|
{formatCurrency(stats.revenue)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-4">
|
<td className="py-4 px-4">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<div
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full"
|
className="h-2 rounded-full bg-gradient-to-r from-blue-600 to-purple-600"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-4">
|
<td className="py-4 px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditTicketType(ticketType)}
|
onClick={() => handleEditTicketType(ticketType)}
|
||||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
className="p-2 transition-colors hover:opacity-80"
|
||||||
title="Edit"
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleTicketType(ticketType)}
|
onClick={() => handleToggleStatus(ticketType)}
|
||||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
className="p-2 transition-colors hover:opacity-80"
|
||||||
title={ticketType.is_active ? 'Deactivate' : 'Activate'}
|
style={{ color: 'var(--glass-text-tertiary)' }}
|
||||||
>
|
>
|
||||||
{ticketType.is_active ? (
|
{ticketType.is_active ? (
|
||||||
<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="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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<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="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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteTicketType(ticketType)}
|
onClick={() => handleDeleteTicketType(ticketType.id)}
|
||||||
className="p-2 text-white/60 hover:text-red-400 transition-colors"
|
className="p-2 transition-colors hover:opacity-80"
|
||||||
title="Delete"
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -268,7 +276,7 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -276,35 +284,48 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-2xl font-light text-white">Ticket Types & Pricing</h2>
|
<h2 className="text-2xl font-light" style={{ color: 'var(--glass-text-primary)' }}>Ticket Types & Pricing</h2>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center bg-white/10 rounded-lg p-1">
|
<div className="flex items-center rounded-lg p-1" style={{ background: 'var(--glass-bg)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('card')}
|
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'
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
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'
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateTicketType}
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
Add Ticket Type
|
Add Ticket Type
|
||||||
@@ -313,14 +334,15 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ticketTypes.length === 0 ? (
|
{ticketTypes.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12 rounded-xl glass-card">
|
||||||
<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 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" />
|
<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>
|
</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
|
<button
|
||||||
onClick={handleCreateTicketType}
|
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
|
Create Your First Ticket Type
|
||||||
</button>
|
</button>
|
||||||
@@ -328,25 +350,25 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{viewMode === 'card' ? (
|
{viewMode === 'card' ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{ticketTypes.map(renderTicketTypeCard)}
|
{ticketTypes.map(renderTicketCard)}
|
||||||
</div>
|
</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">
|
<table className="w-full">
|
||||||
<thead className="bg-white/10">
|
<thead style={{ background: 'var(--glass-bg-lg)' }}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4 text-white/80 font-medium">Name</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 text-white/80 font-medium">Price</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 text-white/80 font-medium">Sold</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 text-white/80 font-medium">Available</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 text-white/80 font-medium">Revenue</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 text-white/80 font-medium">Progress</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 text-white/80 font-medium">Actions</th>
|
<th className="text-left py-3 px-4 font-medium" style={{ color: 'var(--glass-text-secondary)' }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ticketTypes.map(renderTicketTypeList)}
|
{ticketTypes.map(renderTicketRow)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,13 +376,14 @@ export default function TicketsTab({ eventId }: TicketsTabProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TicketTypeModal
|
{showModal && (
|
||||||
isOpen={showModal}
|
<TicketTypeModal
|
||||||
onClose={() => setShowModal(false)}
|
eventId={eventId}
|
||||||
onSave={handleModalSave}
|
ticketType={editingTicketType}
|
||||||
eventId={eventId}
|
onClose={() => setShowModal(false)}
|
||||||
ticketType={editingTicketType}
|
onSave={loadData}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -31,17 +31,30 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const [mapsData, eventData] = await Promise.all([
|
const [mapsData, eventData] = await Promise.all([
|
||||||
loadSeatingMaps(organizationId),
|
loadSeatingMaps(organizationId),
|
||||||
loadEventData(eventId, organizationId)
|
loadEventData(eventId, organizationId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSeatingMaps(mapsData);
|
// Validate seating maps data
|
||||||
setVenueData(eventData?.venue_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);
|
setCurrentSeatingMap(eventData?.seating_map || null);
|
||||||
setSeatingType(eventData?.seating_map ? 'assigned' : 'general');
|
setSeatingType(eventData?.seating_type === 'assigned' ? 'assigned' : 'general');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading venue data:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -97,7 +110,9 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderSeatingPreview = (seatingMap: SeatingMap) => {
|
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);
|
const totalCapacity = layoutItems.reduce((sum, item) => sum + (item.capacity || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,7 +195,10 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApplySeatingMap(seatingMap)}
|
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
|
Apply to Event
|
||||||
</button>
|
</button>
|
||||||
@@ -205,7 +223,11 @@ export default function VenueTab({ eventId, organizationId }: VenueTabProps) {
|
|||||||
<h2 className="text-2xl font-light text-white">Venue & Seating</h2>
|
<h2 className="text-2xl font-light text-white">Venue & Seating</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateSeatingMap}
|
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">
|
<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" />
|
<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>
|
<p className="text-white/60 mb-4">No seating maps created yet</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateSeatingMap}
|
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
|
Create Your First Seating Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function EmbedCodeModal({
|
|||||||
setCopied(type);
|
setCopied(type);
|
||||||
setTimeout(() => setCopied(null), 2000);
|
setTimeout(() => setCopied(null), 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,21 +68,22 @@ export default function EmbedCodeModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
<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="p-6">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-2xl font-light text-white">Embed Event Widget</h2>
|
<h2 className="text-2xl font-light text-white">Embed Event Widget</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Configuration Panel */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -100,7 +101,11 @@ export default function EmbedCodeModal({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(directLink, 'link')}
|
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'}
|
{copied === 'link' ? '✓' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
@@ -224,7 +229,11 @@ export default function EmbedCodeModal({
|
|||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(generateEmbedCode(), 'embed')}
|
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'}
|
{copied === 'embed' ? '✓ Copied' : 'Copy Code'}
|
||||||
</button>
|
</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>();
|
const attendeeMap = new Map<string, AttendeeData>();
|
||||||
|
|
||||||
orders.forEach(order => {
|
orders.forEach(order => {
|
||||||
const existing = attendeeMap.get(order.customer_email) || {
|
const existing = attendeeMap.get(order.purchaser_email) || {
|
||||||
email: order.customer_email,
|
email: order.purchaser_email,
|
||||||
name: order.customer_name,
|
name: order.purchaser_name,
|
||||||
ticketCount: 0,
|
ticketCount: 0,
|
||||||
totalSpent: 0,
|
totalSpent: 0,
|
||||||
checkedInCount: 0,
|
checkedInCount: 0,
|
||||||
@@ -45,18 +45,19 @@ export default function AttendeesTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
existing.tickets.push(order);
|
existing.tickets.push(order);
|
||||||
if (order.status === 'confirmed') {
|
if (!order.refund_status || order.refund_status === null) {
|
||||||
existing.ticketCount += 1;
|
existing.ticketCount += 1;
|
||||||
existing.totalSpent += order.price_paid;
|
existing.totalSpent += order.price;
|
||||||
if (order.checked_in) {
|
if (order.checked_in) {
|
||||||
existing.checkedInCount += 1;
|
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]);
|
}, [orders]);
|
||||||
|
|
||||||
const sortedAttendees = useMemo(() => {
|
const sortedAttendees = useMemo(() => {
|
||||||
|
|||||||
@@ -54,29 +54,32 @@ export default function OrdersTable({
|
|||||||
const SortIcon = ({ field }: { field: keyof SalesData }) => {
|
const SortIcon = ({ field }: { field: keyof SalesData }) => {
|
||||||
if (sortField !== field) {
|
if (sortField !== field) {
|
||||||
return (
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortDirection === 'asc' ? (
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
</svg>
|
</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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</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 = {
|
const statusClasses = {
|
||||||
confirmed: 'bg-green-500/20 text-green-300 border-green-500/30',
|
confirmed: 'bg-[var(--success-bg)] text-[var(--success-color)] border-[var(--success-border)]',
|
||||||
pending: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30',
|
pending: 'bg-[var(--warning-bg)] text-[var(--warning-color)] border-[var(--warning-border)]',
|
||||||
refunded: 'bg-red-500/20 text-red-300 border-red-500/30',
|
refunded: 'bg-[var(--error-bg)] text-[var(--error-color)] border-[var(--error-border)]',
|
||||||
cancelled: 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
cancelled: 'bg-[var(--ui-bg-secondary)] text-[var(--ui-text-tertiary)] border-[var(--ui-border-secondary)]'
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,10 +102,10 @@ export default function OrdersTable({
|
|||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<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" />
|
<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>
|
</svg>
|
||||||
<p className="text-white/60">No orders found</p>
|
<p className="text-[var(--ui-text-tertiary)]">No orders found</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,102 +115,102 @@ export default function OrdersTable({
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/20">
|
<tr className="border-b border-[var(--ui-border-primary)]">
|
||||||
<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
|
<button
|
||||||
onClick={() => handleSort('customer_name')}
|
onClick={() => handleSort('purchaser_name')}
|
||||||
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>Customer</span>
|
<span>Customer</span>
|
||||||
<SortIcon field="customer_name" />
|
<SortIcon field="purchaser_name" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</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
|
<button
|
||||||
onClick={() => handleSort('ticket_types')}
|
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>
|
<span>Ticket Type</span>
|
||||||
<SortIcon field="ticket_types" />
|
<SortIcon field="ticket_types" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</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
|
<button
|
||||||
onClick={() => handleSort('price_paid')}
|
onClick={() => handleSort('price')}
|
||||||
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>Amount</span>
|
<span>Amount</span>
|
||||||
<SortIcon field="price_paid" />
|
<SortIcon field="price" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</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
|
<button
|
||||||
onClick={() => handleSort('status')}
|
onClick={() => handleSort('refund_status')}
|
||||||
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>Status</span>
|
<span>Status</span>
|
||||||
<SortIcon field="status" />
|
<SortIcon field="refund_status" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
{showCheckIn && (
|
{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
|
<button
|
||||||
onClick={() => handleSort('checked_in')}
|
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>
|
<span>Check-in</span>
|
||||||
<SortIcon field="checked_in" />
|
<SortIcon field="checked_in" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</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
|
<button
|
||||||
onClick={() => handleSort('created_at')}
|
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>
|
<span>Date</span>
|
||||||
<SortIcon field="created_at" />
|
<SortIcon field="created_at" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
{showActions && (
|
{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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedOrders.map((order) => (
|
{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">
|
<td className="py-3 px-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-white font-medium">{order.customer_name}</div>
|
<div className="text-[var(--ui-text-primary)] font-medium">{order.purchaser_name}</div>
|
||||||
<div className="text-white/60 text-sm">{order.customer_email}</div>
|
<div className="text-[var(--ui-text-tertiary)] text-sm">{order.purchaser_email}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<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>
|
||||||
<td className="py-3 px-4">
|
<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>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
{getStatusBadge(order.status)}
|
{getStatusBadge(order.refund_status)}
|
||||||
</td>
|
</td>
|
||||||
{showCheckIn && (
|
{showCheckIn && (
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
{order.checked_in ? (
|
{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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
Checked In
|
Checked In
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-white/60">Not Checked In</span>
|
<span className="text-[var(--ui-text-tertiary)]">Not Checked In</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="py-3 px-4">
|
<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>
|
</td>
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
@@ -215,7 +218,7 @@ export default function OrdersTable({
|
|||||||
{onViewOrder && (
|
{onViewOrder && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onViewOrder(order)}
|
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"
|
title="View Details"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -224,10 +227,10 @@ export default function OrdersTable({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onCheckIn && !order.checked_in && order.status === 'confirmed' && (
|
{onCheckIn && !order.checked_in && (!order.refund_status || order.refund_status === null) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onCheckIn(order)}
|
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"
|
title="Check In"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -235,10 +238,10 @@ export default function OrdersTable({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onRefundOrder && order.status === 'confirmed' && (
|
{onRefundOrder && (!order.refund_status || order.refund_status === null) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onRefundOrder(order)}
|
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"
|
title="Refund"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -258,24 +261,24 @@ export default function OrdersTable({
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between">
|
<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
|
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, orders.length)} of {orders.length} orders
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||||
disabled={currentPage === 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
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span className="text-white/80 text-sm">
|
<span className="text-[var(--ui-text-secondary)] text-sm">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||||
disabled={currentPage === totalPages}
|
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
|
Next
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,6 +17,32 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<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>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col">
|
<body class="min-h-screen flex flex-col">
|
||||||
<!-- Skip Links for Accessibility -->
|
<!-- Skip Links for Accessibility -->
|
||||||
@@ -31,8 +57,47 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
<Footer />
|
<Footer />
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
|
||||||
<!-- Initialize accessibility features -->
|
<!-- Initialize theme management, accessibility, and performance optimizations -->
|
||||||
<script>
|
<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';
|
import { initializeAccessibility, initializeHighContrastSupport, initializeReducedMotionSupport } from '../lib/accessibility';
|
||||||
|
|
||||||
// Initialize all accessibility features
|
// Initialize all accessibility features
|
||||||
|
|||||||
@@ -16,6 +16,43 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<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>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
<!-- Skip Links for Accessibility -->
|
<!-- Skip Links for Accessibility -->
|
||||||
@@ -25,8 +62,31 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
|
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
|
||||||
<!-- Initialize accessibility features -->
|
<!-- Initialize theme management and accessibility features -->
|
||||||
<script>
|
<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';
|
import { initializeAccessibility, initializeHighContrastSupport, initializeReducedMotionSupport } from '../lib/accessibility';
|
||||||
|
|
||||||
// Initialize all accessibility features
|
// Initialize all accessibility features
|
||||||
|
|||||||
@@ -15,13 +15,44 @@ import Navigation from '../components/Navigation.astro';
|
|||||||
|
|
||||||
<Layout title={title}>
|
<Layout title={title}>
|
||||||
<style>
|
<style>
|
||||||
.bg-grid-pattern {
|
[data-theme="dark"] .bg-grid-pattern {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, 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;
|
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 {
|
@keyframes fadeInUp {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -51,12 +82,12 @@ import Navigation from '../components/Navigation.astro';
|
|||||||
}
|
}
|
||||||
</style>
|
</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 -->
|
<!-- Animated background elements -->
|
||||||
<div class="fixed inset-0 overflow-hidden pointer-events-none">
|
<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 -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 bg-gradient-to-br from-blue-600/20 to-cyan-600/20 rounded-full blur-3xl animate-pulse"></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 bg-gradient-to-br from-indigo-600/10 to-purple-600/10 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 rounded-full blur-3xl animate-pulse bg-orb-light-3" style="background: var(--bg-orb-3);"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grid pattern overlay -->
|
<!-- Grid pattern overlay -->
|
||||||
@@ -79,4 +110,15 @@ import Navigation from '../components/Navigation.astro';
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</Layout>
|
||||||
@@ -186,7 +186,7 @@ export function addKeyboardNavigation() {
|
|||||||
*/
|
*/
|
||||||
export function validateColorContrast() {
|
export function validateColorContrast() {
|
||||||
// This would typically integrate with a color contrast checking library
|
// 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';
|
status: 'active' | 'cancelled' | 'expired';
|
||||||
purchased_at: string;
|
purchased_at: string;
|
||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddOnWithAccess extends AddOnType {
|
export interface AddOnWithAccess extends AddOnType {
|
||||||
@@ -46,7 +46,7 @@ export async function getAvailableAddOns(
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return data.map((item: any) => ({
|
return data.map((item: Record<string, unknown>) => ({
|
||||||
id: item.addon_id,
|
id: item.addon_id,
|
||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -62,7 +62,7 @@ export async function getAvailableAddOns(
|
|||||||
purchased_at: item.purchased_at
|
purchased_at: item.purchased_at
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching available add-ons:', error);
|
console.error('Available add-ons loading error:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ export async function hasFeatureAccess(
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data === true;
|
return data === true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking feature access:', error);
|
console.error('Feature access check error:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ export async function purchaseEventAddOn(
|
|||||||
addOnTypeId: string,
|
addOnTypeId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
priceCents: number,
|
priceCents: number,
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, unknown>
|
||||||
): Promise<{ success: boolean; addOnId?: string; error?: string }> {
|
): Promise<{ success: boolean; addOnId?: string; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -115,7 +115,7 @@ export async function purchaseEventAddOn(
|
|||||||
|
|
||||||
return { success: true, addOnId: data.id };
|
return { success: true, addOnId: data.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error purchasing add-on:', error);
|
console.error('Add-on purchase error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
@@ -143,7 +143,7 @@ export async function getEventAddOns(eventId: string): Promise<EventAddOn[]> {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data || [];
|
return data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching event add-ons:', error);
|
console.error('Event add-ons loading error:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ export async function calculateAddOnRevenue(organizationId: string): Promise<{
|
|||||||
if (subError) throw subError;
|
if (subError) throw subError;
|
||||||
|
|
||||||
const subscriptionRevenue = (subscriptions || [])
|
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 {
|
return {
|
||||||
totalRevenue: eventRevenue + subscriptionRevenue,
|
totalRevenue: eventRevenue + subscriptionRevenue,
|
||||||
@@ -233,7 +233,7 @@ export async function calculateAddOnRevenue(organizationId: string): Promise<{
|
|||||||
subscriptionRevenue
|
subscriptionRevenue
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error calculating add-on revenue:', error);
|
console.error('Add-on revenue calculation error:', error);
|
||||||
return {
|
return {
|
||||||
totalRevenue: 0,
|
totalRevenue: 0,
|
||||||
eventAddOns: 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);
|
return parseGeneratedDescription(generatedText);
|
||||||
} catch (error) {
|
} 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.');
|
throw new Error('Failed to generate AI event description. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ etc.`;
|
|||||||
.filter(title => title.length > 0);
|
.filter(title => title.length > 0);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating title suggestions:', error);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,5 +264,5 @@ export async function trackAIUsage(
|
|||||||
cost: number
|
cost: number
|
||||||
) {
|
) {
|
||||||
// This could be logged to analytics or usage tracking system
|
// 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 { logSecurityEvent, logUserActivity } from './logger';
|
||||||
import type { User, Session } from '@supabase/supabase-js';
|
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 {
|
export interface AuthContext {
|
||||||
user: User;
|
user: User;
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -29,7 +32,26 @@ export async function verifyAuth(request: Request): Promise<AuthContext | null>
|
|||||||
// Try cookies if no auth header
|
// Try cookies if no auth header
|
||||||
if (!accessToken && cookieHeader) {
|
if (!accessToken && cookieHeader) {
|
||||||
const cookies = parseCookies(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) {
|
if (!accessToken) {
|
||||||
@@ -84,7 +106,7 @@ export async function verifyAuth(request: Request): Promise<AuthContext | null>
|
|||||||
organizationId: userRecord?.organization_id
|
organizationId: userRecord?.organization_id
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth verification error:', error);
|
console.error('Error verifying auth:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export class BackupManager {
|
|||||||
backupData[table] = data || [];
|
backupData[table] = data || [];
|
||||||
totalSize += JSON.stringify(data).length;
|
totalSize += JSON.stringify(data).length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error backing up table ${table}:`, error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,15 +235,14 @@ export class BackupManager {
|
|||||||
const tablesToRestore = options.tables || backup.metadata.tables;
|
const tablesToRestore = options.tables || backup.metadata.tables;
|
||||||
|
|
||||||
if (options.dryRun) {
|
if (options.dryRun) {
|
||||||
console.log('DRY RUN: Would restore tables:', tablesToRestore);
|
|
||||||
console.log('Backup metadata:', backup.metadata);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore each table
|
// Restore each table
|
||||||
for (const table of tablesToRestore) {
|
for (const table of tablesToRestore) {
|
||||||
if (!backup.data[table]) {
|
if (!backup.data[table]) {
|
||||||
console.warn(`Table ${table} not found in backup`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +266,8 @@ export class BackupManager {
|
|||||||
throw new Error(`Failed to restore table ${table}: ${insertError.message}`);
|
throw new Error(`Failed to restore table ${table}: ${insertError.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Restored ${backup.data[table].length} rows to table ${table}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error restoring table ${table}:`, error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +325,7 @@ export class BackupManager {
|
|||||||
backups.push(metadata);
|
backups.push(metadata);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to get metadata for backup ${file.name}:`, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,12 +383,12 @@ export class BackupManager {
|
|||||||
.remove([fileName]);
|
.remove([fileName]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`Failed to delete backup ${backupId}:`, error);
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`Deleted old backup: ${backupId}`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting backup ${backupId}:`, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +446,7 @@ export class BackupManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
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
|
// Monthly backups on the 1st at 4 AM
|
||||||
this.scheduleBackup('monthly', '0 4 1 * *', 'monthly');
|
this.scheduleBackup('monthly', '0 4 1 * *', 'monthly');
|
||||||
|
|
||||||
console.log('Backup scheduler started');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -508,7 +505,7 @@ export class BackupScheduler {
|
|||||||
stopScheduledBackups() {
|
stopScheduledBackups() {
|
||||||
for (const [name, interval] of this.intervals) {
|
for (const [name, interval] of this.intervals) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
console.log(`Stopped ${name} backup schedule`);
|
|
||||||
}
|
}
|
||||||
this.intervals.clear();
|
this.intervals.clear();
|
||||||
}
|
}
|
||||||
@@ -522,14 +519,13 @@ export class BackupScheduler {
|
|||||||
|
|
||||||
const runBackup = async () => {
|
const runBackup = async () => {
|
||||||
try {
|
try {
|
||||||
console.log(`Starting ${name} backup...`);
|
|
||||||
await this.backupManager.createBackup(type);
|
await this.backupManager.createBackup(type);
|
||||||
console.log(`${name} backup completed successfully`);
|
|
||||||
|
|
||||||
// Cleanup old backups after successful backup
|
// Cleanup old backups after successful backup
|
||||||
await this.backupManager.cleanupBackups();
|
await this.backupManager.cleanupBackups();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${name} backup failed:`, error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class CanvasImageGenerator {
|
|||||||
|
|
||||||
this.ctx.drawImage(logoImage, logoX, logoY, logoSize, logoSize);
|
this.ctx.drawImage(logoImage, logoX, logoY, logoSize, logoSize);
|
||||||
} catch (error) {
|
} 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
|
// Validate email configuration
|
||||||
if (!process.env.RESEND_API_KEY) {
|
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 {
|
export interface TicketEmailData {
|
||||||
@@ -60,6 +60,35 @@ export interface OrderConfirmationData {
|
|||||||
refundPolicy?: string;
|
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
|
* Generate QR code data URL for email
|
||||||
*/
|
*/
|
||||||
@@ -79,7 +108,7 @@ async function generateQRCodeDataURL(ticketUuid: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
return qrCodeDataURL;
|
return qrCodeDataURL;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating QR code:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,7 +441,7 @@ function createOrderConfirmationHTML(data: OrderConfirmationData): string {
|
|||||||
*/
|
*/
|
||||||
export async function sendTicketConfirmationEmail(ticketData: TicketEmailData): Promise<void> {
|
export async function sendTicketConfirmationEmail(ticketData: TicketEmailData): Promise<void> {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
console.warn('Email service not configured. Skipping ticket confirmation email.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,9 +480,8 @@ export async function sendTicketConfirmationEmail(ticketData: TicketEmailData):
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Ticket confirmation email sent successfully:', data?.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending ticket confirmation email:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,7 +491,7 @@ export async function sendTicketConfirmationEmail(ticketData: TicketEmailData):
|
|||||||
*/
|
*/
|
||||||
export async function sendOrderConfirmationEmail(orderData: OrderConfirmationData): Promise<void> {
|
export async function sendOrderConfirmationEmail(orderData: OrderConfirmationData): Promise<void> {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
console.warn('Email service not configured. Skipping order confirmation email.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +520,8 @@ export async function sendOrderConfirmationEmail(orderData: OrderConfirmationDat
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Order confirmation email sent successfully:', data?.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending order confirmation email:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,10 +563,10 @@ export async function sendOrganizerNotificationEmail(data: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error sending organizer notification:', error);
|
|
||||||
}
|
}
|
||||||
} catch (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
|
// We expect this to fail with invalid email, but connection should work
|
||||||
return error?.message?.includes('Invalid') || false;
|
return error?.message?.includes('Invalid') || false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Email configuration test failed:', error);
|
|
||||||
return false;
|
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;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
date: string;
|
start_time: string;
|
||||||
venue: string;
|
venue: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
venue_data?: any;
|
seating_map_id?: string | null;
|
||||||
seating_map_id?: string;
|
seating_type?: string;
|
||||||
|
created_by?: string;
|
||||||
|
created_at?: string;
|
||||||
|
end_time?: string;
|
||||||
|
is_published?: boolean;
|
||||||
|
is_public?: boolean;
|
||||||
seating_map?: any;
|
seating_map?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,18 +34,12 @@ export interface EventStats {
|
|||||||
|
|
||||||
export async function loadEventData(eventId: string, organizationId: string): Promise<EventData | null> {
|
export async function loadEventData(eventId: string, organizationId: string): Promise<EventData | null> {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
// First try to load the event by ID only
|
||||||
const { data: event, error } = await supabase
|
const { data: event, error } = await supabase
|
||||||
.from('events')
|
.from('events')
|
||||||
.select(`
|
.select(`
|
||||||
id,
|
*,
|
||||||
title,
|
|
||||||
description,
|
|
||||||
date,
|
|
||||||
venue,
|
|
||||||
slug,
|
|
||||||
organization_id,
|
|
||||||
venue_data,
|
|
||||||
seating_map_id,
|
|
||||||
seating_maps (
|
seating_maps (
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -48,11 +47,36 @@ export async function loadEventData(eventId: string, organizationId: string): Pr
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('id', eventId)
|
.eq('id', eventId)
|
||||||
.eq('organization_id', organizationId)
|
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +85,7 @@ export async function loadEventData(eventId: string, organizationId: string): Pr
|
|||||||
seating_map: event.seating_maps
|
seating_map: event.seating_maps
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading event data:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,41 +97,40 @@ export async function loadEventStats(eventId: string): Promise<EventStats> {
|
|||||||
.from('tickets')
|
.from('tickets')
|
||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
price_paid,
|
price,
|
||||||
checked_in,
|
checked_in,
|
||||||
ticket_types (
|
ticket_types (
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
price_cents,
|
price,
|
||||||
quantity
|
quantity_available
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('event_id', eventId)
|
.eq('event_id', eventId);
|
||||||
.eq('status', 'confirmed');
|
|
||||||
|
|
||||||
if (ticketsError) {
|
if (ticketsError) {
|
||||||
console.error('Error loading tickets:', ticketsError);
|
|
||||||
return getDefaultStats();
|
return getDefaultStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ticket types for availability calculation
|
// Get ticket types for availability calculation
|
||||||
const { data: ticketTypes, error: typesError } = await supabase
|
const { data: ticketTypes, error: typesError } = await supabase
|
||||||
.from('ticket_types')
|
.from('ticket_types')
|
||||||
.select('id, quantity')
|
.select('id, quantity_available')
|
||||||
.eq('event_id', eventId)
|
.eq('event_id', eventId)
|
||||||
.eq('is_active', true);
|
.eq('is_active', true);
|
||||||
|
|
||||||
if (typesError) {
|
if (typesError) {
|
||||||
console.error('Error loading ticket types:', typesError);
|
|
||||||
return getDefaultStats();
|
return getDefaultStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate stats
|
// Calculate stats
|
||||||
const ticketsSold = tickets?.length || 0;
|
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 netRevenue = totalRevenue * 0.97; // Assuming 3% platform fee
|
||||||
const checkedIn = tickets?.filter(ticket => ticket.checked_in).length || 0;
|
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;
|
const ticketsAvailable = totalCapacity - ticketsSold;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -118,7 +141,7 @@ export async function loadEventStats(eventId: string): Promise<EventStats> {
|
|||||||
checkedIn
|
checkedIn
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading event stats:', error);
|
|
||||||
return getDefaultStats();
|
return getDefaultStats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,13 +154,13 @@ export async function updateEventData(eventId: string, updates: Partial<EventDat
|
|||||||
.eq('id', eventId);
|
.eq('id', eventId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error updating event:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating event data:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ async function addScrapedEventToDatabase(eventDetails: ScrapedEventDetails): Pro
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (existingEvent) {
|
if (existingEvent) {
|
||||||
console.log(`Event ${eventId} already exists, skipping`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +275,6 @@ async function addScrapedEventToDatabase(eventDetails: ScrapedEventDetails): Pro
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Successfully added featured event: ${eventDetails.title}`);
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -289,7 +288,6 @@ async function addScrapedEventToDatabase(eventDetails: ScrapedEventDetails): Pro
|
|||||||
*/
|
*/
|
||||||
export async function runEventScraper(): Promise<{ success: boolean; message: string; newEvent?: ScrapedEventDetails }> {
|
export async function runEventScraper(): Promise<{ success: boolean; message: string; newEvent?: ScrapedEventDetails }> {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Starting event scraper...');
|
|
||||||
|
|
||||||
// Get current event slug
|
// Get current event slug
|
||||||
const currentSlug = await getCurrentEventSlug();
|
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
|
// Check if this is a new event
|
||||||
const lastSeenSlug = await loadLastSeenSlug();
|
const lastSeenSlug = await loadLastSeenSlug();
|
||||||
if (currentSlug === lastSeenSlug) {
|
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
|
// Add to database as featured event
|
||||||
const added = await addScrapedEventToDatabase(eventDetails);
|
const added = await addScrapedEventToDatabase(eventDetails);
|
||||||
if (!added) {
|
if (!added) {
|
||||||
@@ -418,7 +412,6 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Initialized scraper organization and user');
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ async function saveLastSyncTime(timestamp: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
||||||
if (!supabase) {
|
if (!supabase) {
|
||||||
console.log(`❌ No Supabase client for checking event ${firebaseId}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,18 +312,18 @@ async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log(`🔍 Event firebase-${firebaseId} not found in database: ${error.message}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
console.log(`✅ Event ${firebaseId} already exists: ${data.title}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`❌ Error checking event ${firebaseId}:`, error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +333,7 @@ async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
|||||||
*/
|
*/
|
||||||
async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boolean> {
|
async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boolean> {
|
||||||
if (!supabase) {
|
if (!supabase) {
|
||||||
console.log('❌ Supabase client not available for adding Firebase event');
|
|
||||||
logError('Supabase client not available for adding Firebase event');
|
logError('Supabase client not available for adding Firebase event');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -341,7 +341,6 @@ async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boole
|
|||||||
try {
|
try {
|
||||||
// Generate a proper UUID for the event ID (can't use string concatenation)
|
// Generate a proper UUID for the event ID (can't use string concatenation)
|
||||||
const eventId = crypto.randomUUID();
|
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
|
// Insert the new event as featured and public
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
@@ -366,16 +365,15 @@ async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boole
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log(`❌ Database insert failed for ${processedEvent.title}:`, error);
|
|
||||||
logError('Failed to insert Firebase event into database', error);
|
logError('Failed to insert Firebase event into database', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Added featured event: ${processedEvent.title} (${processedEvent.priceRange})`);
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`💥 Exception adding event ${processedEvent.title}:`, error);
|
|
||||||
logError('Error adding Firebase event to database', error);
|
logError('Error adding Firebase event to database', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -386,7 +384,6 @@ async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boole
|
|||||||
*/
|
*/
|
||||||
export async function runFirebaseEventScraper(): Promise<{ success: boolean; message: string; newEvents?: ProcessedEvent[] }> {
|
export async function runFirebaseEventScraper(): Promise<{ success: boolean; message: string; newEvents?: ProcessedEvent[] }> {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Starting Firebase event scraper...');
|
|
||||||
|
|
||||||
// Authenticate with Firebase
|
// Authenticate with Firebase
|
||||||
const idToken = await authenticateFirebase();
|
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
|
// Ensure scraper organization exists
|
||||||
try {
|
try {
|
||||||
const orgInitialized = await initializeScraperOrganization();
|
const orgInitialized = await initializeScraperOrganization();
|
||||||
@@ -416,11 +411,9 @@ export async function runFirebaseEventScraper(): Promise<{ success: boolean; mes
|
|||||||
debug: { step: 'organization_init_exception', error: orgError },
|
debug: { step: 'organization_init_exception', error: orgError },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
console.log('✅ Black Canyon Tickets organization ready');
|
|
||||||
|
|
||||||
// Fetch events from Firebase
|
// Fetch events from Firebase
|
||||||
const firebaseEvents = await fetchFirebaseEvents(idToken);
|
const firebaseEvents = await fetchFirebaseEvents(idToken);
|
||||||
console.log(`📅 Found ${firebaseEvents.length} events in Firebase`);
|
|
||||||
|
|
||||||
if (firebaseEvents.length === 0) {
|
if (firebaseEvents.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -432,25 +425,23 @@ export async function runFirebaseEventScraper(): Promise<{ success: boolean; mes
|
|||||||
// Process and filter new events
|
// Process and filter new events
|
||||||
const newEvents: ProcessedEvent[] = [];
|
const newEvents: ProcessedEvent[] = [];
|
||||||
|
|
||||||
console.log('🔍 Processing Firebase events...');
|
|
||||||
for (const firebaseEvent of firebaseEvents) {
|
for (const firebaseEvent of firebaseEvents) {
|
||||||
console.log(`📅 Processing: ${firebaseEvent.name} (ID: ${firebaseEvent.id})`);
|
|
||||||
|
|
||||||
const exists = await eventExistsInDatabase(firebaseEvent.id);
|
const exists = await eventExistsInDatabase(firebaseEvent.id);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
console.log(`🆕 Adding new event: ${firebaseEvent.name}`);
|
|
||||||
const processedEvent = processFirebaseEvent(firebaseEvent);
|
const processedEvent = processFirebaseEvent(firebaseEvent);
|
||||||
const added = await addEventToDatabase(processedEvent);
|
const added = await addEventToDatabase(processedEvent);
|
||||||
|
|
||||||
if (added) {
|
if (added) {
|
||||||
newEvents.push(processedEvent);
|
newEvents.push(processedEvent);
|
||||||
console.log(`✅ Successfully added: ${processedEvent.title}`);
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`❌ Failed to add: ${firebaseEvent.name}`);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`⏭️ Event already exists: ${firebaseEvent.name}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +505,7 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if scraper organization exists
|
// Check if scraper organization exists
|
||||||
console.log(`🔍 Checking for organization: ${SCRAPER_ORGANIZATION_ID}`);
|
|
||||||
const { data: existingOrg, error: checkError } = await supabase
|
const { data: existingOrg, error: checkError } = await supabase
|
||||||
.from('organizations')
|
.from('organizations')
|
||||||
.select('id')
|
.select('id')
|
||||||
@@ -522,12 +513,10 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (existingOrg) {
|
if (existingOrg) {
|
||||||
console.log('✅ Organization already exists');
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🆕 Creating new organization:', checkError?.message);
|
|
||||||
|
|
||||||
// Create scraper organization
|
// Create scraper organization
|
||||||
const { error: orgError } = await supabase
|
const { error: orgError } = await supabase
|
||||||
.from('organizations')
|
.from('organizations')
|
||||||
@@ -539,7 +528,7 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (orgError) {
|
if (orgError) {
|
||||||
console.log('❌ Failed to create organization:', orgError);
|
|
||||||
logError('Failed to create scraper organization', orgError);
|
logError('Failed to create scraper organization', orgError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -555,12 +544,11 @@ export async function initializeScraperOrganization(): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (userError) {
|
if (userError) {
|
||||||
console.log('❌ Failed to create user:', userError);
|
|
||||||
logError('Failed to create scraper user', userError);
|
logError('Failed to create scraper user', userError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Initialized Firebase scraper organization and user');
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class GeolocationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
console.warn('Geolocation is not supported by this browser');
|
|
||||||
resolve(null);
|
resolve(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export class GeolocationService {
|
|||||||
resolve(location);
|
resolve(location);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.warn('Error getting location:', error.message);
|
|
||||||
resolve(null);
|
resolve(null);
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
@@ -98,7 +98,7 @@ export class GeolocationService {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error getting IP location:', error);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ export class GeolocationService {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error geocoding address:', error);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -155,10 +155,10 @@ export class GeolocationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error saving location preference:', error);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving location preference:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ export class GeolocationService {
|
|||||||
locationSource: data.location_source
|
locationSource: data.location_source
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting location preference:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ export class GeolocationService {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('GPS location failed, trying IP geolocation:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.getLocationFromIP();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error generating marketing kit:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const supabase = createClient<Database>(
|
|||||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
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 {
|
export interface MarketingAsset {
|
||||||
id: string;
|
id: string;
|
||||||
event_id: string;
|
event_id: string;
|
||||||
@@ -20,7 +24,7 @@ export interface MarketingKitData {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
date: string;
|
start_time: string;
|
||||||
venue: string;
|
venue: string;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
};
|
};
|
||||||
@@ -34,10 +38,14 @@ export interface MarketingKitData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SocialMediaContent {
|
export interface SocialMediaContent {
|
||||||
|
id?: string;
|
||||||
platform: 'facebook' | 'twitter' | 'instagram' | 'linkedin';
|
platform: 'facebook' | 'twitter' | 'instagram' | 'linkedin';
|
||||||
content: string;
|
content: string;
|
||||||
hashtags: string[];
|
hashtags: string[];
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
|
tone?: 'professional' | 'casual' | 'exciting' | 'informative' | 'urgent';
|
||||||
|
votes?: number;
|
||||||
|
generated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplate {
|
export interface EmailTemplate {
|
||||||
@@ -52,34 +60,29 @@ export async function loadMarketingKit(eventId: string): Promise<MarketingKitDat
|
|||||||
// Load event data
|
// Load event data
|
||||||
const { data: event, error: eventError } = await supabase
|
const { data: event, error: eventError } = await supabase
|
||||||
.from('events')
|
.from('events')
|
||||||
.select('id, title, description, date, venue, image_url, social_links')
|
.select('id, title, description, start_time, venue, image_url')
|
||||||
.eq('id', eventId)
|
.eq('id', eventId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (eventError) {
|
if (eventError) {
|
||||||
console.error('Error loading event for marketing kit:', eventError);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing marketing assets
|
// Since marketing_kit_assets table doesn't exist, return empty assets
|
||||||
const { data: assets, error: assetsError } = await supabase
|
// This can be implemented later when the table is created
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event,
|
event: {
|
||||||
assets: assets || [],
|
...event,
|
||||||
social_links: event.social_links || {}
|
start_time: event.start_time || '',
|
||||||
|
description: event.description || ''
|
||||||
|
},
|
||||||
|
assets: [], // Empty assets for now
|
||||||
|
social_links: {}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading marketing kit:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,32 +103,26 @@ export async function generateMarketingKit(eventId: string): Promise<MarketingKi
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating marketing kit:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMarketingAsset(eventId: string, assetType: string, assetData: any): Promise<MarketingAsset | null> {
|
export async function saveMarketingAsset(eventId: string, assetType: string, assetData: any): Promise<MarketingAsset | null> {
|
||||||
try {
|
try {
|
||||||
const { data: asset, error } = await supabase
|
// Since marketing_kit_assets table doesn't exist, return a mock asset
|
||||||
.from('marketing_kit_assets')
|
// This can be implemented later when the table is created
|
||||||
.insert({
|
|
||||||
event_id: eventId,
|
|
||||||
asset_type: assetType,
|
|
||||||
asset_data: assetData,
|
|
||||||
asset_url: assetData.url || ''
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
return {
|
||||||
console.error('Error saving marketing asset:', error);
|
id: `temp-${Date.now()}`,
|
||||||
return null;
|
event_id: eventId,
|
||||||
}
|
asset_type: assetType as any,
|
||||||
|
asset_url: assetData.url || '',
|
||||||
return asset;
|
asset_data: assetData,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving marketing asset:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,13 +135,13 @@ export async function updateSocialLinks(eventId: string, socialLinks: Record<str
|
|||||||
.eq('id', eventId);
|
.eq('id', eventId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error updating social links:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating social links:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,7 +308,7 @@ export async function downloadAsset(assetUrl: string, filename: string): Promise
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
} catch (error) {
|
} 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
|
// Log slow queries
|
||||||
if (duration > 1000) { // Queries over 1 second
|
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', {
|
addBreadcrumb(`Slow query: ${query.substring(0, 100)}...`, 'database', 'warning', {
|
||||||
duration,
|
duration,
|
||||||
table
|
table
|
||||||
@@ -158,7 +158,7 @@ export class APIMonitor {
|
|||||||
|
|
||||||
// Log slow API calls
|
// Log slow API calls
|
||||||
if (duration > 5000) { // API calls over 5 seconds
|
if (duration > 5000) { // API calls over 5 seconds
|
||||||
console.warn(`Slow API call: ${key} took ${duration}ms`);
|
|
||||||
addBreadcrumb(`Slow API call: ${key}`, 'http', 'warning', {
|
addBreadcrumb(`Slow API call: ${key}`, 'http', 'warning', {
|
||||||
duration,
|
duration,
|
||||||
statusCode
|
statusCode
|
||||||
@@ -211,7 +211,7 @@ export class MemoryMonitor {
|
|||||||
// Log memory warning if usage is high
|
// Log memory warning if usage is high
|
||||||
const heapUsedMB = usage.heapUsed / 1024 / 1024;
|
const heapUsedMB = usage.heapUsed / 1024 / 1024;
|
||||||
if (heapUsedMB > 512) { // Over 512MB
|
if (heapUsedMB > 512) { // Over 512MB
|
||||||
console.warn(`High memory usage: ${heapUsedMB.toFixed(2)}MB`);
|
|
||||||
addBreadcrumb(`High memory usage: ${heapUsedMB.toFixed(2)}MB`, 'memory', 'warning', {
|
addBreadcrumb(`High memory usage: ${heapUsedMB.toFixed(2)}MB`, 'memory', 'warning', {
|
||||||
heapUsed: usage.heapUsed,
|
heapUsed: usage.heapUsed,
|
||||||
heapTotal: usage.heapTotal,
|
heapTotal: usage.heapTotal,
|
||||||
@@ -288,7 +288,7 @@ export const WebVitalsMonitor = {
|
|||||||
addBreadcrumb(`LCP: ${entry.startTime.toFixed(2)}ms`, 'performance', 'info');
|
addBreadcrumb(`LCP: ${entry.startTime.toFixed(2)}ms`, 'performance', 'info');
|
||||||
|
|
||||||
if (entry.startTime > 2500) { // LCP > 2.5s is poor
|
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');
|
addBreadcrumb(`FID: ${fid.toFixed(2)}ms`, 'performance', 'info');
|
||||||
|
|
||||||
if (fid > 100) { // FID > 100ms is poor
|
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
|
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
|
// Log slow page loads
|
||||||
if (metrics.loadComplete > 3000) { // Over 3 seconds
|
if (metrics.loadComplete > 3000) { // Over 3 seconds
|
||||||
console.warn(`Slow page load: ${metrics.loadComplete}ms`);
|
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class QRCodeGenerator {
|
|||||||
size: mergedOptions.size || this.defaultOptions.size!
|
size: mergedOptions.size || this.defaultOptions.size!
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating QR code:', error);
|
|
||||||
throw new Error('Failed to generate QR code');
|
throw new Error('Failed to generate QR code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function generateQRCode(ticketData: TicketData): Promise<string> {
|
|||||||
|
|
||||||
return qrCodeDataURL;
|
return qrCodeDataURL;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating QR code:', error);
|
|
||||||
throw new Error('Failed to generate QR code');
|
throw new Error('Failed to generate QR code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ export function parseQRCode(qrData: string): { uuid: string; eventId: string; ty
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing QR code:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,16 +10,16 @@ export interface SalesData {
|
|||||||
id: string;
|
id: string;
|
||||||
event_id: string;
|
event_id: string;
|
||||||
ticket_type_id: string;
|
ticket_type_id: string;
|
||||||
price_paid: number;
|
price: number;
|
||||||
status: string;
|
refund_status: string | null;
|
||||||
checked_in: boolean;
|
checked_in: boolean;
|
||||||
customer_email: string;
|
purchaser_email: string;
|
||||||
customer_name: string;
|
purchaser_name: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
ticket_uuid: string;
|
uuid: string;
|
||||||
ticket_types: {
|
ticket_types: {
|
||||||
name: string;
|
name: string;
|
||||||
price_cents: number;
|
price: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,16 +64,16 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
|||||||
id,
|
id,
|
||||||
event_id,
|
event_id,
|
||||||
ticket_type_id,
|
ticket_type_id,
|
||||||
price_paid,
|
price,
|
||||||
status,
|
refund_status,
|
||||||
checked_in,
|
checked_in,
|
||||||
customer_email,
|
purchaser_email,
|
||||||
customer_name,
|
purchaser_name,
|
||||||
created_at,
|
created_at,
|
||||||
ticket_uuid,
|
uuid,
|
||||||
ticket_types (
|
ticket_types (
|
||||||
name,
|
name,
|
||||||
price_cents
|
price
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('event_id', eventId)
|
.eq('event_id', eventId)
|
||||||
@@ -85,7 +85,15 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filters?.status) {
|
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) {
|
if (filters?.checkedIn !== undefined) {
|
||||||
@@ -93,7 +101,7 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filters?.searchTerm) {
|
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) {
|
if (filters?.dateFrom) {
|
||||||
@@ -113,16 +121,16 @@ export async function loadSalesData(eventId: string, filters?: SalesFilter): Pro
|
|||||||
|
|
||||||
return sales || [];
|
return sales || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading sales data:', error);
|
console.error('Error in loadSalesData:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateSalesMetrics(salesData: SalesData[]): SalesMetrics {
|
export function calculateSalesMetrics(salesData: SalesData[]): SalesMetrics {
|
||||||
const confirmedSales = salesData.filter(sale => sale.status === 'confirmed');
|
const confirmedSales = salesData.filter(sale => !sale.refund_status || sale.refund_status === null);
|
||||||
const refundedSales = salesData.filter(sale => sale.status === 'refunded');
|
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 netRevenue = totalRevenue * 0.97; // Assuming 3% platform fee
|
||||||
const ticketsSold = confirmedSales.length;
|
const ticketsSold = confirmedSales.length;
|
||||||
const averageTicketPrice = ticketsSold > 0 ? totalRevenue / ticketsSold : 0;
|
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 }>();
|
const groupedData = new Map<string, { revenue: number; tickets: number }>();
|
||||||
|
|
||||||
salesData.forEach(sale => {
|
salesData.forEach(sale => {
|
||||||
if (sale.status !== 'confirmed') return;
|
if (sale.refund_status && sale.refund_status !== null) return;
|
||||||
|
|
||||||
const date = new Date(sale.created_at);
|
const date = new Date(sale.created_at);
|
||||||
let key: string;
|
let key: string;
|
||||||
@@ -151,11 +159,12 @@ export function generateTimeSeries(salesData: SalesData[], groupBy: 'day' | 'wee
|
|||||||
case 'day':
|
case 'day':
|
||||||
key = date.toISOString().split('T')[0];
|
key = date.toISOString().split('T')[0];
|
||||||
break;
|
break;
|
||||||
case 'week':
|
case 'week': {
|
||||||
const weekStart = new Date(date);
|
const weekStart = new Date(date);
|
||||||
weekStart.setDate(date.getDate() - date.getDay());
|
weekStart.setDate(date.getDate() - date.getDay());
|
||||||
key = weekStart.toISOString().split('T')[0];
|
key = weekStart.toISOString().split('T')[0];
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'month':
|
case 'month':
|
||||||
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
break;
|
break;
|
||||||
@@ -164,7 +173,7 @@ export function generateTimeSeries(salesData: SalesData[], groupBy: 'day' | 'wee
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = groupedData.get(key) || { revenue: 0, tickets: 0 };
|
const existing = groupedData.get(key) || { revenue: 0, tickets: 0 };
|
||||||
existing.revenue += sale.price_paid;
|
existing.revenue += sale.price;
|
||||||
existing.tickets += 1;
|
existing.tickets += 1;
|
||||||
groupedData.set(key, existing);
|
groupedData.set(key, existing);
|
||||||
});
|
});
|
||||||
@@ -195,10 +204,10 @@ export function generateTicketTypeBreakdown(salesData: SalesData[]): TicketTypeB
|
|||||||
refunded: 0
|
refunded: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sale.status === 'confirmed') {
|
if (!sale.refund_status || sale.refund_status === null) {
|
||||||
existing.sold += 1;
|
existing.sold += 1;
|
||||||
existing.revenue += sale.price_paid;
|
existing.revenue += sale.price;
|
||||||
} else if (sale.status === 'refunded') {
|
} else if (sale.refund_status === 'completed') {
|
||||||
existing.refunded += 1;
|
existing.refunded += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +239,8 @@ export async function exportSalesData(eventId: string, format: 'csv' | 'json' =
|
|||||||
// CSV format
|
// CSV format
|
||||||
const headers = [
|
const headers = [
|
||||||
'Order ID',
|
'Order ID',
|
||||||
'Customer Name',
|
'Purchaser Name',
|
||||||
'Customer Email',
|
'Purchaser Email',
|
||||||
'Ticket Type',
|
'Ticket Type',
|
||||||
'Price Paid',
|
'Price Paid',
|
||||||
'Status',
|
'Status',
|
||||||
@@ -242,14 +251,16 @@ export async function exportSalesData(eventId: string, format: 'csv' | 'json' =
|
|||||||
|
|
||||||
const rows = salesData.map(sale => [
|
const rows = salesData.map(sale => [
|
||||||
sale.id,
|
sale.id,
|
||||||
sale.customer_name,
|
sale.purchaser_name || '',
|
||||||
sale.customer_email,
|
sale.purchaser_email,
|
||||||
sale.ticket_types.name,
|
sale.ticket_types.name,
|
||||||
formatCurrency(sale.price_paid),
|
formatCurrency(sale.price),
|
||||||
sale.status,
|
(!sale.refund_status || sale.refund_status === null) ? 'confirmed' :
|
||||||
|
sale.refund_status === 'completed' ? 'refunded' :
|
||||||
|
sale.refund_status === 'pending' ? 'pending' : 'failed',
|
||||||
sale.checked_in ? 'Yes' : 'No',
|
sale.checked_in ? 'Yes' : 'No',
|
||||||
new Date(sale.created_at).toLocaleDateString(),
|
new Date(sale.created_at).toLocaleDateString(),
|
||||||
sale.ticket_uuid
|
sale.uuid
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function verifyPin(pin: string, hash: string): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
return await bcrypt.compare(pin, hash);
|
return await bcrypt.compare(pin, hash);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PIN verification error:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ export async function loadSeatingMaps(organizationId: string): Promise<SeatingMa
|
|||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error loading seating maps:', error);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return seatingMaps || [];
|
return seatingMaps || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading seating maps:', error);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,13 +64,13 @@ export async function getSeatingMap(seatingMapId: string): Promise<SeatingMap |
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error loading seating map:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return seatingMap;
|
return seatingMap;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading seating map:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,13 +87,13 @@ export async function createSeatingMap(organizationId: string, seatingMapData: S
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error creating seating map:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return seatingMap;
|
return seatingMap;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating seating map:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,13 +106,13 @@ export async function updateSeatingMap(seatingMapId: string, updates: Partial<Se
|
|||||||
.eq('id', seatingMapId);
|
.eq('id', seatingMapId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error updating seating map:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating seating map:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,13 +136,13 @@ export async function deleteSeatingMap(seatingMapId: string): Promise<boolean> {
|
|||||||
.eq('id', seatingMapId);
|
.eq('id', seatingMapId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error deleting seating map:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting seating map:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,13 +155,13 @@ export async function applySeatingMapToEvent(eventId: string, seatingMapId: stri
|
|||||||
.eq('id', eventId);
|
.eq('id', eventId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error applying seating map to event:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error applying seating map to event:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,8 @@ if (SENTRY_CONFIG.DSN) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Sentry initialized successfully');
|
|
||||||
} else {
|
} 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>;
|
additionalData?: Record<string, any>;
|
||||||
}) {
|
}) {
|
||||||
if (!SENTRY_CONFIG.DSN) {
|
if (!SENTRY_CONFIG.DSN) {
|
||||||
console.error('Sentry not configured, logging error locally:', error);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +133,7 @@ export function captureMessage(message: string, level: 'fatal' | 'error' | 'warn
|
|||||||
additionalData?: Record<string, any>;
|
additionalData?: Record<string, any>;
|
||||||
}) {
|
}) {
|
||||||
if (!SENTRY_CONFIG.DSN) {
|
if (!SENTRY_CONFIG.DSN) {
|
||||||
console.log('Sentry not configured, logging message locally:', message);
|
|
||||||
return;
|
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 = {
|
export const STRIPE_CONFIG = {
|
||||||
// Stripe Connect settings
|
// Stripe Connect settings
|
||||||
CONNECT_CLIENT_ID: import.meta.env.STRIPE_CONNECT_CLIENT_ID,
|
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,
|
SECRET_KEY: import.meta.env.STRIPE_SECRET_KEY,
|
||||||
WEBHOOK_SECRET: import.meta.env.STRIPE_WEBHOOK_SECRET,
|
WEBHOOK_SECRET: import.meta.env.STRIPE_WEBHOOK_SECRET,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate required environment variables (only warn in development)
|
// Validate required environment variables
|
||||||
if (!STRIPE_CONFIG.SECRET_KEY && typeof window === 'undefined') {
|
function validateStripeConfig() {
|
||||||
if (import.meta.env.DEV) {
|
const errors = [];
|
||||||
console.warn('Missing STRIPE_SECRET_KEY environment variable - Stripe functionality will be disabled');
|
|
||||||
|
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) {
|
// Run validation
|
||||||
if (import.meta.env.DEV) {
|
const _isConfigValid = validateStripeConfig();
|
||||||
console.warn('Missing STRIPE_PUBLISHABLE_KEY environment variable - Stripe functionality will be disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Stripe instance (server-side only)
|
// Initialize Stripe instance (server-side only)
|
||||||
export const stripe = typeof window === 'undefined' && STRIPE_CONFIG.SECRET_KEY
|
export const stripe = typeof window === 'undefined' && STRIPE_CONFIG.SECRET_KEY
|
||||||
@@ -67,17 +91,21 @@ export function calculatePlatformFee(ticketPrice: number, feeStructure?: FeeStru
|
|||||||
let fee = 0;
|
let fee = 0;
|
||||||
|
|
||||||
switch (fees.fee_type) {
|
switch (fees.fee_type) {
|
||||||
case 'percentage':
|
case 'percentage': {
|
||||||
fee = Math.round(priceInCents * fees.fee_percentage);
|
fee = Math.round(priceInCents * fees.fee_percentage);
|
||||||
break;
|
break;
|
||||||
case 'fixed':
|
}
|
||||||
|
case 'fixed': {
|
||||||
fee = fees.fee_fixed;
|
fee = fees.fee_fixed;
|
||||||
break;
|
break;
|
||||||
case 'percentage_plus_fixed':
|
}
|
||||||
|
case 'percentage_plus_fixed': {
|
||||||
fee = Math.round(priceInCents * fees.fee_percentage) + fees.fee_fixed;
|
fee = Math.round(priceInCents * fees.fee_percentage) + fees.fee_fixed;
|
||||||
break;
|
break;
|
||||||
default:
|
}
|
||||||
|
default: {
|
||||||
fee = Math.round(priceInCents * DEFAULT_FEE_STRUCTURE.fee_percentage) + DEFAULT_FEE_STRUCTURE.fee_fixed;
|
fee = Math.round(priceInCents * DEFAULT_FEE_STRUCTURE.fee_percentage) + DEFAULT_FEE_STRUCTURE.fee_fixed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(0, fee); // Ensure fee is never negative
|
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
|
// Format fee structure for display
|
||||||
export function formatFeeStructure(feeStructure: FeeStructure): string {
|
export function formatFeeStructure(feeStructure: FeeStructure): string {
|
||||||
switch (feeStructure.fee_type) {
|
switch (feeStructure.fee_type) {
|
||||||
case 'percentage':
|
case 'percentage': {
|
||||||
return `${(feeStructure.fee_percentage * 100).toFixed(2)}%`;
|
return `${(feeStructure.fee_percentage * 100).toFixed(2)}%`;
|
||||||
case 'fixed':
|
}
|
||||||
|
case 'fixed': {
|
||||||
return `$${(feeStructure.fee_fixed / 100).toFixed(2)}`;
|
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)}`;
|
return `${(feeStructure.fee_percentage * 100).toFixed(2)}% + $${(feeStructure.fee_fixed / 100).toFixed(2)}`;
|
||||||
default:
|
}
|
||||||
|
default: {
|
||||||
return 'Unknown fee structure';
|
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