feat: Modularize event management system - 98.7% reduction in main file size
BREAKING CHANGES: - Refactored monolithic manage.astro (7,623 lines) into modular architecture - Original file backed up as manage-old.astro NEW ARCHITECTURE: ✅ 5 Utility Libraries: - event-management.ts: Event data operations & formatting - ticket-management.ts: Ticket CRUD operations & sales data - seating-management.ts: Seating map management & layout generation - sales-analytics.ts: Sales metrics, reporting & data export - marketing-kit.ts: Marketing asset generation & social media ✅ 5 Shared Components: - TicketTypeModal.tsx: Reusable ticket type creation/editing - SeatingMapModal.tsx: Advanced seating map editor with drag-and-drop - EmbedCodeModal.tsx: Widget embedding with customization - OrdersTable.tsx: Comprehensive orders table with sorting/pagination - AttendeesTable.tsx: Attendee management with export capabilities ✅ 11 Tab Components: - TicketsTab.tsx: Ticket management with card/list views - VenueTab.tsx: Seating map management & venue configuration - OrdersTab.tsx: Sales data & order management - AttendeesTab.tsx: Attendee check-in & management - PresaleTab.tsx: Presale code generation & tracking - DiscountTab.tsx: Discount code management - AddonsTab.tsx: Add-on product management - PrintedTab.tsx: Printed ticket barcode management - SettingsTab.tsx: Event configuration & custom fields - MarketingTab.tsx: Marketing kit with social media templates - PromotionsTab.tsx: Campaign & promotion management ✅ 4 Infrastructure Components: - TabNavigation.tsx: Responsive tab navigation system - EventManagement.tsx: Main orchestration component - EventHeader.astro: Event information header - QuickStats.astro: Statistics dashboard BENEFITS: - 98.7% reduction in main file size (7,623 → ~100 lines) - Dramatic improvement in maintainability and team collaboration - Component-level testing now possible - Reusable components across multiple features - Lazy loading support for better performance - Full TypeScript support with proper interfaces - Separation of concerns: business logic separated from UI 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
416
src/components/manage/PresaleTab.tsx
Normal file
416
src/components/manage/PresaleTab.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { formatCurrency } from '../../lib/event-management';
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
interface PresaleCode {
|
||||
id: string;
|
||||
code: string;
|
||||
discount_type: 'percentage' | 'fixed';
|
||||
discount_value: number;
|
||||
max_uses: number;
|
||||
uses_count: number;
|
||||
expires_at: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PresaleTabProps {
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export default function PresaleTab({ eventId }: PresaleTabProps) {
|
||||
const [presaleCodes, setPresaleCodes] = useState<PresaleCode[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCode, setEditingCode] = useState<PresaleCode | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
discount_type: 'percentage' as 'percentage' | 'fixed',
|
||||
discount_value: 10,
|
||||
max_uses: 100,
|
||||
expires_at: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadPresaleCodes();
|
||||
}, [eventId]);
|
||||
|
||||
const loadPresaleCodes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('presale_codes')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setPresaleCodes(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading presale codes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateCode = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleCreateCode = () => {
|
||||
setEditingCode(null);
|
||||
setFormData({
|
||||
code: generateCode(),
|
||||
discount_type: 'percentage',
|
||||
discount_value: 10,
|
||||
max_uses: 100,
|
||||
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEditCode = (code: PresaleCode) => {
|
||||
setEditingCode(code);
|
||||
setFormData({
|
||||
code: code.code,
|
||||
discount_type: code.discount_type,
|
||||
discount_value: code.discount_value,
|
||||
max_uses: code.max_uses,
|
||||
expires_at: code.expires_at.split('T')[0]
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSaveCode = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const codeData = {
|
||||
...formData,
|
||||
event_id: eventId,
|
||||
expires_at: new Date(formData.expires_at).toISOString()
|
||||
};
|
||||
|
||||
if (editingCode) {
|
||||
const { error } = await supabase
|
||||
.from('presale_codes')
|
||||
.update(codeData)
|
||||
.eq('id', editingCode.id);
|
||||
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('presale_codes')
|
||||
.insert({
|
||||
...codeData,
|
||||
is_active: true,
|
||||
uses_count: 0
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
loadPresaleCodes();
|
||||
} catch (error) {
|
||||
console.error('Error saving presale code:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCode = async (code: PresaleCode) => {
|
||||
if (confirm(`Are you sure you want to delete the code "${code.code}"?`)) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('presale_codes')
|
||||
.delete()
|
||||
.eq('id', code.id);
|
||||
|
||||
if (error) throw error;
|
||||
loadPresaleCodes();
|
||||
} catch (error) {
|
||||
console.error('Error deleting presale code:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCode = async (code: PresaleCode) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('presale_codes')
|
||||
.update({ is_active: !code.is_active })
|
||||
.eq('id', code.id);
|
||||
|
||||
if (error) throw error;
|
||||
loadPresaleCodes();
|
||||
} catch (error) {
|
||||
console.error('Error toggling presale code:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDiscount = (type: string, value: number) => {
|
||||
return type === 'percentage' ? `${value}%` : formatCurrency(value * 100);
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string) => {
|
||||
return new Date(expiresAt) < new Date();
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-light text-white">Presale Codes</h2>
|
||||
<button
|
||||
onClick={handleCreateCode}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
>
|
||||
<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 Presale Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{presaleCodes.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 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p className="text-white/60 mb-4">No presale codes created yet</p>
|
||||
<button
|
||||
onClick={handleCreateCode}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200"
|
||||
>
|
||||
Create Your First Presale Code
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{presaleCodes.map((code) => (
|
||||
<div key={code.id} className="bg-white/5 border border-white/20 rounded-xl p-6 hover:bg-white/10 transition-all duration-200">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="text-2xl font-bold text-white font-mono">{code.code}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
code.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'
|
||||
}`}>
|
||||
{code.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{isExpired(code.expires_at) && (
|
||||
<span className="px-2 py-1 text-xs bg-red-500/20 text-red-300 border border-red-500/30 rounded-full">
|
||||
Expired
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-400 mb-2">
|
||||
{formatDiscount(code.discount_type, code.discount_value)} OFF
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEditCode(code)}
|
||||
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={() => handleToggleCode(code)}
|
||||
className="p-2 text-white/60 hover:text-white transition-colors"
|
||||
title={code.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{code.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={() => handleDeleteCode(code)}
|
||||
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 className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-white/60">Uses</div>
|
||||
<div className="text-white font-semibold">
|
||||
{code.uses_count} / {code.max_uses}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/60">Expires</div>
|
||||
<div className="text-white font-semibold">
|
||||
{new Date(code.expires_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-white/10 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(code.uses_count / code.max_uses) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-white/60">
|
||||
{((code.uses_count / code.max_uses) * 100).toFixed(1)}% used
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<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-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-light text-white">
|
||||
{editingCode ? 'Edit Presale Code' : 'Create Presale Code'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-6 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="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Code</label>
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
||||
className="flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-l-lg text-white placeholder-white/50 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono"
|
||||
placeholder="CODE123"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
|
||||
className="px-4 py-3 bg-white/20 hover:bg-white/30 text-white rounded-r-lg border border-l-0 border-white/20 transition-colors"
|
||||
title="Generate Random Code"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Discount Type</label>
|
||||
<select
|
||||
value={formData.discount_type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, discount_type: e.target.value as 'percentage' | 'fixed' }))}
|
||||
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"
|
||||
>
|
||||
<option value="percentage">Percentage</option>
|
||||
<option value="fixed">Fixed Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">
|
||||
Discount Value {formData.discount_type === 'percentage' ? '(%)' : '($)'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.discount_value}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, discount_value: parseFloat(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"
|
||||
min="0"
|
||||
step={formData.discount_type === 'percentage' ? "1" : "0.01"}
|
||||
max={formData.discount_type === 'percentage' ? "100" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Max Uses</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.max_uses}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Expires On</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-6 py-3 text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveCode}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : editingCode ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user