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:
@@ -39,6 +39,12 @@ import Layout from '../../layouts/Layout.astro';
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a
|
||||
href="/admin/super-dashboard"
|
||||
class="bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 hover:shadow-md hover:scale-105"
|
||||
>
|
||||
Super Admin
|
||||
</a>
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="bg-white/10 backdrop-blur-xl border border-white/20 hover:bg-white/20 text-white px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 hover:shadow-md hover:scale-105"
|
||||
|
||||
1761
src/pages/admin/super-dashboard.astro
Normal file
1761
src/pages/admin/super-dashboard.astro
Normal file
File diff suppressed because it is too large
Load Diff
78
src/pages/api/admin/setup-super-admin.ts
Normal file
78
src/pages/api/admin/setup-super-admin.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { requireAdmin } from '../../../lib/auth';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Verify admin authentication
|
||||
const auth = await requireAdmin(request);
|
||||
|
||||
const { email } = await request.json();
|
||||
|
||||
if (!email) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Email is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const { data: existingUser } = await supabase
|
||||
.from('users')
|
||||
.select('id, email, role')
|
||||
.eq('email', email)
|
||||
.single();
|
||||
|
||||
if (!existingUser) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'User not found. User must be registered first.'
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Make user admin using the database function
|
||||
const { error } = await supabase.rpc('make_user_admin', {
|
||||
user_email: email
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error making user admin:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to make user admin'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully made ${email} an admin`,
|
||||
user: {
|
||||
id: existingUser.id,
|
||||
email: existingUser.email,
|
||||
role: 'admin'
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Setup super admin error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Access denied or server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
1069
src/pages/api/admin/super-analytics.ts
Normal file
1069
src/pages/api/admin/super-analytics.ts
Normal file
File diff suppressed because it is too large
Load Diff
69
src/pages/api/analytics/track.ts
Normal file
69
src/pages/api/analytics/track.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { trendingAnalyticsService } from '../../../lib/analytics';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { eventId, metricType, sessionId, userId, locationData, metadata } = body;
|
||||
|
||||
if (!eventId || !metricType) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'eventId and metricType are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get client information
|
||||
const clientIP = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
const referrer = request.headers.get('referer') || undefined;
|
||||
|
||||
// Track the event
|
||||
await trendingAnalyticsService.trackEvent({
|
||||
eventId,
|
||||
metricType,
|
||||
sessionId,
|
||||
userId,
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
referrer,
|
||||
locationData,
|
||||
metadata
|
||||
});
|
||||
|
||||
// Update popularity score if this is a significant event
|
||||
if (metricType === 'page_view' || metricType === 'checkout_complete') {
|
||||
// Don't await this to avoid slowing down the response
|
||||
trendingAnalyticsService.updateEventPopularityScore(eventId);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Event tracked successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error tracking event:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to track event'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
39
src/pages/api/cron/update-popularity.ts
Normal file
39
src/pages/api/cron/update-popularity.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { trendingAnalyticsService } from '../../../lib/analytics';
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
try {
|
||||
// This endpoint should be called by a cron job or background service
|
||||
// It updates popularity scores for all events
|
||||
|
||||
console.log('Starting popularity score update job...');
|
||||
|
||||
await trendingAnalyticsService.batchUpdatePopularityScores();
|
||||
|
||||
console.log('Popularity score update job completed successfully');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Popularity scores updated successfully',
|
||||
timestamp: new Date().toISOString()
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in popularity update job:', error);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to update popularity scores',
|
||||
timestamp: new Date().toISOString()
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
268
src/pages/api/events/[id]/marketing-kit.ts
Normal file
268
src/pages/api/events/[id]/marketing-kit.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../../lib/supabase';
|
||||
import { qrGenerator } from '../../../../lib/qr-generator';
|
||||
import { marketingKitService } from '../../../../lib/marketing-kit-service';
|
||||
|
||||
export const GET: APIRoute = async ({ params, request, url }) => {
|
||||
try {
|
||||
const eventId = params.id;
|
||||
if (!eventId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user session
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Invalid authentication'
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's organization
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userError || !userData?.organization_id) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'User organization not found'
|
||||
}), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get event details with organization check
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
venue,
|
||||
start_time,
|
||||
end_time,
|
||||
slug,
|
||||
image_url,
|
||||
organization_id,
|
||||
organizations!inner(name, logo)
|
||||
`)
|
||||
.eq('id', eventId)
|
||||
.eq('organization_id', userData.organization_id)
|
||||
.single();
|
||||
|
||||
if (eventError || !event) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event not found or access denied'
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if marketing kit already exists and is recent
|
||||
const { data: existingAssets } = await supabase
|
||||
.from('marketing_kit_assets')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.eq('is_active', true)
|
||||
.gte('generated_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); // Last 24 hours
|
||||
|
||||
if (existingAssets && existingAssets.length > 0) {
|
||||
// Return existing marketing kit
|
||||
const groupedAssets = groupAssetsByType(existingAssets);
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
event,
|
||||
assets: groupedAssets,
|
||||
generated_at: existingAssets[0].generated_at
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new marketing kit
|
||||
const marketingKit = await marketingKitService.generateCompleteKit(event, userData.organization_id, user.id);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: marketingKit
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in marketing kit API:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to generate marketing kit'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const eventId = params.id;
|
||||
const body = await request.json();
|
||||
const { asset_types, regenerate = false } = body;
|
||||
|
||||
if (!eventId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user session
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Invalid authentication'
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's organization
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userError || !userData?.organization_id) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'User organization not found'
|
||||
}), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get event details
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
venue,
|
||||
start_time,
|
||||
end_time,
|
||||
slug,
|
||||
image_url,
|
||||
organization_id,
|
||||
organizations!inner(name, logo)
|
||||
`)
|
||||
.eq('id', eventId)
|
||||
.eq('organization_id', userData.organization_id)
|
||||
.single();
|
||||
|
||||
if (eventError || !event) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event not found or access denied'
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// If regenerate is true, deactivate existing assets
|
||||
if (regenerate) {
|
||||
await supabase
|
||||
.from('marketing_kit_assets')
|
||||
.update({ is_active: false })
|
||||
.eq('event_id', eventId);
|
||||
}
|
||||
|
||||
// Generate specific asset types or complete kit
|
||||
let result;
|
||||
if (asset_types && asset_types.length > 0) {
|
||||
result = await marketingKitService.generateSpecificAssets(event, userData.organization_id, user.id, asset_types);
|
||||
} else {
|
||||
result = await marketingKitService.generateCompleteKit(event, userData.organization_id, user.id);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: result
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating marketing kit:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to generate marketing kit'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function groupAssetsByType(assets: any[]) {
|
||||
return assets.reduce((acc, asset) => {
|
||||
if (!acc[asset.asset_type]) {
|
||||
acc[asset.asset_type] = [];
|
||||
}
|
||||
acc[asset.asset_type].push(asset);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
90
src/pages/api/events/[id]/marketing-kit/download.ts
Normal file
90
src/pages/api/events/[id]/marketing-kit/download.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const eventId = params.id;
|
||||
if (!eventId) {
|
||||
return new Response('Event ID is required', { status: 400 });
|
||||
}
|
||||
|
||||
// Get user session
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return new Response('Authentication required', { status: 401 });
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response('Invalid authentication', { status: 401 });
|
||||
}
|
||||
|
||||
// Get user's organization
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userError || !userData?.organization_id) {
|
||||
return new Response('User organization not found', { status: 403 });
|
||||
}
|
||||
|
||||
// Get event details
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('id', eventId)
|
||||
.eq('organization_id', userData.organization_id)
|
||||
.single();
|
||||
|
||||
if (eventError || !event) {
|
||||
return new Response('Event not found or access denied', { status: 404 });
|
||||
}
|
||||
|
||||
// Get marketing kit assets
|
||||
const { data: assets, error: assetsError } = await supabase
|
||||
.from('marketing_kit_assets')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.eq('is_active', true)
|
||||
.order('generated_at', { ascending: false });
|
||||
|
||||
if (assetsError || !assets || assets.length === 0) {
|
||||
return new Response('No marketing kit assets found', { status: 404 });
|
||||
}
|
||||
|
||||
// Create a simple ZIP-like response for now
|
||||
// In production, you'd generate an actual ZIP file
|
||||
const zipContent = {
|
||||
event: {
|
||||
title: event.title,
|
||||
date: event.start_time,
|
||||
venue: event.venue
|
||||
},
|
||||
assets: assets.map(asset => ({
|
||||
type: asset.asset_type,
|
||||
title: asset.title,
|
||||
url: asset.image_url || asset.download_url,
|
||||
content: asset.content
|
||||
})),
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Return JSON for now - in production this would be a ZIP file
|
||||
return new Response(JSON.stringify(zipContent, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="${event.slug}-marketing-kit.json"`,
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error downloading marketing kit:', error);
|
||||
return new Response('Internal server error', { status: 500 });
|
||||
}
|
||||
};
|
||||
67
src/pages/api/events/nearby.ts
Normal file
67
src/pages/api/events/nearby.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { trendingAnalyticsService } from '../../../lib/analytics';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
try {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
// Get required location parameters
|
||||
const latitude = searchParams.get('lat');
|
||||
const longitude = searchParams.get('lng');
|
||||
|
||||
if (!latitude || !longitude) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Latitude and longitude are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const radiusMiles = parseInt(searchParams.get('radius') || '25');
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
|
||||
// Get hot events in the area
|
||||
const nearbyEvents = await trendingAnalyticsService.getHotEventsInArea(
|
||||
parseFloat(latitude),
|
||||
parseFloat(longitude),
|
||||
radiusMiles,
|
||||
limit
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: nearbyEvents,
|
||||
meta: {
|
||||
userLocation: {
|
||||
latitude: parseFloat(latitude),
|
||||
longitude: parseFloat(longitude),
|
||||
radius: radiusMiles
|
||||
},
|
||||
count: nearbyEvents.length,
|
||||
limit
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=300' // 5 minutes
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in nearby events API:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to fetch nearby events'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
66
src/pages/api/events/trending.ts
Normal file
66
src/pages/api/events/trending.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { trendingAnalyticsService } from '../../../lib/analytics';
|
||||
import { geolocationService } from '../../../lib/geolocation';
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
try {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
// Get location parameters
|
||||
const latitude = searchParams.get('lat') ? parseFloat(searchParams.get('lat')!) : undefined;
|
||||
const longitude = searchParams.get('lng') ? parseFloat(searchParams.get('lng')!) : undefined;
|
||||
const radiusMiles = parseInt(searchParams.get('radius') || '50');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
|
||||
// Get user location from IP if not provided
|
||||
let userLat = latitude;
|
||||
let userLng = longitude;
|
||||
|
||||
if (!userLat || !userLng) {
|
||||
const ipLocation = await geolocationService.getLocationFromIP();
|
||||
if (ipLocation) {
|
||||
userLat = ipLocation.latitude;
|
||||
userLng = ipLocation.longitude;
|
||||
}
|
||||
}
|
||||
|
||||
// Get trending events
|
||||
const trendingEvents = await trendingAnalyticsService.getTrendingEvents(
|
||||
userLat,
|
||||
userLng,
|
||||
radiusMiles,
|
||||
limit
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: trendingEvents,
|
||||
meta: {
|
||||
userLocation: userLat && userLng ? {
|
||||
latitude: userLat,
|
||||
longitude: userLng,
|
||||
radius: radiusMiles
|
||||
} : null,
|
||||
count: trendingEvents.length,
|
||||
limit
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=300' // 5 minutes
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in trending events API:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to fetch trending events'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
121
src/pages/api/location/preferences.ts
Normal file
121
src/pages/api/location/preferences.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { geolocationService } from '../../../lib/geolocation';
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
try {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const userId = searchParams.get('userId');
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
|
||||
if (!userId && !sessionId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'userId or sessionId is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const preferences = await geolocationService.getUserLocationPreference(userId || undefined, sessionId || undefined);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: preferences
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting location preferences:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to get location preferences'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
userId,
|
||||
sessionId,
|
||||
preferredLatitude,
|
||||
preferredLongitude,
|
||||
preferredCity,
|
||||
preferredState,
|
||||
preferredCountry,
|
||||
preferredZipCode,
|
||||
searchRadiusMiles,
|
||||
locationSource
|
||||
} = body;
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'sessionId is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!preferredLatitude || !preferredLongitude) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'preferredLatitude and preferredLongitude are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await geolocationService.saveUserLocationPreference({
|
||||
userId,
|
||||
sessionId,
|
||||
preferredLatitude,
|
||||
preferredLongitude,
|
||||
preferredCity,
|
||||
preferredState,
|
||||
preferredCountry,
|
||||
preferredZipCode,
|
||||
searchRadiusMiles: searchRadiusMiles || 50,
|
||||
locationSource: locationSource || 'manual'
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Location preferences saved successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving location preferences:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to save location preferences'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,36 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
export const GET: APIRoute = async ({ url, request }) => {
|
||||
try {
|
||||
const eventId = url.searchParams.get('event_id');
|
||||
let eventId = url.searchParams.get('event_id');
|
||||
|
||||
// Fallback: try to extract from URL path if query param doesn't work
|
||||
if (!eventId) {
|
||||
const urlParts = url.pathname.split('/');
|
||||
const eventIdIndex = urlParts.findIndex(part => part === 'api') + 1;
|
||||
if (eventIdIndex > 0 && urlParts[eventIdIndex] === 'printed-tickets' && urlParts[eventIdIndex + 1]) {
|
||||
eventId = urlParts[eventIdIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log what we received
|
||||
console.log('API Debug - Full URL:', url.toString());
|
||||
console.log('API Debug - Request URL:', request.url);
|
||||
console.log('API Debug - Search params string:', url.searchParams.toString());
|
||||
console.log('API Debug - Event ID:', eventId);
|
||||
console.log('API Debug - URL pathname:', url.pathname);
|
||||
|
||||
if (!eventId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID is required'
|
||||
error: 'Event ID is required',
|
||||
debug: {
|
||||
url: url.toString(),
|
||||
pathname: url.pathname,
|
||||
searchParams: url.searchParams.toString(),
|
||||
allParams: Object.fromEntries(url.searchParams.entries())
|
||||
}
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
@@ -50,7 +72,52 @@ export const GET: APIRoute = async ({ url }) => {
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { barcodes, event_id, ticket_type_id, batch_number, notes, issued_by } = await request.json();
|
||||
const body = await request.json();
|
||||
|
||||
// Handle fetch action (getting printed tickets)
|
||||
if (body.action === 'fetch') {
|
||||
const eventId = body.event_id;
|
||||
|
||||
console.log('POST Fetch - Event ID:', eventId);
|
||||
|
||||
if (!eventId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID is required for fetch action'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
const { data: tickets, error } = await supabase
|
||||
.from('printed_tickets')
|
||||
.select(`
|
||||
*,
|
||||
ticket_types (
|
||||
name,
|
||||
price
|
||||
),
|
||||
events (
|
||||
title
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to fetch printed tickets'
|
||||
}), { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
tickets: tickets || []
|
||||
}), { status: 200 });
|
||||
}
|
||||
|
||||
// Handle add action (adding new printed tickets)
|
||||
const { barcodes, event_id, ticket_type_id, batch_number, notes, issued_by } = body;
|
||||
|
||||
if (!barcodes || !Array.isArray(barcodes) || barcodes.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
|
||||
58
src/pages/api/printed-tickets/[eventId].ts
Normal file
58
src/pages/api/printed-tickets/[eventId].ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
try {
|
||||
const eventId = params.eventId;
|
||||
|
||||
console.log('API Debug - Event ID from path:', eventId);
|
||||
|
||||
if (!eventId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID is required',
|
||||
debug: {
|
||||
params: params,
|
||||
eventId: eventId
|
||||
}
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
const { data: tickets, error } = await supabase
|
||||
.from('printed_tickets')
|
||||
.select(`
|
||||
*,
|
||||
ticket_types (
|
||||
name,
|
||||
price
|
||||
),
|
||||
events (
|
||||
title
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to fetch printed tickets'
|
||||
}), { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
tickets: tickets || []
|
||||
}), { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch printed tickets error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}), { status: 500 });
|
||||
}
|
||||
};
|
||||
106
src/pages/api/tickets/preview.ts
Normal file
106
src/pages/api/tickets/preview.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ url, cookies }) => {
|
||||
try {
|
||||
// Get query parameters
|
||||
const eventId = url.searchParams.get('event_id');
|
||||
const ticketTypeId = url.searchParams.get('ticket_type_id');
|
||||
|
||||
if (!eventId || !ticketTypeId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID and ticket type ID are required'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Authenticate user (basic auth check)
|
||||
const token = cookies.get('sb-access-token')?.value;
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
}), { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch event and ticket type data
|
||||
const { data: eventData, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
start_time,
|
||||
end_time,
|
||||
venue,
|
||||
address,
|
||||
image_url,
|
||||
organizations (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('id', eventId)
|
||||
.single();
|
||||
|
||||
if (eventError || !eventData) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event not found'
|
||||
}), { status: 404 });
|
||||
}
|
||||
|
||||
const { data: ticketTypeData, error: ticketTypeError } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('id, name, price, description')
|
||||
.eq('id', ticketTypeId)
|
||||
.eq('event_id', eventId)
|
||||
.single();
|
||||
|
||||
if (ticketTypeError || !ticketTypeData) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Ticket type not found'
|
||||
}), { status: 404 });
|
||||
}
|
||||
|
||||
// Format dates
|
||||
const startTime = new Date(eventData.start_time);
|
||||
const eventDate = startTime.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const eventTime = startTime.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
// Prepare preview data
|
||||
const previewData = {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventDate,
|
||||
eventTime: eventTime,
|
||||
venue: eventData.venue,
|
||||
address: eventData.address,
|
||||
ticketTypeName: ticketTypeData.name,
|
||||
ticketTypePrice: ticketTypeData.price,
|
||||
organizationName: eventData.organizations?.name || 'Event Organizer',
|
||||
imageUrl: eventData.image_url
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
preview: previewData
|
||||
}), { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ticket preview error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}), { status: 500 });
|
||||
}
|
||||
};
|
||||
119
src/pages/api/upload-event-image.ts
Normal file
119
src/pages/api/upload-event-image.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
console.log('Image upload API called');
|
||||
|
||||
// Get the authorization header
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (!authHeader) {
|
||||
console.log('No authorization header provided');
|
||||
return new Response(JSON.stringify({ error: 'Authorization required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the user is authenticated
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(
|
||||
authHeader.replace('Bearer ', '')
|
||||
);
|
||||
|
||||
if (authError || !user) {
|
||||
console.log('Authentication failed:', authError?.message || 'No user');
|
||||
return new Response(JSON.stringify({ error: 'Invalid authentication' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
console.log('User authenticated:', user.id);
|
||||
|
||||
// Parse the form data
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file) {
|
||||
console.log('No file provided in form data');
|
||||
return new Response(JSON.stringify({ error: 'No file provided' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
console.log('File received:', file.name, file.type, file.size, 'bytes');
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
console.log('Invalid file type:', file.type);
|
||||
return new Response(JSON.stringify({ error: 'Invalid file type. Only JPG, PNG, and WebP are allowed.' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
console.log('File too large:', file.size);
|
||||
return new Response(JSON.stringify({ error: 'File too large. Maximum size is 2MB.' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Convert file to buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Generate unique filename
|
||||
const fileExtension = file.type.split('/')[1];
|
||||
const fileName = `${uuidv4()}.${fileExtension}`;
|
||||
const filePath = `events/${fileName}`;
|
||||
|
||||
// Upload to Supabase Storage
|
||||
console.log('Uploading to Supabase Storage:', filePath);
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('event-images')
|
||||
.upload(filePath, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Upload failed',
|
||||
details: uploadError.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Upload successful:', uploadData);
|
||||
|
||||
// Get the public URL
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('event-images')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
console.log('Public URL generated:', publicUrl);
|
||||
|
||||
return new Response(JSON.stringify({ imageUrl: publicUrl }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('API error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
611
src/pages/calendar-enhanced.astro
Normal file
611
src/pages/calendar-enhanced.astro
Normal file
@@ -0,0 +1,611 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import PublicHeader from '../components/PublicHeader.astro';
|
||||
|
||||
// Get query parameters for filtering
|
||||
const url = new URL(Astro.request.url);
|
||||
const featured = url.searchParams.get('featured');
|
||||
const category = url.searchParams.get('category');
|
||||
const search = url.searchParams.get('search');
|
||||
---
|
||||
|
||||
<Layout title="Enhanced Event Calendar - Black Canyon Tickets">
|
||||
<div class="min-h-screen">
|
||||
<!-- Hero Section with Dynamic Background -->
|
||||
<section class="relative overflow-hidden bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<PublicHeader showCalendarNav={true} />
|
||||
|
||||
<!-- Animated Background Elements -->
|
||||
<div class="absolute inset-0 opacity-20">
|
||||
<div class="absolute top-20 left-20 w-64 h-64 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full blur-3xl animate-pulse delay-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Geometric Patterns -->
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<svg class="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="white" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-32 pb-24 lg:pt-40 lg:pb-32">
|
||||
<div class="text-center">
|
||||
<!-- Badge -->
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-full bg-white/10 backdrop-blur-lg border border-white/20 mb-8">
|
||||
<span class="text-sm font-medium text-white/90">✨ Discover Events Near You</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Heading -->
|
||||
<h1 class="text-5xl lg:text-7xl font-light text-white mb-6 tracking-tight">
|
||||
Smart Event
|
||||
<span class="font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
Discovery
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subheading -->
|
||||
<p class="text-xl lg:text-2xl text-white/80 mb-12 max-w-3xl mx-auto leading-relaxed">
|
||||
Find trending events near you with personalized recommendations and location-based discovery.
|
||||
</p>
|
||||
|
||||
<!-- Location Detection -->
|
||||
<div class="max-w-xl mx-auto mb-8">
|
||||
<div id="location-detector" class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
|
||||
<div class="flex items-center justify-center space-x-3 mb-4">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-white">Find Events Near You</h3>
|
||||
</div>
|
||||
|
||||
<div id="location-status" class="text-center">
|
||||
<button id="enable-location" 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-semibold transition-all duration-200 shadow-lg hover:shadow-xl">
|
||||
Enable Location
|
||||
</button>
|
||||
<p class="text-white/60 text-sm mt-2">Get personalized event recommendations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Bar -->
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-600 rounded-2xl blur opacity-20 group-hover:opacity-30 transition-opacity"></div>
|
||||
<div class="relative bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-2 flex items-center space-x-2">
|
||||
<div class="flex-1 flex items-center space-x-3 px-4">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="Search events, venues, or organizers..."
|
||||
class="bg-transparent text-white placeholder-white/60 focus:outline-none flex-1 text-lg"
|
||||
value={search || ''}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="search-btn"
|
||||
class="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- What's Hot Section -->
|
||||
<section id="whats-hot-section" class="py-16 bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div id="whats-hot-container">
|
||||
<!-- Will be populated by WhatsHotEvents component -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<section class="sticky top-0 z-50 bg-white/95 backdrop-blur-xl border-b border-gray-200/50 shadow-lg">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<!-- Location Display -->
|
||||
<div id="location-display" class="hidden flex items-center space-x-2 bg-blue-50 px-3 py-2 rounded-lg">
|
||||
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span id="location-text" class="text-sm text-blue-800 font-medium"></span>
|
||||
<button id="change-location" class="text-blue-600 hover:text-blue-800 text-xs font-medium">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700">View:</span>
|
||||
<div class="bg-gray-100 rounded-lg p-1 flex border border-gray-200">
|
||||
<button
|
||||
id="calendar-view-btn"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 bg-white shadow-sm text-gray-900"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Calendar
|
||||
</button>
|
||||
<button
|
||||
id="list-view-btn"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||
</svg>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters -->
|
||||
<div class="flex flex-wrap items-center space-x-4">
|
||||
<!-- Category Filter -->
|
||||
<div class="relative">
|
||||
<select
|
||||
id="category-filter"
|
||||
class="appearance-none bg-white border border-gray-300 rounded-lg px-4 py-2 pr-8 text-sm font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="music" {category === 'music' ? 'selected' : ''}>Music & Concerts</option>
|
||||
<option value="arts" {category === 'arts' ? 'selected' : ''}>Arts & Culture</option>
|
||||
<option value="community" {category === 'community' ? 'selected' : ''}>Community Events</option>
|
||||
<option value="business" {category === 'business' ? 'selected' : ''}>Business & Networking</option>
|
||||
<option value="food" {category === 'food' ? 'selected' : ''}>Food & Wine</option>
|
||||
<option value="sports" {category === 'sports' ? 'selected' : ''}>Sports & Recreation</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distance Filter -->
|
||||
<div id="distance-filter" class="relative hidden">
|
||||
<select
|
||||
id="radius-filter"
|
||||
class="appearance-none bg-white border border-gray-300 rounded-lg px-4 py-2 pr-8 text-sm font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="10">Within 10 miles</option>
|
||||
<option value="25" selected>Within 25 miles</option>
|
||||
<option value="50">Within 50 miles</option>
|
||||
<option value="100">Within 100 miles</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="relative">
|
||||
<select
|
||||
id="date-filter"
|
||||
class="appearance-none bg-white border border-gray-300 rounded-lg px-4 py-2 pr-8 text-sm font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Dates</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="tomorrow">Tomorrow</option>
|
||||
<option value="this-week">This Week</option>
|
||||
<option value="this-weekend">This Weekend</option>
|
||||
<option value="next-week">Next Week</option>
|
||||
<option value="this-month">This Month</option>
|
||||
<option value="next-month">Next Month</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Featured Toggle -->
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="featured-filter"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
{featured ? 'checked' : ''}
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">Featured Only</span>
|
||||
</label>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<button
|
||||
id="clear-filters"
|
||||
class="text-sm font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading State -->
|
||||
<div id="loading-state" class="text-center py-16">
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span class="text-lg font-medium text-gray-600">Loading events...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Calendar Container -->
|
||||
<div id="enhanced-calendar-container">
|
||||
<!-- React Calendar component will be mounted here -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="empty-state" class="hidden text-center py-16">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="w-24 h-24 mx-auto mb-6 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">No Events Found</h3>
|
||||
<p class="text-gray-600 mb-6">Try adjusting your filters or search terms to find events.</p>
|
||||
<button
|
||||
id="clear-filters-empty"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Location Input Modal -->
|
||||
<div id="location-modal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="relative min-h-screen flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900">Set Your Location</h3>
|
||||
<button id="close-location-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<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="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="location-input-container">
|
||||
<!-- LocationInput component will be mounted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Purchase Modal -->
|
||||
<div id="quick-purchase-modal" class="fixed inset-0 z-50 hidden">
|
||||
<div id="quick-purchase-container">
|
||||
<!-- QuickTicketPurchase component will be mounted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Calendar from '../components/Calendar.tsx';
|
||||
import WhatsHotEvents from '../components/WhatsHotEvents.tsx';
|
||||
import LocationInput from '../components/LocationInput.tsx';
|
||||
import QuickTicketPurchase from '../components/QuickTicketPurchase.tsx';
|
||||
import { geolocationService } from '../lib/geolocation.ts';
|
||||
import { trendingAnalyticsService } from '../lib/analytics.ts';
|
||||
|
||||
// State
|
||||
let userLocation = null;
|
||||
let currentRadius = 25;
|
||||
let sessionId = sessionStorage.getItem('sessionId') || Date.now().toString();
|
||||
sessionStorage.setItem('sessionId', sessionId);
|
||||
|
||||
// DOM elements
|
||||
const enableLocationBtn = document.getElementById('enable-location');
|
||||
const locationStatus = document.getElementById('location-status');
|
||||
const locationDisplay = document.getElementById('location-display');
|
||||
const locationText = document.getElementById('location-text');
|
||||
const changeLocationBtn = document.getElementById('change-location');
|
||||
const distanceFilter = document.getElementById('distance-filter');
|
||||
const radiusFilter = document.getElementById('radius-filter');
|
||||
const locationModal = document.getElementById('location-modal');
|
||||
const closeLocationModalBtn = document.getElementById('close-location-modal');
|
||||
const quickPurchaseModal = document.getElementById('quick-purchase-modal');
|
||||
|
||||
// React component containers
|
||||
const whatsHotContainer = document.getElementById('whats-hot-container');
|
||||
const calendarContainer = document.getElementById('enhanced-calendar-container');
|
||||
const locationInputContainer = document.getElementById('location-input-container');
|
||||
const quickPurchaseContainer = document.getElementById('quick-purchase-container');
|
||||
|
||||
// Initialize React components
|
||||
let whatsHotRoot = null;
|
||||
let calendarRoot = null;
|
||||
let locationInputRoot = null;
|
||||
let quickPurchaseRoot = null;
|
||||
|
||||
// Initialize location detection
|
||||
async function initializeLocation() {
|
||||
try {
|
||||
// Try to get saved location preference first
|
||||
const savedLocation = await geolocationService.getUserLocationPreference(null, sessionId);
|
||||
if (savedLocation) {
|
||||
userLocation = {
|
||||
latitude: savedLocation.preferredLatitude,
|
||||
longitude: savedLocation.preferredLongitude,
|
||||
city: savedLocation.preferredCity,
|
||||
state: savedLocation.preferredState,
|
||||
source: savedLocation.locationSource
|
||||
};
|
||||
currentRadius = savedLocation.searchRadiusMiles;
|
||||
updateLocationDisplay();
|
||||
loadComponents();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no saved location, try IP geolocation
|
||||
const ipLocation = await geolocationService.getLocationFromIP();
|
||||
if (ipLocation) {
|
||||
userLocation = ipLocation;
|
||||
updateLocationDisplay();
|
||||
loadComponents();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing location:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update location display
|
||||
function updateLocationDisplay() {
|
||||
if (userLocation) {
|
||||
locationStatus.innerHTML = `
|
||||
<div class="flex items-center space-x-2 text-green-400">
|
||||
<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="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Location enabled</span>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-1">
|
||||
${userLocation.city ? `${userLocation.city}, ${userLocation.state}` : 'Location detected'}
|
||||
</p>
|
||||
`;
|
||||
|
||||
locationDisplay.classList.remove('hidden');
|
||||
locationText.textContent = userLocation.city ?
|
||||
`${userLocation.city}, ${userLocation.state}` :
|
||||
'Location detected';
|
||||
|
||||
distanceFilter.classList.remove('hidden');
|
||||
radiusFilter.value = currentRadius.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Load React components
|
||||
function loadComponents() {
|
||||
// Load What's Hot Events
|
||||
if (whatsHotRoot) {
|
||||
whatsHotRoot.unmount();
|
||||
}
|
||||
whatsHotRoot = createRoot(whatsHotContainer);
|
||||
whatsHotRoot.render(React.createElement(WhatsHotEvents, {
|
||||
userLocation: userLocation,
|
||||
radius: currentRadius,
|
||||
limit: 8,
|
||||
onEventClick: handleEventClick,
|
||||
className: 'w-full'
|
||||
}));
|
||||
|
||||
// Load Enhanced Calendar
|
||||
if (calendarRoot) {
|
||||
calendarRoot.unmount();
|
||||
}
|
||||
calendarRoot = createRoot(calendarContainer);
|
||||
calendarRoot.render(React.createElement(Calendar, {
|
||||
events: [], // Will be populated by the calendar component
|
||||
onEventClick: handleEventClick,
|
||||
showLocationFeatures: true,
|
||||
showTrending: true
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle event click
|
||||
function handleEventClick(event) {
|
||||
// Track the click
|
||||
trendingAnalyticsService.trackEvent({
|
||||
eventId: event.id || event.eventId,
|
||||
metricType: 'page_view',
|
||||
sessionId: sessionId,
|
||||
locationData: userLocation ? {
|
||||
latitude: userLocation.latitude,
|
||||
longitude: userLocation.longitude,
|
||||
city: userLocation.city,
|
||||
state: userLocation.state
|
||||
} : undefined
|
||||
});
|
||||
|
||||
// Show quick purchase modal
|
||||
showQuickPurchaseModal(event);
|
||||
}
|
||||
|
||||
// Show quick purchase modal
|
||||
function showQuickPurchaseModal(event) {
|
||||
if (quickPurchaseRoot) {
|
||||
quickPurchaseRoot.unmount();
|
||||
}
|
||||
quickPurchaseRoot = createRoot(quickPurchaseContainer);
|
||||
quickPurchaseRoot.render(React.createElement(QuickTicketPurchase, {
|
||||
event: event,
|
||||
onClose: hideQuickPurchaseModal,
|
||||
onPurchaseStart: handlePurchaseStart
|
||||
}));
|
||||
|
||||
quickPurchaseModal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Hide quick purchase modal
|
||||
function hideQuickPurchaseModal() {
|
||||
quickPurchaseModal.classList.add('hidden');
|
||||
document.body.style.overflow = 'auto';
|
||||
if (quickPurchaseRoot) {
|
||||
quickPurchaseRoot.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle purchase start
|
||||
function handlePurchaseStart(ticketTypeId, quantity) {
|
||||
// Track checkout start
|
||||
trendingAnalyticsService.trackEvent({
|
||||
eventId: event.id || event.eventId,
|
||||
metricType: 'checkout_start',
|
||||
sessionId: sessionId,
|
||||
locationData: userLocation ? {
|
||||
latitude: userLocation.latitude,
|
||||
longitude: userLocation.longitude,
|
||||
city: userLocation.city,
|
||||
state: userLocation.state
|
||||
} : undefined,
|
||||
metadata: {
|
||||
ticketTypeId: ticketTypeId,
|
||||
quantity: quantity
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate to checkout
|
||||
window.location.href = `/checkout?ticketType=${ticketTypeId}&quantity=${quantity}`;
|
||||
}
|
||||
|
||||
// Show location modal
|
||||
function showLocationModal() {
|
||||
if (locationInputRoot) {
|
||||
locationInputRoot.unmount();
|
||||
}
|
||||
locationInputRoot = createRoot(locationInputContainer);
|
||||
locationInputRoot.render(React.createElement(LocationInput, {
|
||||
initialLocation: userLocation,
|
||||
defaultRadius: currentRadius,
|
||||
onLocationChange: handleLocationChange,
|
||||
onRadiusChange: handleRadiusChange,
|
||||
className: 'w-full'
|
||||
}));
|
||||
|
||||
locationModal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Hide location modal
|
||||
function hideLocationModal() {
|
||||
locationModal.classList.add('hidden');
|
||||
document.body.style.overflow = 'auto';
|
||||
if (locationInputRoot) {
|
||||
locationInputRoot.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle location change
|
||||
function handleLocationChange(location) {
|
||||
userLocation = location;
|
||||
if (location) {
|
||||
// Save location preference
|
||||
geolocationService.saveUserLocationPreference({
|
||||
sessionId: sessionId,
|
||||
preferredLatitude: location.latitude,
|
||||
preferredLongitude: location.longitude,
|
||||
preferredCity: location.city,
|
||||
preferredState: location.state,
|
||||
preferredCountry: location.country,
|
||||
preferredZipCode: location.zipCode,
|
||||
searchRadiusMiles: currentRadius,
|
||||
locationSource: location.source
|
||||
});
|
||||
|
||||
updateLocationDisplay();
|
||||
loadComponents();
|
||||
hideLocationModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle radius change
|
||||
function handleRadiusChange(radius) {
|
||||
currentRadius = radius;
|
||||
if (userLocation) {
|
||||
// Update saved preference
|
||||
geolocationService.saveUserLocationPreference({
|
||||
sessionId: sessionId,
|
||||
preferredLatitude: userLocation.latitude,
|
||||
preferredLongitude: userLocation.longitude,
|
||||
preferredCity: userLocation.city,
|
||||
preferredState: userLocation.state,
|
||||
preferredCountry: userLocation.country,
|
||||
preferredZipCode: userLocation.zipCode,
|
||||
searchRadiusMiles: currentRadius,
|
||||
locationSource: userLocation.source
|
||||
});
|
||||
|
||||
loadComponents();
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
enableLocationBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const location = await geolocationService.requestLocationPermission();
|
||||
if (location) {
|
||||
userLocation = location;
|
||||
updateLocationDisplay();
|
||||
loadComponents();
|
||||
|
||||
// Save location preference
|
||||
geolocationService.saveUserLocationPreference({
|
||||
sessionId: sessionId,
|
||||
preferredLatitude: location.latitude,
|
||||
preferredLongitude: location.longitude,
|
||||
preferredCity: location.city,
|
||||
preferredState: location.state,
|
||||
preferredCountry: location.country,
|
||||
preferredZipCode: location.zipCode,
|
||||
searchRadiusMiles: currentRadius,
|
||||
locationSource: location.source
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error enabling location:', error);
|
||||
}
|
||||
});
|
||||
|
||||
changeLocationBtn.addEventListener('click', showLocationModal);
|
||||
closeLocationModalBtn.addEventListener('click', hideLocationModal);
|
||||
|
||||
radiusFilter.addEventListener('change', (e) => {
|
||||
currentRadius = parseInt(e.target.value);
|
||||
handleRadiusChange(currentRadius);
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeLocation();
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
@@ -7,6 +7,9 @@ const url = new URL(Astro.request.url);
|
||||
const featured = url.searchParams.get('featured');
|
||||
const category = url.searchParams.get('category');
|
||||
const search = url.searchParams.get('search');
|
||||
|
||||
// Add environment variable for Mapbox (if needed for geocoding)
|
||||
const mapboxToken = import.meta.env.PUBLIC_MAPBOX_TOKEN || '';
|
||||
---
|
||||
|
||||
<Layout title="Event Calendar - Black Canyon Tickets">
|
||||
@@ -49,10 +52,25 @@ const search = url.searchParams.get('search');
|
||||
</h1>
|
||||
|
||||
<!-- Subheading -->
|
||||
<p class="text-xl lg:text-2xl text-white/80 mb-12 max-w-3xl mx-auto leading-relaxed">
|
||||
<p class="text-xl lg:text-2xl text-white/80 mb-8 max-w-3xl mx-auto leading-relaxed">
|
||||
Curated experiences in Colorado's most prestigious venues. From intimate galas to grand celebrations.
|
||||
</p>
|
||||
|
||||
<!-- Location Detection -->
|
||||
<div class="max-w-md mx-auto mb-8">
|
||||
<div id="location-detector" class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl p-4 transition-all duration-300">
|
||||
<div id="location-status" class="flex items-center justify-center space-x-2">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<button id="enable-location" class="text-white/90 hover:text-white font-medium">
|
||||
Enable location for personalized events
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Bar -->
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="relative group">
|
||||
@@ -83,10 +101,26 @@ const search = url.searchParams.get('search');
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- What's Hot Section -->
|
||||
<section id="whats-hot-section" class="py-8 bg-gradient-to-br from-gray-50 to-gray-100 hidden">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-2xl">🔥</span>
|
||||
<h2 class="text-2xl font-bold text-gray-900">What's Hot Near You</h2>
|
||||
</div>
|
||||
<span id="hot-location-text" class="text-sm text-gray-600"></span>
|
||||
</div>
|
||||
<div id="hot-events-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Hot events will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<section class="sticky top-0 z-50 bg-white/95 backdrop-blur-xl border-b border-gray-200/50 shadow-lg">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 md:py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 md:gap-4">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700">View:</span>
|
||||
@@ -114,6 +148,34 @@ const search = url.searchParams.get('search');
|
||||
|
||||
<!-- Advanced Filters -->
|
||||
<div class="flex flex-wrap items-center space-x-4">
|
||||
<!-- Location Display -->
|
||||
<div id="location-display" class="hidden items-center space-x-2 bg-blue-50 px-3 py-1.5 rounded-lg">
|
||||
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span id="location-text" class="text-sm text-blue-800 font-medium"></span>
|
||||
<button id="clear-location" class="text-blue-600 hover:text-blue-800 text-xs">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Distance Filter -->
|
||||
<div id="distance-filter" class="relative hidden">
|
||||
<select
|
||||
id="radius-filter"
|
||||
class="appearance-none bg-white border border-gray-300 rounded-lg px-4 py-2 pr-8 text-sm font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="10">Within 10 miles</option>
|
||||
<option value="25" selected>Within 25 miles</option>
|
||||
<option value="50">Within 50 miles</option>
|
||||
<option value="100">Within 100 miles</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<div class="relative">
|
||||
<select
|
||||
@@ -193,29 +255,29 @@ const search = url.searchParams.get('search');
|
||||
<!-- Calendar View -->
|
||||
<div id="calendar-view" class="hidden">
|
||||
<!-- Calendar Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center justify-between mb-4 md:mb-8">
|
||||
<div class="flex items-center space-x-2 md:space-x-4">
|
||||
<button
|
||||
id="prev-month"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
class="p-1 md:p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5 text-gray-600" 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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 id="calendar-month" class="text-2xl font-bold text-gray-900"></h2>
|
||||
<h2 id="calendar-month" class="text-lg md:text-2xl font-bold text-gray-900"></h2>
|
||||
<button
|
||||
id="next-month"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
class="p-1 md:p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
id="today-btn"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
class="px-3 md:px-4 py-1.5 md:py-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg transition-all duration-200 font-medium text-sm md:text-base shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
@@ -223,19 +285,40 @@ const search = url.searchParams.get('search');
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
|
||||
<!-- Day Headers -->
|
||||
<!-- Day Headers - Responsive -->
|
||||
<div class="grid grid-cols-7 bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
||||
<div class="p-4 text-center text-sm font-semibold text-gray-700">Sunday</div>
|
||||
<div class="p-4 text-center text-sm font-semibold text-gray-700">Monday</div>
|
||||
<div class="p-4 text-center text-sm font-semibold text-gray-700">Tuesday</div>
|
||||
<div class="p-4 text-center text-sm font-semibold text-gray-700">Wednesday</div>
|
||||
<div class="p-4 text-center text-sm font-semibold text-gray-700">Thursday</div>
|
||||
<div class="p-4 text-center text-sm font-semibold text-gray-700">Friday</div>
|
||||
<div class="p-4 text-center text-sm font-semibold text-gray-700">Saturday</div>
|
||||
<div class="p-2 md:p-4 text-center text-xs md:text-sm font-semibold text-gray-700">
|
||||
<span class="hidden md:inline">Sunday</span>
|
||||
<span class="md:hidden">Sun</span>
|
||||
</div>
|
||||
<div class="p-2 md:p-4 text-center text-xs md:text-sm font-semibold text-gray-700">
|
||||
<span class="hidden md:inline">Monday</span>
|
||||
<span class="md:hidden">Mon</span>
|
||||
</div>
|
||||
<div class="p-2 md:p-4 text-center text-xs md:text-sm font-semibold text-gray-700">
|
||||
<span class="hidden md:inline">Tuesday</span>
|
||||
<span class="md:hidden">Tue</span>
|
||||
</div>
|
||||
<div class="p-2 md:p-4 text-center text-xs md:text-sm font-semibold text-gray-700">
|
||||
<span class="hidden md:inline">Wednesday</span>
|
||||
<span class="md:hidden">Wed</span>
|
||||
</div>
|
||||
<div class="p-2 md:p-4 text-center text-xs md:text-sm font-semibold text-gray-700">
|
||||
<span class="hidden md:inline">Thursday</span>
|
||||
<span class="md:hidden">Thu</span>
|
||||
</div>
|
||||
<div class="p-2 md:p-4 text-center text-xs md:text-sm font-semibold text-gray-700">
|
||||
<span class="hidden md:inline">Friday</span>
|
||||
<span class="md:hidden">Fri</span>
|
||||
</div>
|
||||
<div class="p-2 md:p-4 text-center text-xs md:text-sm font-semibold text-gray-700">
|
||||
<span class="hidden md:inline">Saturday</span>
|
||||
<span class="md:hidden">Sat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Days -->
|
||||
<div id="calendar-grid" class="grid grid-cols-7 divide-x divide-gray-200">
|
||||
<div id="calendar-grid" class="grid grid-cols-7 gap-px bg-gray-200">
|
||||
<!-- Days will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,11 +428,17 @@ const search = url.searchParams.get('search');
|
||||
/* Calendar day hover effects */
|
||||
.calendar-day {
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.calendar-day:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* Event card animations */
|
||||
@@ -371,11 +460,16 @@ const search = url.searchParams.get('search');
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Import geolocation utilities
|
||||
const MAPBOX_TOKEN = '<%= mapboxToken %>';
|
||||
|
||||
// Calendar state
|
||||
let currentDate = new Date();
|
||||
let currentView = 'calendar';
|
||||
let events = [];
|
||||
let filteredEvents = [];
|
||||
let userLocation = null;
|
||||
let currentRadius = 25;
|
||||
|
||||
// DOM elements
|
||||
const loadingState = document.getElementById('loading-state');
|
||||
@@ -407,6 +501,18 @@ const search = url.searchParams.get('search');
|
||||
const nextMonthBtn = document.getElementById('next-month');
|
||||
const todayBtn = document.getElementById('today-btn');
|
||||
|
||||
// Location elements
|
||||
const enableLocationBtn = document.getElementById('enable-location');
|
||||
const locationStatus = document.getElementById('location-status');
|
||||
const locationDisplay = document.getElementById('location-display');
|
||||
const locationText = document.getElementById('location-text');
|
||||
const clearLocationBtn = document.getElementById('clear-location');
|
||||
const distanceFilter = document.getElementById('distance-filter');
|
||||
const radiusFilter = document.getElementById('radius-filter');
|
||||
const whatsHotSection = document.getElementById('whats-hot-section');
|
||||
const hotEventsGrid = document.getElementById('hot-events-grid');
|
||||
const hotLocationText = document.getElementById('hot-location-text');
|
||||
|
||||
// Utility functions
|
||||
function formatDate(date) {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
@@ -459,10 +565,200 @@ const search = url.searchParams.get('search');
|
||||
return icons[category] || icons.default;
|
||||
}
|
||||
|
||||
// Location functions
|
||||
async function requestLocationPermission() {
|
||||
try {
|
||||
// First try GPS location
|
||||
if (navigator.geolocation) {
|
||||
return new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
userLocation = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
source: 'gps'
|
||||
};
|
||||
await updateLocationDisplay();
|
||||
resolve(userLocation);
|
||||
},
|
||||
async (error) => {
|
||||
console.warn('GPS location failed, trying IP geolocation');
|
||||
// Fall back to IP geolocation
|
||||
const ipLocation = await getLocationFromIP();
|
||||
if (ipLocation) {
|
||||
userLocation = ipLocation;
|
||||
await updateLocationDisplay();
|
||||
resolve(userLocation);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Try IP geolocation if browser doesn't support GPS
|
||||
const ipLocation = await getLocationFromIP();
|
||||
if (ipLocation) {
|
||||
userLocation = ipLocation;
|
||||
await updateLocationDisplay();
|
||||
return userLocation;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting location:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocationFromIP() {
|
||||
try {
|
||||
const response = await fetch('https://ipapi.co/json/');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.latitude && data.longitude) {
|
||||
return {
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
city: data.city,
|
||||
state: data.region,
|
||||
country: data.country_code,
|
||||
source: 'ip'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error getting IP location:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateLocationDisplay() {
|
||||
if (userLocation) {
|
||||
// Update location status in hero
|
||||
locationStatus.innerHTML = `
|
||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-green-400 font-medium">Location enabled</span>
|
||||
${userLocation.city ? `<span class="text-white/60 text-sm ml-2">(${userLocation.city})</span>` : ''}
|
||||
`;
|
||||
|
||||
// Show location in filter bar
|
||||
locationDisplay.classList.remove('hidden');
|
||||
locationDisplay.classList.add('flex');
|
||||
locationText.textContent = userLocation.city && userLocation.state ?
|
||||
`${userLocation.city}, ${userLocation.state}` :
|
||||
'Location detected';
|
||||
|
||||
// Show distance filter
|
||||
distanceFilter.classList.remove('hidden');
|
||||
|
||||
// Load hot events
|
||||
await loadHotEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotEvents() {
|
||||
if (!userLocation) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/events/trending?lat=${userLocation.latitude}&lng=${userLocation.longitude}&radius=${currentRadius}&limit=4`);
|
||||
if (!response.ok) throw new Error('Failed to fetch trending events');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data.length > 0) {
|
||||
displayHotEvents(data.data);
|
||||
whatsHotSection.classList.remove('hidden');
|
||||
hotLocationText.textContent = `Within ${currentRadius} miles`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading hot events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function displayHotEvents(hotEvents) {
|
||||
hotEventsGrid.innerHTML = hotEvents.map(event => {
|
||||
const categoryColor = getCategoryColor(event.category);
|
||||
const categoryIcon = getCategoryIcon(event.category);
|
||||
return `
|
||||
<div class="bg-white rounded-xl shadow-lg hover:shadow-2xl transition-all duration-300 cursor-pointer transform hover:-translate-y-1" onclick="showEventModal(${JSON.stringify(event).replace(/"/g, '"')})">
|
||||
<div class="relative">
|
||||
<div class="h-32 bg-gradient-to-br ${categoryColor} rounded-t-xl flex items-center justify-center">
|
||||
<span class="text-4xl">${categoryIcon}</span>
|
||||
</div>
|
||||
${event.popularityScore > 50 ? `
|
||||
<div class="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-bold">
|
||||
HOT 🔥
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-bold text-gray-900 mb-1 line-clamp-1">${event.title}</h3>
|
||||
<p class="text-sm text-gray-600 mb-2">${event.venue}</p>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>${event.distanceMiles ? `${event.distanceMiles.toFixed(1)} mi` : ''}</span>
|
||||
<span>${event.ticketsSold || 0} sold</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
userLocation = null;
|
||||
locationStatus.innerHTML = `
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<button id="enable-location" class="text-white/90 hover:text-white font-medium">
|
||||
Enable location for personalized events
|
||||
</button>
|
||||
`;
|
||||
locationDisplay.classList.add('hidden');
|
||||
distanceFilter.classList.add('hidden');
|
||||
whatsHotSection.classList.add('hidden');
|
||||
|
||||
// Re-attach event listener
|
||||
document.getElementById('enable-location').addEventListener('click', enableLocation);
|
||||
|
||||
// Reload events without location filtering
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
async function enableLocation() {
|
||||
const btn = event.target;
|
||||
btn.textContent = 'Getting location...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await requestLocationPermission();
|
||||
if (userLocation) {
|
||||
await loadEvents(); // Reload events with location data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Location error:', error);
|
||||
btn.textContent = 'Location unavailable';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Enable location for personalized events';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// API functions
|
||||
async function fetchEvents(params = {}) {
|
||||
try {
|
||||
const url = new URL('/api/public/events', window.location.origin);
|
||||
|
||||
// Add location parameters if available
|
||||
if (userLocation && currentRadius) {
|
||||
params.lat = userLocation.latitude;
|
||||
params.lng = userLocation.longitude;
|
||||
params.radius = currentRadius;
|
||||
}
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) url.searchParams.append(key, value);
|
||||
});
|
||||
@@ -664,7 +960,7 @@ const search = url.searchParams.get('search');
|
||||
|
||||
function createCalendarDay(index, startingDayOfWeek, daysInMonth, daysInPrevMonth, year, month) {
|
||||
const dayDiv = document.createElement('div');
|
||||
dayDiv.className = 'calendar-day min-h-[120px] p-3 border-b border-gray-200 hover:bg-gradient-to-br hover:from-blue-50 hover:to-purple-50 transition-all duration-300 cursor-pointer group';
|
||||
dayDiv.className = 'calendar-day min-h-[80px] md:min-h-[120px] p-1 md:p-3 border-b border-gray-200 hover:bg-gradient-to-br hover:from-blue-50 hover:to-purple-50 transition-all duration-300 cursor-pointer group';
|
||||
|
||||
let dayNumber, isCurrentMonth, currentDayDate;
|
||||
|
||||
@@ -697,10 +993,10 @@ const search = url.searchParams.get('search');
|
||||
|
||||
// Create day number
|
||||
const dayNumberSpan = document.createElement('span');
|
||||
dayNumberSpan.className = `text-sm font-medium ${
|
||||
dayNumberSpan.className = `text-xs md:text-sm font-medium ${
|
||||
isCurrentMonth
|
||||
? isToday
|
||||
? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white px-2 py-1 rounded-full'
|
||||
? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white px-1 md:px-2 py-0.5 md:py-1 rounded-full'
|
||||
: 'text-gray-900'
|
||||
: 'text-gray-400'
|
||||
}`;
|
||||
@@ -711,17 +1007,21 @@ const search = url.searchParams.get('search');
|
||||
// Add events
|
||||
if (dayEvents.length > 0 && isCurrentMonth) {
|
||||
const eventsContainer = document.createElement('div');
|
||||
eventsContainer.className = 'mt-2 space-y-1';
|
||||
eventsContainer.className = 'mt-1 md:mt-2 space-y-0.5 md:space-y-1';
|
||||
|
||||
// Show up to 3 events, then a "more" indicator
|
||||
const visibleEvents = dayEvents.slice(0, 3);
|
||||
// Show fewer events on mobile
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const maxVisibleEvents = isMobile ? 1 : 3;
|
||||
const visibleEvents = dayEvents.slice(0, maxVisibleEvents);
|
||||
const remainingCount = dayEvents.length - visibleEvents.length;
|
||||
|
||||
visibleEvents.forEach(event => {
|
||||
const eventDiv = document.createElement('div');
|
||||
const categoryColor = getCategoryColor(event.category);
|
||||
eventDiv.className = `text-xs px-2 py-1 rounded-md bg-gradient-to-r ${categoryColor} text-white font-medium cursor-pointer transform hover:scale-105 transition-all duration-200 shadow-sm hover:shadow-md`;
|
||||
eventDiv.textContent = event.title.length > 20 ? event.title.substring(0, 20) + '...' : event.title;
|
||||
eventDiv.className = `text-xs px-1 md:px-2 py-0.5 md:py-1 rounded-md bg-gradient-to-r ${categoryColor} text-white font-medium cursor-pointer transform hover:scale-105 transition-all duration-200 shadow-sm hover:shadow-md truncate`;
|
||||
const maxTitleLength = isMobile ? 10 : 20;
|
||||
eventDiv.textContent = event.title.length > maxTitleLength ? event.title.substring(0, maxTitleLength) + '...' : event.title;
|
||||
eventDiv.title = event.title; // Full title on hover
|
||||
eventDiv.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showEventModal(event);
|
||||
@@ -731,8 +1031,8 @@ const search = url.searchParams.get('search');
|
||||
|
||||
if (remainingCount > 0) {
|
||||
const moreDiv = document.createElement('div');
|
||||
moreDiv.className = 'text-xs text-gray-600 font-medium px-2 py-1 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors cursor-pointer';
|
||||
moreDiv.textContent = `+${remainingCount} more`;
|
||||
moreDiv.className = 'text-xs text-gray-600 font-medium px-1 md:px-2 py-0.5 md:py-1 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors cursor-pointer';
|
||||
moreDiv.textContent = `+${remainingCount}`;
|
||||
moreDiv.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Could show a day view modal here
|
||||
@@ -1130,6 +1430,15 @@ const search = url.searchParams.get('search');
|
||||
|
||||
modalBackdrop.addEventListener('click', hideEventModal);
|
||||
|
||||
// Location event listeners
|
||||
enableLocationBtn.addEventListener('click', enableLocation);
|
||||
clearLocationBtn.addEventListener('click', clearLocation);
|
||||
radiusFilter.addEventListener('change', async () => {
|
||||
currentRadius = parseInt(radiusFilter.value);
|
||||
await loadEvents();
|
||||
await loadHotEvents();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !eventModal.classList.contains('hidden')) {
|
||||
@@ -1137,6 +1446,17 @@ const search = url.searchParams.get('search');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resize for mobile responsiveness
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (currentView === 'calendar') {
|
||||
renderCalendarGrid();
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
loadEvents();
|
||||
</script>
|
||||
@@ -63,6 +63,17 @@ const formattedTime = eventDate.toLocaleTimeString('en-US', {
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||
<main class="max-w-5xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow-2xl rounded-2xl overflow-hidden border border-slate-200">
|
||||
<!-- Event Image -->
|
||||
{event.image_url && (
|
||||
<div class="w-full h-64 md:h-72 lg:h-80 overflow-hidden">
|
||||
<img
|
||||
src={event.image_url}
|
||||
alt={event.title}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<!-- Compact Header -->
|
||||
<div class="bg-gradient-to-r from-slate-800 to-slate-900 rounded-xl p-6 mb-6 text-white">
|
||||
|
||||
@@ -145,6 +145,17 @@ const formattedTime = eventDate.toLocaleTimeString('en-US', {
|
||||
</head>
|
||||
<body>
|
||||
<div class="widget-container">
|
||||
<!-- Event Image -->
|
||||
{event.image_url && (
|
||||
<div class="w-full h-32 sm:h-40 overflow-hidden">
|
||||
<img
|
||||
src={event.image_url}
|
||||
alt={event.title}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="embed-content">
|
||||
<!-- Compact Header -->
|
||||
{!hideHeader && (
|
||||
|
||||
7624
src/pages/events/[id]/manage-old.astro
Normal file
7624
src/pages/events/[id]/manage-old.astro
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,15 @@ import Navigation from '../../components/Navigation.astro';
|
||||
<h2 class="text-2xl font-light text-white mb-6">Event Details</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Event Image Upload -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-white mb-4">Event Image</h3>
|
||||
<div id="image-upload-container"></div>
|
||||
<p class="text-sm text-white/60 mt-2">
|
||||
Upload a horizontal image. Recommended: 1200×628px. Crop to fit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-semibold text-white/90 mb-2">Event Title</label>
|
||||
<input
|
||||
@@ -289,6 +298,9 @@ import Navigation from '../../components/Navigation.astro';
|
||||
|
||||
<script>
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import ImageUploadCropper from '../../components/ImageUploadCropper.tsx';
|
||||
|
||||
const eventForm = document.getElementById('event-form') as HTMLFormElement;
|
||||
const errorMessage = document.getElementById('error-message') as HTMLDivElement;
|
||||
@@ -298,6 +310,7 @@ import Navigation from '../../components/Navigation.astro';
|
||||
|
||||
let currentOrganizationId = null;
|
||||
let selectedAddons = [];
|
||||
let eventImageUrl = null;
|
||||
|
||||
// Check authentication
|
||||
async function checkAuth() {
|
||||
@@ -471,7 +484,8 @@ import Navigation from '../../components/Navigation.astro';
|
||||
description,
|
||||
created_by: user.id,
|
||||
organization_id: organizationId,
|
||||
seating_type: seatingType
|
||||
seating_type: seatingType,
|
||||
image_url: eventImageUrl
|
||||
}
|
||||
])
|
||||
.select()
|
||||
@@ -513,11 +527,25 @@ import Navigation from '../../components/Navigation.astro';
|
||||
radio.addEventListener('change', handleVenueOptionChange);
|
||||
});
|
||||
|
||||
// Initialize Image Upload Component
|
||||
function initializeImageUpload() {
|
||||
const container = document.getElementById('image-upload-container');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(createElement(ImageUploadCropper, {
|
||||
onImageChange: (imageUrl) => {
|
||||
eventImageUrl = imageUrl;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
checkAuth().then(session => {
|
||||
if (session && currentOrganizationId) {
|
||||
loadVenues();
|
||||
}
|
||||
handleVenueOptionChange(); // Set initial state
|
||||
initializeImageUpload(); // Initialize image upload
|
||||
});
|
||||
</script>
|
||||
@@ -1,13 +1,11 @@
|
||||
---
|
||||
import LoginLayout from '../layouts/LoginLayout.astro';
|
||||
import { generateCSRFToken } from '../lib/auth';
|
||||
|
||||
// Generate CSRF token for the form
|
||||
const csrfToken = generateCSRFToken();
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import PublicHeader from '../components/PublicHeader.astro';
|
||||
import ComparisonSection from '../components/ComparisonSection.astro';
|
||||
---
|
||||
|
||||
<LoginLayout title="Login - Black Canyon Tickets">
|
||||
<main class="h-screen relative overflow-hidden flex flex-col">
|
||||
<Layout title="Black Canyon Tickets - Premium Event Ticketing Platform">
|
||||
<div class="min-h-screen relative overflow-hidden">
|
||||
<!-- Premium Hero Background with Animated Gradients -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<!-- Animated Background Elements -->
|
||||
@@ -30,265 +28,187 @@ const csrfToken = generateCSRFToken();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex-1 container mx-auto px-4 py-4 lg:py-6 flex items-center">
|
||||
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12 items-center max-w-6xl mx-auto w-full">
|
||||
<!-- Navigation -->
|
||||
<PublicHeader />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-32 pb-24 lg:pt-40 lg:pb-32">
|
||||
<div class="text-center">
|
||||
<!-- Badge -->
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-full bg-white/10 backdrop-blur-lg border border-white/20 mb-8">
|
||||
<span class="text-sm font-medium text-white/90">✨ Premium Event Ticketing Platform</span>
|
||||
</div>
|
||||
|
||||
<!-- Left Column: Brand & Features -->
|
||||
<div class="lg:pr-8">
|
||||
<!-- Brand Header -->
|
||||
<div class="text-center lg:text-left mb-8">
|
||||
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-light text-white mb-4 tracking-tight">
|
||||
Black Canyon
|
||||
<span class="font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
Tickets
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-lg lg:text-xl text-white/80 mb-6 lg:mb-8 max-w-2xl leading-relaxed">
|
||||
Elegant ticketing platform for Colorado's most prestigious venues
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center lg:justify-start gap-4 text-xs lg:text-sm text-white/70 mb-6">
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-blue-400 rounded-full mr-2"></span>
|
||||
Self-serve event setup
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-purple-400 rounded-full mr-2"></span>
|
||||
Automated Stripe payouts
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-pink-400 rounded-full mr-2"></span>
|
||||
Mobile QR scanning — no apps required
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Features -->
|
||||
<div class="grid sm:grid-cols-3 lg:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white/10 backdrop-blur-lg rounded-xl p-4 border border-white/20 text-center">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span class="text-lg">💡</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white text-sm mb-1">Quick Setup</h3>
|
||||
<p class="text-xs text-white/70">Create events in minutes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-lg rounded-xl p-4 border border-white/20 text-center">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-emerald-500 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span class="text-lg">💸</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white text-sm mb-1">Fast Payments</h3>
|
||||
<p class="text-xs text-white/70">Automated Stripe payouts</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-lg rounded-xl p-4 border border-white/20 text-center">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span class="text-lg">📊</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white text-sm mb-1">Live Analytics</h3>
|
||||
<p class="text-xs text-white/70">Dashboard + exports</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Login Form -->
|
||||
<div class="max-w-md mx-auto w-full">
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-600 rounded-2xl blur opacity-20 group-hover:opacity-30 transition-opacity"></div>
|
||||
<div class="relative bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 lg:p-8 shadow-2xl">
|
||||
<!-- Form Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Organizer Login</h2>
|
||||
<p class="text-sm text-white/70">Manage your events and track ticket sales</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div id="auth-form">
|
||||
<form id="login-form" class="space-y-6">
|
||||
<input type="hidden" id="csrf-token" value={csrfToken} />
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-white mb-2">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
class="appearance-none block w-full px-4 py-3 bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg shadow-sm placeholder-white/60 text-white focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-white mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="appearance-none block w-full px-4 py-3 bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg shadow-sm placeholder-white/60 text-white focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="name-field" class="hidden">
|
||||
<label for="name" class="block text-sm font-medium text-white mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
class="appearance-none block w-full px-4 py-3 bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg shadow-sm placeholder-white/60 text-white focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors"
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-lg text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 hover:shadow-xl"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-mode"
|
||||
class="text-sm text-blue-400 hover:text-blue-300 font-medium transition-colors"
|
||||
>
|
||||
Don't have an account? Sign up
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="mt-4 text-sm text-red-400 hidden"></div>
|
||||
|
||||
<!-- Privacy Policy and Terms Links -->
|
||||
<div class="mt-6 pt-6 border-t border-white/20">
|
||||
<div class="text-center text-xs text-white/60">
|
||||
By signing up, you agree to our
|
||||
<a href="/terms" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
|
||||
Terms of Service
|
||||
</a>
|
||||
and
|
||||
<a href="/privacy" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Minimal Footer -->
|
||||
<footer class="relative z-10 py-4 lg:py-6">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex flex-col items-center space-y-2">
|
||||
<div class="flex space-x-6">
|
||||
<a href="/support" class="text-white/40 hover:text-white/60 text-xs lg:text-sm transition-colors">
|
||||
Support
|
||||
</a>
|
||||
<a href="/terms" class="text-white/40 hover:text-white/60 text-xs lg:text-sm transition-colors">
|
||||
Terms
|
||||
</a>
|
||||
<a href="/privacy" class="text-white/40 hover:text-white/60 text-xs lg:text-sm transition-colors">
|
||||
Privacy
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-white/30 text-xs">
|
||||
© 2024 Black Canyon Tickets • Montrose, CO
|
||||
<!-- Main Heading -->
|
||||
<h1 class="text-5xl lg:text-7xl font-light text-white mb-6 tracking-tight">
|
||||
Premium Ticketing for
|
||||
<span class="font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
Colorado's Elite
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subheading -->
|
||||
<p class="text-xl lg:text-2xl text-white/80 mb-12 max-w-3xl mx-auto leading-relaxed">
|
||||
Elegant self-service platform designed for upscale venues, prestigious events, and discerning organizers
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center mb-12">
|
||||
<a href="/login" class="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200 transform hover:scale-105 hover:shadow-xl">
|
||||
Start Selling Tickets
|
||||
</a>
|
||||
<a href="/calendar" class="text-white/80 hover:text-white px-8 py-4 rounded-xl font-semibold text-lg transition-colors border border-white/20 hover:border-white/40">
|
||||
View Events
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Feature Points -->
|
||||
<div class="flex flex-wrap justify-center gap-6 text-sm text-white/70">
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-blue-400 rounded-full mr-2"></span>
|
||||
No setup fees
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-purple-400 rounded-full mr-2"></span>
|
||||
Instant payouts
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-pink-400 rounded-full mr-2"></span>
|
||||
Mobile-first design
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</LoginLayout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="relative z-10 py-20 lg:py-32">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-16">
|
||||
<h3 class="text-3xl lg:text-5xl font-light text-white mb-6">
|
||||
Why Choose
|
||||
<span class="font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
Black Canyon
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-lg text-white/80 max-w-2xl mx-auto">
|
||||
Built specifically for Colorado's premium venues and high-end events
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Tiles Grid -->
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||
|
||||
<!-- Quick Setup Tile -->
|
||||
<div class="group bg-white/10 backdrop-blur-lg rounded-xl p-6 border border-white/20 text-center hover:bg-white/15 transition-all duration-300 hover:transform hover:scale-105">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<span class="text-xl">💡</span>
|
||||
</div>
|
||||
<h4 class="text-xl font-semibold text-white mb-2">Quick Setup</h4>
|
||||
<p class="text-white/70 text-sm mb-4">
|
||||
Create professional events in minutes with our intuitive dashboard
|
||||
</p>
|
||||
<button class="text-blue-400 hover:text-blue-300 font-medium text-sm transition-colors">
|
||||
Learn More
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Real Experience Tile -->
|
||||
<div class="group bg-white/10 backdrop-blur-lg rounded-xl p-6 border border-white/20 text-center hover:bg-white/15 transition-all duration-300 hover:transform hover:scale-105">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-emerald-500 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<span class="text-xl">🎯</span>
|
||||
</div>
|
||||
<h4 class="text-xl font-semibold text-white mb-2">Built by Event Pros</h4>
|
||||
<p class="text-white/70 text-sm mb-4">
|
||||
Created by people who've actually worked ticket gates and run events
|
||||
</p>
|
||||
<button class="text-blue-400 hover:text-blue-300 font-medium text-sm transition-colors">
|
||||
Learn More
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tile -->
|
||||
<div class="group bg-white/10 backdrop-blur-lg rounded-xl p-6 border border-white/20 text-center hover:bg-white/15 transition-all duration-300 hover:transform hover:scale-105">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<span class="text-xl">📊</span>
|
||||
</div>
|
||||
<h4 class="text-xl font-semibold text-white mb-2">Live Analytics</h4>
|
||||
<p class="text-white/70 text-sm mb-4">
|
||||
Real-time sales tracking with comprehensive reporting
|
||||
</p>
|
||||
<button class="text-blue-400 hover:text-blue-300 font-medium text-sm transition-colors">
|
||||
Learn More
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Human Support Tile -->
|
||||
<div class="group bg-white/10 backdrop-blur-lg rounded-xl p-6 border border-white/20 text-center hover:bg-white/15 transition-all duration-300 hover:transform hover:scale-105">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<span class="text-xl">🤝</span>
|
||||
</div>
|
||||
<h4 class="text-xl font-semibold text-white mb-2">Real Human Support</h4>
|
||||
<p class="text-white/70 text-sm mb-4">
|
||||
Actual humans help you before and during your event
|
||||
</p>
|
||||
<button class="text-blue-400 hover:text-blue-300 font-medium text-sm transition-colors">
|
||||
Get Help
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
const loginForm = document.getElementById('login-form') as HTMLFormElement;
|
||||
const toggleMode = document.getElementById('toggle-mode') as HTMLButtonElement;
|
||||
const nameField = document.getElementById('name-field') as HTMLDivElement;
|
||||
const nameInput = document.getElementById('name') as HTMLInputElement;
|
||||
const submitButton = loginForm.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
const errorMessage = document.getElementById('error-message') as HTMLDivElement;
|
||||
<!-- Competitive Comparison Section -->
|
||||
<ComparisonSection />
|
||||
|
||||
let isSignUpMode = false;
|
||||
<!-- Call to Action -->
|
||||
<section class="relative z-10 py-20">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<h3 class="text-3xl lg:text-5xl font-light text-white mb-6">
|
||||
Ready to
|
||||
<span class="font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
Get Started?
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-xl text-white/80 mb-8">
|
||||
Join Colorado's most prestigious venues and start selling tickets today
|
||||
</p>
|
||||
<a href="/login" class="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200 transform hover:scale-105 hover:shadow-xl">
|
||||
Create Your Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
toggleMode.addEventListener('click', () => {
|
||||
isSignUpMode = !isSignUpMode;
|
||||
if (isSignUpMode) {
|
||||
nameField.classList.remove('hidden');
|
||||
nameInput.required = true;
|
||||
submitButton.textContent = 'Sign up';
|
||||
toggleMode.textContent = 'Already have an account? Sign in';
|
||||
} else {
|
||||
nameField.classList.add('hidden');
|
||||
nameInput.required = false;
|
||||
submitButton.textContent = 'Sign in';
|
||||
toggleMode.textContent = "Don't have an account? Sign up";
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* Smooth scrolling for anchor links */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
});
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(loginForm);
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
try {
|
||||
errorMessage.classList.add('hidden');
|
||||
|
||||
if (isSignUpMode) {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
alert('Check your email for the confirmation link!');
|
||||
} else {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
window.location.pathname = '/dashboard';
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.textContent = error.message;
|
||||
errorMessage.classList.remove('hidden');
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user is already logged in
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session) {
|
||||
window.location.pathname = '/dashboard';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.delay-1000 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.delay-500 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
</style>
|
||||
294
src/pages/login.astro
Normal file
294
src/pages/login.astro
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
import LoginLayout from '../layouts/LoginLayout.astro';
|
||||
import { generateCSRFToken } from '../lib/auth';
|
||||
|
||||
// Generate CSRF token for the form
|
||||
const csrfToken = generateCSRFToken();
|
||||
---
|
||||
|
||||
<LoginLayout title="Login - Black Canyon Tickets">
|
||||
<main class="h-screen relative overflow-hidden flex flex-col">
|
||||
<!-- Premium Hero Background with Animated Gradients -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<!-- Animated Background Elements -->
|
||||
<div class="absolute inset-0 opacity-20">
|
||||
<div class="absolute top-20 left-20 w-64 h-64 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full blur-3xl animate-pulse delay-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Geometric Patterns -->
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<svg class="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="white" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex-1 container mx-auto px-4 py-4 lg:py-6 flex items-center">
|
||||
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12 items-center max-w-6xl mx-auto w-full">
|
||||
|
||||
<!-- Left Column: Brand & Features -->
|
||||
<div class="lg:pr-8">
|
||||
<!-- Brand Header -->
|
||||
<div class="text-center lg:text-left mb-8">
|
||||
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-light text-white mb-4 tracking-tight">
|
||||
Black Canyon
|
||||
<span class="font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
Tickets
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-lg lg:text-xl text-white/80 mb-6 lg:mb-8 max-w-2xl leading-relaxed">
|
||||
Elegant ticketing platform for Colorado's most prestigious venues
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center lg:justify-start gap-4 text-xs lg:text-sm text-white/70 mb-6">
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-blue-400 rounded-full mr-2"></span>
|
||||
Self-serve event setup
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-purple-400 rounded-full mr-2"></span>
|
||||
Automated Stripe payouts
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-pink-400 rounded-full mr-2"></span>
|
||||
Mobile QR scanning — no apps required
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Features -->
|
||||
<div class="grid sm:grid-cols-3 lg:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white/10 backdrop-blur-lg rounded-xl p-4 border border-white/20 text-center">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span class="text-lg">💡</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white text-sm mb-1">Quick Setup</h3>
|
||||
<p class="text-xs text-white/70">Create events in minutes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-lg rounded-xl p-4 border border-white/20 text-center">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-emerald-500 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span class="text-lg">💸</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white text-sm mb-1">Fast Payments</h3>
|
||||
<p class="text-xs text-white/70">Automated Stripe payouts</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-lg rounded-xl p-4 border border-white/20 text-center">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span class="text-lg">📊</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white text-sm mb-1">Live Analytics</h3>
|
||||
<p class="text-xs text-white/70">Dashboard + exports</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Login Form -->
|
||||
<div class="max-w-md mx-auto w-full">
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-600 rounded-2xl blur opacity-20 group-hover:opacity-30 transition-opacity"></div>
|
||||
<div class="relative bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 lg:p-8 shadow-2xl">
|
||||
<!-- Form Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Organizer Login</h2>
|
||||
<p class="text-sm text-white/70">Manage your events and track ticket sales</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div id="auth-form">
|
||||
<form id="login-form" class="space-y-6">
|
||||
<input type="hidden" id="csrf-token" value={csrfToken} />
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-white mb-2">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
class="appearance-none block w-full px-4 py-3 bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg shadow-sm placeholder-white/60 text-white focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-white mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="appearance-none block w-full px-4 py-3 bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg shadow-sm placeholder-white/60 text-white focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="name-field" class="hidden">
|
||||
<label for="name" class="block text-sm font-medium text-white mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
class="appearance-none block w-full px-4 py-3 bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg shadow-sm placeholder-white/60 text-white focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors"
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-lg text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 hover:shadow-xl"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-mode"
|
||||
class="text-sm text-blue-400 hover:text-blue-300 font-medium transition-colors"
|
||||
>
|
||||
Don't have an account? Sign up
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="mt-4 text-sm text-red-400 hidden"></div>
|
||||
|
||||
<!-- Privacy Policy and Terms Links -->
|
||||
<div class="mt-6 pt-6 border-t border-white/20">
|
||||
<div class="text-center text-xs text-white/60">
|
||||
By signing up, you agree to our
|
||||
<a href="/terms" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
|
||||
Terms of Service
|
||||
</a>
|
||||
and
|
||||
<a href="/privacy" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Minimal Footer -->
|
||||
<footer class="relative z-10 py-4 lg:py-6">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex flex-col items-center space-y-2">
|
||||
<div class="flex space-x-6">
|
||||
<a href="/support" class="text-white/40 hover:text-white/60 text-xs lg:text-sm transition-colors">
|
||||
Support
|
||||
</a>
|
||||
<a href="/terms" class="text-white/40 hover:text-white/60 text-xs lg:text-sm transition-colors">
|
||||
Terms
|
||||
</a>
|
||||
<a href="/privacy" class="text-white/40 hover:text-white/60 text-xs lg:text-sm transition-colors">
|
||||
Privacy
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-white/30 text-xs">
|
||||
© 2024 Black Canyon Tickets • Montrose, CO
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</LoginLayout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const loginForm = document.getElementById('login-form') as HTMLFormElement;
|
||||
const toggleMode = document.getElementById('toggle-mode') as HTMLButtonElement;
|
||||
const nameField = document.getElementById('name-field') as HTMLDivElement;
|
||||
const nameInput = document.getElementById('name') as HTMLInputElement;
|
||||
const submitButton = loginForm.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
const errorMessage = document.getElementById('error-message') as HTMLDivElement;
|
||||
|
||||
let isSignUpMode = false;
|
||||
|
||||
toggleMode.addEventListener('click', () => {
|
||||
isSignUpMode = !isSignUpMode;
|
||||
if (isSignUpMode) {
|
||||
nameField.classList.remove('hidden');
|
||||
nameInput.required = true;
|
||||
submitButton.textContent = 'Sign up';
|
||||
toggleMode.textContent = 'Already have an account? Sign in';
|
||||
} else {
|
||||
nameField.classList.add('hidden');
|
||||
nameInput.required = false;
|
||||
submitButton.textContent = 'Sign in';
|
||||
toggleMode.textContent = "Don't have an account? Sign up";
|
||||
}
|
||||
});
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(loginForm);
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
try {
|
||||
errorMessage.classList.add('hidden');
|
||||
|
||||
if (isSignUpMode) {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
alert('Check your email for the confirmation link!');
|
||||
} else {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
window.location.pathname = '/dashboard';
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.textContent = error.message;
|
||||
errorMessage.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user is already logged in
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session) {
|
||||
window.location.pathname = '/dashboard';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user