Initial commit - Black Canyon Tickets whitelabel platform
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
24
src/.gitignore
vendored
Normal file
24
src/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
284
src/components/AgeVerification.astro
Normal file
284
src/components/AgeVerification.astro
Normal file
@@ -0,0 +1,284 @@
|
||||
---
|
||||
// Age verification component for ticket purchases
|
||||
export interface Props {
|
||||
minimumAge?: number;
|
||||
eventTitle?: string;
|
||||
onVerified?: string; // Callback function name
|
||||
}
|
||||
|
||||
const { minimumAge = 18, eventTitle = "this event", onVerified = "onAgeVerified" } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
id="age-verification-modal"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-75 flex items-center justify-center p-4"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<!-- Header -->
|
||||
<div class="text-center p-6 border-b border-gray-200">
|
||||
<div class="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-2">Age Verification Required</h2>
|
||||
<p class="text-gray-600">
|
||||
You must be at least <strong>{minimumAge} years old</strong> to purchase tickets for {eventTitle}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<!-- Date of birth input -->
|
||||
<div>
|
||||
<label for="date-of-birth" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date of Birth
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date-of-birth"
|
||||
name="dateOfBirth"
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
We use this information solely for age verification and do not store it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div id="age-verification-error" class="hidden bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span id="age-verification-error-text" class="text-sm text-red-700"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COPPA notice for under 13 -->
|
||||
<div id="coppa-notice" class="hidden bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-500 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm text-blue-700">
|
||||
<p class="font-medium">Parental Consent Required</p>
|
||||
<p>Users under 13 require verifiable parental consent. Please contact our support team for assistance.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 p-6 border-t border-gray-200">
|
||||
<button
|
||||
id="age-verification-cancel"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
id="age-verification-confirm"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Verify & Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Privacy notice -->
|
||||
<div class="px-6 pb-6">
|
||||
<p class="text-xs text-gray-500 text-center">
|
||||
By proceeding, you confirm that the information provided is accurate.
|
||||
This information is used solely for age verification and is not stored or shared.
|
||||
<a href="/privacy" target="_blank" class="text-blue-600 hover:text-blue-500 underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ minimumAge, onVerified }}>
|
||||
class AgeVerification {
|
||||
private modal: HTMLElement;
|
||||
private dateInput: HTMLInputElement;
|
||||
private confirmButton: HTMLButtonElement;
|
||||
private errorDiv: HTMLElement;
|
||||
private errorText: HTMLElement;
|
||||
private coppaNotice: HTMLElement;
|
||||
private isVerified: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.modal = document.getElementById('age-verification-modal')!;
|
||||
this.dateInput = document.getElementById('date-of-birth') as HTMLInputElement;
|
||||
this.confirmButton = document.getElementById('age-verification-confirm') as HTMLButtonElement;
|
||||
this.errorDiv = document.getElementById('age-verification-error')!;
|
||||
this.errorText = document.getElementById('age-verification-error-text')!;
|
||||
this.coppaNotice = document.getElementById('coppa-notice')!;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
// Date input change
|
||||
this.dateInput.addEventListener('change', () => {
|
||||
this.validateAge();
|
||||
});
|
||||
|
||||
// Confirm button
|
||||
this.confirmButton.addEventListener('click', () => {
|
||||
this.confirmAge();
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
document.getElementById('age-verification-cancel')?.addEventListener('click', () => {
|
||||
this.hide();
|
||||
});
|
||||
|
||||
// Modal backdrop click
|
||||
this.modal.addEventListener('click', (e) => {
|
||||
if (e.target === this.modal) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private validateAge() {
|
||||
this.hideError();
|
||||
this.hideCoppaNotice();
|
||||
|
||||
const birthDate = new Date(this.dateInput.value);
|
||||
const today = new Date();
|
||||
|
||||
if (!this.dateInput.value) {
|
||||
this.confirmButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate age
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
}
|
||||
|
||||
// Check if under 13 (COPPA)
|
||||
if (age < 13) {
|
||||
this.showCoppaNotice();
|
||||
this.confirmButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check minimum age requirement
|
||||
if (age < minimumAge) {
|
||||
this.showError(`You must be at least ${minimumAge} years old to purchase tickets for this event.`);
|
||||
this.confirmButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid age
|
||||
this.confirmButton.disabled = false;
|
||||
}
|
||||
|
||||
private confirmAge() {
|
||||
if (this.confirmButton.disabled) return;
|
||||
|
||||
// Mark as verified
|
||||
this.isVerified = true;
|
||||
|
||||
// Store verification (session only, not persistent)
|
||||
sessionStorage.setItem('age_verified', 'true');
|
||||
sessionStorage.setItem('age_verified_timestamp', Date.now().toString());
|
||||
|
||||
// Call the callback function if provided
|
||||
if (typeof window[onVerified] === 'function') {
|
||||
window[onVerified]();
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
this.hide();
|
||||
|
||||
// Dispatch custom event
|
||||
window.dispatchEvent(new CustomEvent('ageVerified', {
|
||||
detail: { verified: true, minimumAge }
|
||||
}));
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
this.errorText.textContent = message;
|
||||
this.errorDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private hideError() {
|
||||
this.errorDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
private showCoppaNotice() {
|
||||
this.coppaNotice.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private hideCoppaNotice() {
|
||||
this.coppaNotice.classList.add('hidden');
|
||||
}
|
||||
|
||||
public show() {
|
||||
// Check if already verified in this session
|
||||
const verified = sessionStorage.getItem('age_verified');
|
||||
const timestamp = sessionStorage.getItem('age_verified_timestamp');
|
||||
|
||||
if (verified === 'true' && timestamp) {
|
||||
// Check if verification is still valid (within 1 hour)
|
||||
const verificationAge = Date.now() - parseInt(timestamp);
|
||||
if (verificationAge < 60 * 60 * 1000) { // 1 hour
|
||||
this.isVerified = true;
|
||||
if (typeof window[onVerified] === 'function') {
|
||||
window[onVerified]();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus on date input
|
||||
setTimeout(() => {
|
||||
this.dateInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
public hide() {
|
||||
this.modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
public isAgeVerified(): boolean {
|
||||
return this.isVerified;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize and expose globally
|
||||
const ageVerification = new AgeVerification();
|
||||
(window as any).ageVerification = ageVerification;
|
||||
(window as any).showAgeVerification = () => ageVerification.show();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Custom styles for date input */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
</style>
|
||||
242
src/components/Calendar.tsx
Normal file
242
src/components/Calendar.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
start_time: string;
|
||||
venue: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface CalendarProps {
|
||||
events: Event[];
|
||||
onEventClick?: (event: Event) => void;
|
||||
}
|
||||
|
||||
const Calendar: React.FC<CalendarProps> = ({ events, onEventClick }) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [view, setView] = useState<'month' | 'week'>('month');
|
||||
|
||||
const today = new Date();
|
||||
const currentMonth = currentDate.getMonth();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
// Get days in month
|
||||
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
|
||||
|
||||
// Generate calendar grid
|
||||
const calendarDays = [];
|
||||
|
||||
// Empty cells for days before month starts
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
calendarDays.push(null);
|
||||
}
|
||||
|
||||
// Days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarDays.push(day);
|
||||
}
|
||||
|
||||
// Get events for a specific day
|
||||
const getEventsForDay = (day: number) => {
|
||||
const dayDate = new Date(currentYear, currentMonth, day);
|
||||
return events.filter(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return eventDate.toDateString() === dayDate.toDateString();
|
||||
});
|
||||
};
|
||||
|
||||
// Navigation functions
|
||||
const previousMonth = () => {
|
||||
setCurrentDate(new Date(currentYear, currentMonth - 1, 1));
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentDate(new Date(currentYear, currentMonth + 1, 1));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const isToday = (day: number) => {
|
||||
const dayDate = new Date(currentYear, currentMonth, day);
|
||||
return dayDate.toDateString() === today.toDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{/* Calendar Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{monthNames[currentMonth]} {currentYear}
|
||||
</h2>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<button
|
||||
onClick={() => setView('month')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded-l-md border ${
|
||||
view === 'month'
|
||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('week')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded-r-md border-t border-r border-b ${
|
||||
view === 'week'
|
||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={previousMonth}
|
||||
className="p-1 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<svg className="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-1 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<svg className="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="p-6">
|
||||
{/* Day Headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{dayNames.map(day => (
|
||||
<div key={day} className="text-center text-sm font-medium text-gray-500 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Days */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendarDays.map((day, index) => {
|
||||
if (day === null) {
|
||||
return <div key={index} className="aspect-square"></div>;
|
||||
}
|
||||
|
||||
const dayEvents = getEventsForDay(day);
|
||||
const isCurrentDay = isToday(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`aspect-square border rounded-lg p-1 hover:bg-gray-50 ${
|
||||
isCurrentDay ? 'bg-indigo-50 border-indigo-200' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-1 ${
|
||||
isCurrentDay ? 'text-indigo-700' : 'text-gray-900'
|
||||
}`}>
|
||||
{day}
|
||||
</div>
|
||||
|
||||
{/* Events for this day */}
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 2).map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className="text-xs bg-indigo-100 text-indigo-800 rounded px-1 py-0.5 cursor-pointer hover:bg-indigo-200 truncate"
|
||||
title={`${event.title} at ${event.venue}`}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dayEvents.length > 2 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
+{dayEvents.length - 2} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Events List */}
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Upcoming Events</h3>
|
||||
<div className="space-y-2">
|
||||
{events
|
||||
.filter(event => new Date(event.start_time) >= today)
|
||||
.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime())
|
||||
.slice(0, 5)
|
||||
.map(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{event.title}</div>
|
||||
<div className="text-xs text-gray-500">{event.venue}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{eventDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{events.filter(event => new Date(event.start_time) >= today).length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
No upcoming events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
41
src/components/Card.astro
Normal file
41
src/components/Card.astro
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
export interface Props {
|
||||
variant?: 'default' | 'elevated' | 'gradient' | 'outlined';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
interactive?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
interactive = false,
|
||||
class: className = ''
|
||||
} = Astro.props;
|
||||
|
||||
const baseClasses = 'bg-white border border-slate-200/50 transition-all duration-200 ease-out';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'shadow-sm',
|
||||
elevated: 'shadow-lg',
|
||||
gradient: 'bg-gradient-to-br from-slate-50 to-white shadow-lg',
|
||||
outlined: 'border-2 border-slate-300 shadow-none'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'rounded-xl p-4',
|
||||
md: 'rounded-2xl p-6',
|
||||
lg: 'rounded-2xl p-8',
|
||||
xl: 'rounded-3xl p-10'
|
||||
};
|
||||
|
||||
const interactiveClasses = interactive
|
||||
? 'hover:shadow-xl hover:-translate-y-0.5 cursor-pointer'
|
||||
: '';
|
||||
|
||||
const cardClasses = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${interactiveClasses} ${className}`;
|
||||
---
|
||||
|
||||
<div class={cardClasses}>
|
||||
<slot />
|
||||
</div>
|
||||
191
src/components/ChatWidget.tsx
Normal file
191
src/components/ChatWidget.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
isUser: boolean;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const ChatWidget: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
text: 'Hello! I\'m here to help you with Black Canyon Tickets. How can I assist you today?',
|
||||
isUser: false,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputMessage.trim() || isLoading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
text: inputMessage,
|
||||
isUser: true,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputMessage('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: inputMessage }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send message');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: data.message,
|
||||
isUser: false,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: 'I apologize, but I\'m having trouble connecting right now. Please try again later or email support@blackcanyontickets.com for assistance.',
|
||||
isUser: false,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
{/* Chat Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`mb-2 p-3 rounded-full shadow-lg transition-all duration-200 ${
|
||||
isOpen
|
||||
? 'bg-red-500 hover:bg-red-600'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
} text-white`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Chat Window */}
|
||||
{isOpen && (
|
||||
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 w-80 h-96 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-blue-600 text-white p-4 rounded-t-lg">
|
||||
<h3 className="font-semibold">Black Canyon Tickets Support</h3>
|
||||
<p className="text-sm text-blue-100">We're here to help!</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.isUser ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-xs p-3 rounded-lg ${
|
||||
message.isUser
|
||||
? 'bg-blue-600 text-white rounded-br-none'
|
||||
: 'bg-gray-100 text-gray-800 rounded-bl-none'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm">{message.text}</p>
|
||||
<p className={`text-xs mt-1 ${
|
||||
message.isUser ? 'text-blue-100' : 'text-gray-500'
|
||||
}`}>
|
||||
{formatTime(message.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 text-gray-800 rounded-lg rounded-bl-none p-3">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isLoading || !inputMessage.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWidget;
|
||||
403
src/components/CookieConsent.astro
Normal file
403
src/components/CookieConsent.astro
Normal file
@@ -0,0 +1,403 @@
|
||||
---
|
||||
// Cookie consent banner component
|
||||
export interface Props {
|
||||
position?: 'bottom' | 'top';
|
||||
}
|
||||
|
||||
const { position = 'bottom' } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
id="cookie-consent-banner"
|
||||
class={`fixed ${position === 'bottom' ? 'bottom-0' : 'top-0'} left-0 right-0 z-50 bg-gray-900 text-white shadow-lg transform translate-y-full transition-transform duration-300 ease-in-out`}
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<!-- Cookie notice content -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-1">Cookie Preferences</h3>
|
||||
<p class="text-sm text-gray-300 leading-relaxed">
|
||||
We use essential cookies to make our website work and analytics cookies to understand how you interact with our site.
|
||||
<a href="/privacy" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
|
||||
Learn more in our Privacy Policy
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-2 min-w-fit">
|
||||
<button
|
||||
id="cookie-settings-btn"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-600 hover:border-gray-500 rounded-lg transition-colors"
|
||||
>
|
||||
Manage Preferences
|
||||
</button>
|
||||
<button
|
||||
id="cookie-accept-btn"
|
||||
class="px-4 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie preferences modal -->
|
||||
<div
|
||||
id="cookie-preferences-modal"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">Cookie Preferences</h2>
|
||||
<button
|
||||
id="cookie-modal-close"
|
||||
class="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-500" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal content -->
|
||||
<div class="p-6 space-y-6">
|
||||
<p class="text-gray-600">
|
||||
We use cookies to enhance your experience on our website. You can choose which types of cookies to allow below.
|
||||
</p>
|
||||
|
||||
<!-- Essential cookies -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold text-gray-900">Essential Cookies</h3>
|
||||
<div class="bg-gray-100 text-gray-500 text-xs px-2 py-1 rounded">
|
||||
Always Active
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
These cookies are necessary for the website to function and cannot be disabled. They include authentication, security, and basic functionality.
|
||||
</p>
|
||||
<details class="text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">View details</summary>
|
||||
<div class="mt-2 pl-4 border-l-2 border-gray-200">
|
||||
<ul class="space-y-1">
|
||||
<li>• Authentication tokens (Supabase)</li>
|
||||
<li>• CSRF protection tokens</li>
|
||||
<li>• Session management</li>
|
||||
<li>• Security preferences</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Analytics cookies -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold text-gray-900">Analytics Cookies</h3>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="analytics-toggle" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Help us understand how visitors interact with our website by collecting and reporting information anonymously.
|
||||
</p>
|
||||
<details class="text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">View details</summary>
|
||||
<div class="mt-2 pl-4 border-l-2 border-gray-200">
|
||||
<ul class="space-y-1">
|
||||
<li>• Page views and user interactions</li>
|
||||
<li>• Performance metrics</li>
|
||||
<li>• Error tracking (anonymized)</li>
|
||||
<li>• Usage patterns (no personal data)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Marketing cookies -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold text-gray-900">Marketing Cookies</h3>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="marketing-toggle" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
These cookies track your activity to deliver more relevant advertisements and marketing communications.
|
||||
</p>
|
||||
<details class="text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700">View details</summary>
|
||||
<div class="mt-2 pl-4 border-l-2 border-gray-200">
|
||||
<ul class="space-y-1">
|
||||
<li>• Advertising preferences</li>
|
||||
<li>• Email campaign effectiveness</li>
|
||||
<li>• Social media integration</li>
|
||||
<li>• Retargeting pixels</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal footer -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 p-6 border-t border-gray-200">
|
||||
<button
|
||||
id="cookie-reject-all"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
Reject All
|
||||
</button>
|
||||
<button
|
||||
id="cookie-save-preferences"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
Save Preferences
|
||||
</button>
|
||||
<button
|
||||
id="cookie-accept-all"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Cookie consent management
|
||||
class CookieConsent {
|
||||
private consentKey = 'bct_cookie_consent';
|
||||
private banner: HTMLElement;
|
||||
private modal: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
this.banner = document.getElementById('cookie-consent-banner')!;
|
||||
this.modal = document.getElementById('cookie-preferences-modal')!;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Check if consent has already been given
|
||||
const consent = this.getConsent();
|
||||
if (!consent) {
|
||||
this.showBanner();
|
||||
} else {
|
||||
this.applyConsent(consent);
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
// Banner buttons
|
||||
document.getElementById('cookie-accept-btn')?.addEventListener('click', () => {
|
||||
this.acceptAll();
|
||||
});
|
||||
|
||||
document.getElementById('cookie-settings-btn')?.addEventListener('click', () => {
|
||||
this.showModal();
|
||||
});
|
||||
|
||||
// Modal buttons
|
||||
document.getElementById('cookie-modal-close')?.addEventListener('click', () => {
|
||||
this.hideModal();
|
||||
});
|
||||
|
||||
document.getElementById('cookie-accept-all')?.addEventListener('click', () => {
|
||||
this.acceptAll();
|
||||
});
|
||||
|
||||
document.getElementById('cookie-reject-all')?.addEventListener('click', () => {
|
||||
this.rejectAll();
|
||||
});
|
||||
|
||||
document.getElementById('cookie-save-preferences')?.addEventListener('click', () => {
|
||||
this.savePreferences();
|
||||
});
|
||||
|
||||
// Modal backdrop click
|
||||
this.modal.addEventListener('click', (e) => {
|
||||
if (e.target === this.modal) {
|
||||
this.hideModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showBanner() {
|
||||
this.banner.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
this.banner.classList.remove('translate-y-full');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private hideBanner() {
|
||||
this.banner.classList.add('translate-y-full');
|
||||
setTimeout(() => {
|
||||
this.banner.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private showModal() {
|
||||
// Load current preferences
|
||||
const consent = this.getConsent();
|
||||
if (consent) {
|
||||
(document.getElementById('analytics-toggle') as HTMLInputElement).checked = consent.analytics;
|
||||
(document.getElementById('marketing-toggle') as HTMLInputElement).checked = consent.marketing;
|
||||
}
|
||||
|
||||
this.modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
private hideModal() {
|
||||
this.modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
private acceptAll() {
|
||||
const consent = {
|
||||
essential: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.saveConsent(consent);
|
||||
this.applyConsent(consent);
|
||||
this.hideBanner();
|
||||
this.hideModal();
|
||||
}
|
||||
|
||||
private rejectAll() {
|
||||
const consent = {
|
||||
essential: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.saveConsent(consent);
|
||||
this.applyConsent(consent);
|
||||
this.hideBanner();
|
||||
this.hideModal();
|
||||
}
|
||||
|
||||
private savePreferences() {
|
||||
const analyticsToggle = document.getElementById('analytics-toggle') as HTMLInputElement;
|
||||
const marketingToggle = document.getElementById('marketing-toggle') as HTMLInputElement;
|
||||
|
||||
const consent = {
|
||||
essential: true,
|
||||
analytics: analyticsToggle.checked,
|
||||
marketing: marketingToggle.checked,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.saveConsent(consent);
|
||||
this.applyConsent(consent);
|
||||
this.hideBanner();
|
||||
this.hideModal();
|
||||
}
|
||||
|
||||
private saveConsent(consent: any) {
|
||||
localStorage.setItem(this.consentKey, JSON.stringify(consent));
|
||||
|
||||
// Also save to cookie for server-side access
|
||||
document.cookie = `${this.consentKey}=${JSON.stringify(consent)}; max-age=31536000; path=/; SameSite=Strict; Secure`;
|
||||
}
|
||||
|
||||
private getConsent() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.consentKey);
|
||||
if (stored) {
|
||||
const consent = JSON.parse(stored);
|
||||
// Check if consent is older than 12 months
|
||||
if (Date.now() - consent.timestamp > 365 * 24 * 60 * 60 * 1000) {
|
||||
return null;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error reading cookie consent:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private applyConsent(consent: any) {
|
||||
// Apply analytics consent
|
||||
if (consent.analytics) {
|
||||
this.enableAnalytics();
|
||||
} else {
|
||||
this.disableAnalytics();
|
||||
}
|
||||
|
||||
// Apply marketing consent
|
||||
if (consent.marketing) {
|
||||
this.enableMarketing();
|
||||
} else {
|
||||
this.disableMarketing();
|
||||
}
|
||||
|
||||
// Dispatch custom event for other scripts
|
||||
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', {
|
||||
detail: consent
|
||||
}));
|
||||
}
|
||||
|
||||
private enableAnalytics() {
|
||||
// Enable analytics tracking
|
||||
console.log('Analytics enabled');
|
||||
// TODO: Initialize analytics services (Google Analytics, etc.)
|
||||
}
|
||||
|
||||
private disableAnalytics() {
|
||||
// Disable analytics tracking
|
||||
console.log('Analytics disabled');
|
||||
// TODO: Disable analytics services
|
||||
}
|
||||
|
||||
private enableMarketing() {
|
||||
// Enable marketing cookies
|
||||
console.log('Marketing enabled');
|
||||
// TODO: Enable marketing pixels, retargeting, etc.
|
||||
}
|
||||
|
||||
private disableMarketing() {
|
||||
// Disable marketing cookies
|
||||
console.log('Marketing disabled');
|
||||
// TODO: Disable marketing pixels, retargeting, etc.
|
||||
}
|
||||
|
||||
// Public method to show preferences modal
|
||||
public showPreferences() {
|
||||
this.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize cookie consent when DOM is loaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new CookieConsent();
|
||||
});
|
||||
} else {
|
||||
new CookieConsent();
|
||||
}
|
||||
|
||||
// Export for global access
|
||||
(window as any).cookieConsent = CookieConsent;
|
||||
</script>
|
||||
38
src/components/Footer.astro
Normal file
38
src/components/Footer.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
// Footer component for whitelabel ticketing platform
|
||||
---
|
||||
|
||||
<footer class="bg-white border-t border-slate-200/50 mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<div class="flex items-center space-x-6">
|
||||
<a
|
||||
href="/terms"
|
||||
class="text-slate-600 hover:text-slate-900 text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a
|
||||
href="/privacy"
|
||||
class="text-slate-600 hover:text-slate-900 text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="/support"
|
||||
class="text-slate-600 hover:text-slate-900 text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
Support
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center space-y-2 sm:space-y-0 sm:space-x-8 text-center">
|
||||
<span class="text-slate-600 text-sm">
|
||||
© {new Date().getFullYear()} All rights reserved
|
||||
</span>
|
||||
<span class="text-slate-500 text-xs">
|
||||
Powered by <a href="https://blackcanyontickets.com" class="text-slate-600 hover:text-slate-900 transition-colors">blackcanyontickets.com</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
104
src/components/Navigation.astro
Normal file
104
src/components/Navigation.astro
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
export interface Props {
|
||||
title?: string;
|
||||
showBackLink?: boolean;
|
||||
backLinkUrl?: string;
|
||||
backLinkText?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Dashboard",
|
||||
showBackLink = false,
|
||||
backLinkUrl = "/dashboard",
|
||||
backLinkText = "← Back"
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!-- Unified Navigation -->
|
||||
<nav class="sticky top-0 z-50 bg-white/90 backdrop-blur-lg shadow-xl border-b border-slate-200/50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/dashboard" class="flex items-center">
|
||||
<span class="text-xl font-light text-gray-900">
|
||||
<span class="font-bold">P</span>ortal
|
||||
</span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center space-x-6">
|
||||
{showBackLink && (
|
||||
<div class="flex items-center space-x-3">
|
||||
<a
|
||||
href={backLinkUrl}
|
||||
class="text-slate-600 hover:text-slate-900 font-medium transition-colors duration-200"
|
||||
>
|
||||
{backLinkText}
|
||||
</a>
|
||||
<span class="text-slate-400">|</span>
|
||||
</div>
|
||||
)}
|
||||
<span class="text-slate-900 font-semibold">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a
|
||||
id="admin-dashboard-link"
|
||||
href="/admin/dashboard"
|
||||
class="hidden bg-slate-800 hover:bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
Admin Dashboard
|
||||
</a>
|
||||
<span id="user-name" class="text-sm text-slate-700 font-medium"></span>
|
||||
<button
|
||||
id="logout-btn"
|
||||
class="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Initialize navigation functionality
|
||||
const userNameSpan = document.getElementById('user-name');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const adminDashboardLink = document.getElementById('admin-dashboard-link');
|
||||
|
||||
// Check authentication and load user info
|
||||
async function initializeNavigation() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user info
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
userNameSpan.textContent = user.user_metadata.name || user.email;
|
||||
|
||||
// Check if user is admin and show admin dashboard link
|
||||
const { data: userProfile } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userProfile?.role === 'admin') {
|
||||
adminDashboardLink.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logout functionality
|
||||
logoutBtn?.addEventListener('click', async () => {
|
||||
await supabase.auth.signOut();
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
// Initialize when the page loads
|
||||
initializeNavigation();
|
||||
</script>
|
||||
86
src/components/ProtectedRoute.astro
Normal file
86
src/components/ProtectedRoute.astro
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
// Server-side auth check for protected routes
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// This is a basic server-side auth check
|
||||
// In production, you'd want more sophisticated session management
|
||||
const cookies = Astro.request.headers.get('cookie');
|
||||
let isAuthenticated = false;
|
||||
let userSession = null;
|
||||
|
||||
if (cookies) {
|
||||
// Try to extract auth token from cookies
|
||||
// This is a simplified check - in production you'd validate the token
|
||||
const authCookie = cookies.split(';')
|
||||
.find(c => c.trim().startsWith('sb-access-token=') || c.trim().startsWith('supabase-auth-token='));
|
||||
|
||||
if (authCookie) {
|
||||
isAuthenticated = true;
|
||||
// You would verify the token here in production
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated && Astro.url.pathname !== '/') {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
title?: string;
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
const { title = "Protected Page", requireAdmin = false } = Astro.props;
|
||||
---
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Client-side auth verification as backup
|
||||
async function verifyAuth() {
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error || !session) {
|
||||
console.warn('Authentication verification failed');
|
||||
window.location.pathname = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// Store auth token for API calls
|
||||
const authToken = session.access_token;
|
||||
if (authToken) {
|
||||
// Set default authorization header for fetch requests
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options = {}) {
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
|
||||
// Add auth header to API calls
|
||||
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||
options.headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Verify authentication on page load
|
||||
verifyAuth();
|
||||
|
||||
// Listen for auth state changes
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_OUT' || !session) {
|
||||
window.location.pathname = '/';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Add loading state styles */
|
||||
.auth-loading {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
108
src/components/PublicHeader.astro
Normal file
108
src/components/PublicHeader.astro
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
// Clean public header matching the minimalist design
|
||||
export interface Props {
|
||||
showCalendarNav?: boolean;
|
||||
}
|
||||
|
||||
const { showCalendarNav = false } = Astro.props;
|
||||
---
|
||||
|
||||
<header class="absolute top-0 left-0 right-0 z-10 bg-transparent">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<!-- Logo and Branding -->
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/" class="flex items-center">
|
||||
<span class="text-xl font-light text-white">
|
||||
<span class="font-bold">Black Canyon</span> Tickets
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Clean Navigation -->
|
||||
{showCalendarNav && (
|
||||
<nav class="hidden md:flex items-center space-x-1">
|
||||
<div class="flex items-center space-x-1 bg-slate-50 rounded-xl p-1">
|
||||
<a href="/calendar" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
All Events
|
||||
</a>
|
||||
<a href="/calendar?featured=true" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Featured
|
||||
</a>
|
||||
<a href="/calendar?category=music" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Music
|
||||
</a>
|
||||
<a href="/calendar?category=arts" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Arts
|
||||
</a>
|
||||
<a href="/calendar?category=community" class="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-white rounded-lg font-medium transition-all duration-200 hover:shadow-sm">
|
||||
Community
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Right side actions -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Mobile menu button -->
|
||||
{showCalendarNav && (
|
||||
<button
|
||||
class="md:hidden p-2 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
onclick="toggleMobileMenu()"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<!-- Clean Action buttons -->
|
||||
<a href="/" class="text-white/80 hover:text-white text-sm font-medium transition-colors duration-200">
|
||||
Login
|
||||
</a>
|
||||
<a href="https://blackcanyontickets.com/get-started" class="bg-white/10 backdrop-blur-lg hover:bg-white/20 text-white px-6 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 border border-white/20">
|
||||
Create Events
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clean Mobile Navigation -->
|
||||
{showCalendarNav && (
|
||||
<div id="mobile-menu" class="hidden md:hidden border-t border-slate-200 py-4">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<a href="/calendar" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
All Events
|
||||
</a>
|
||||
<a href="/calendar?featured=true" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
Featured Events
|
||||
</a>
|
||||
<a href="/calendar?category=music" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
Music
|
||||
</a>
|
||||
<a href="/calendar?category=arts" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
Arts
|
||||
</a>
|
||||
<a href="/calendar?category=community" class="px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
Community
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Login -->
|
||||
<div class="mt-4 pt-4 border-t border-slate-200">
|
||||
<a href="/" class="block text-center px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium transition-all duration-200">
|
||||
Organizer Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
function toggleMobileMenu() {
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
if (menu) {
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
25
src/components/SimpleHeader.astro
Normal file
25
src/components/SimpleHeader.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
// Simple header for legal pages
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="flex items-center">
|
||||
<span class="text-xl font-light text-gray-900">
|
||||
<span class="font-bold">Black Canyon</span> Tickets
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">
|
||||
Login
|
||||
</a>
|
||||
<a href="/support" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
649
src/components/TicketCheckout.tsx
Normal file
649
src/components/TicketCheckout.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { inventoryManager } from '../lib/inventory';
|
||||
import { calculateFeeBreakdown } from '../lib/stripe';
|
||||
import {
|
||||
formatAvailabilityDisplay,
|
||||
shouldShowTicketType,
|
||||
defaultAvailabilitySettings,
|
||||
type EventAvailabilitySettings,
|
||||
type AvailabilityInfo
|
||||
} from '../lib/availabilityDisplay';
|
||||
|
||||
interface TicketType {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
quantity_available?: number;
|
||||
is_active: boolean;
|
||||
requires_presale_code?: boolean;
|
||||
presale_start_time?: string;
|
||||
presale_end_time?: string;
|
||||
general_sale_start_time?: string;
|
||||
}
|
||||
|
||||
interface EventData {
|
||||
id: string;
|
||||
title: string;
|
||||
ticket_types: TicketType[];
|
||||
availability_display_mode?: 'available_only' | 'show_quantity' | 'smart_threshold';
|
||||
availability_threshold?: number;
|
||||
show_sold_out?: boolean;
|
||||
low_stock_threshold?: number;
|
||||
availability_messages?: {
|
||||
available: string;
|
||||
low_stock: string;
|
||||
sold_out: string;
|
||||
unlimited: string;
|
||||
};
|
||||
organizations: {
|
||||
platform_fee_type?: string;
|
||||
platform_fee_percentage?: number;
|
||||
platform_fee_fixed?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
event: EventData;
|
||||
}
|
||||
|
||||
export default function TicketCheckout({ event }: Props) {
|
||||
const [selectedTickets, setSelectedTickets] = useState<Map<string, any>>(new Map());
|
||||
const [currentReservations, setCurrentReservations] = useState<Map<string, any>>(new Map());
|
||||
const [availability, setAvailability] = useState<Map<string, AvailabilityInfo>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [presaleCode, setPresaleCode] = useState('');
|
||||
const [presaleCodeValidated, setPresaleCodeValidated] = useState(false);
|
||||
const [presaleCodeData, setPresaleCodeData] = useState<any>(null);
|
||||
const [presaleCodeError, setPresaleCodeError] = useState('');
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set());
|
||||
|
||||
// Check if presale is currently active
|
||||
const hasActivePresale = event.ticket_types?.some(ticketType => {
|
||||
if (!ticketType.requires_presale_code) return false;
|
||||
|
||||
const now = new Date();
|
||||
const presaleStart = ticketType.presale_start_time ? new Date(ticketType.presale_start_time) : null;
|
||||
const presaleEnd = ticketType.presale_end_time ? new Date(ticketType.presale_end_time) : null;
|
||||
const generalSaleStart = ticketType.general_sale_start_time ? new Date(ticketType.general_sale_start_time) : null;
|
||||
|
||||
// If general sale hasn't started yet, check if we're in presale period
|
||||
if (generalSaleStart && now < generalSaleStart) {
|
||||
// If presale has specific timing, check if we're in the window
|
||||
if (presaleStart && presaleEnd) {
|
||||
return now >= presaleStart && now <= presaleEnd;
|
||||
} else if (presaleStart) {
|
||||
return now >= presaleStart;
|
||||
}
|
||||
return true; // Presale required but no specific timing - assume active
|
||||
}
|
||||
|
||||
// If general sale has started, presale is no longer active
|
||||
return false;
|
||||
}) || false;
|
||||
|
||||
const feeStructure = event?.organizations ? {
|
||||
fee_type: event.organizations.platform_fee_type,
|
||||
fee_percentage: event.organizations.platform_fee_percentage,
|
||||
fee_fixed: event.organizations.platform_fee_fixed
|
||||
} : null;
|
||||
|
||||
// Get availability settings with defaults
|
||||
const availabilitySettings: EventAvailabilitySettings = {
|
||||
availability_display_mode: event.availability_display_mode || defaultAvailabilitySettings.availability_display_mode,
|
||||
availability_threshold: event.availability_threshold || defaultAvailabilitySettings.availability_threshold,
|
||||
show_sold_out: event.show_sold_out ?? defaultAvailabilitySettings.show_sold_out,
|
||||
low_stock_threshold: event.low_stock_threshold || defaultAvailabilitySettings.low_stock_threshold,
|
||||
availability_messages: event.availability_messages || defaultAvailabilitySettings.availability_messages
|
||||
};
|
||||
|
||||
// Load availability for all ticket types
|
||||
useEffect(() => {
|
||||
async function loadAvailability() {
|
||||
const availabilityMap = new Map();
|
||||
|
||||
for (const ticketType of event.ticket_types?.filter(tt => tt.is_active) || []) {
|
||||
try {
|
||||
const avail = await inventoryManager.getAvailability(ticketType.id);
|
||||
availabilityMap.set(ticketType.id, avail);
|
||||
} catch (error) {
|
||||
console.error('Error loading availability for', ticketType.id, error);
|
||||
availabilityMap.set(ticketType.id, { is_available: false, error: true });
|
||||
}
|
||||
}
|
||||
|
||||
setAvailability(availabilityMap);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
loadAvailability();
|
||||
}, [event.ticket_types]);
|
||||
|
||||
// Timer effect
|
||||
useEffect(() => {
|
||||
if (currentReservations.size === 0) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const firstReservation = Array.from(currentReservations.values())[0];
|
||||
if (firstReservation) {
|
||||
const now = new Date().getTime();
|
||||
const expiry = new Date(firstReservation.expires_at).getTime();
|
||||
const timeLeft = expiry - now;
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
alert('Your ticket reservation has expired. Please select your tickets again.');
|
||||
window.location.reload();
|
||||
} else {
|
||||
const minutes = Math.floor(timeLeft / 60000);
|
||||
const seconds = Math.floor((timeLeft % 60000) / 1000);
|
||||
setTimeRemaining(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [currentReservations]);
|
||||
|
||||
const handleQuantityChange = async (ticketTypeId: string, newQuantity: number) => {
|
||||
const currentQuantity = selectedTickets.get(ticketTypeId)?.quantity || 0;
|
||||
|
||||
if (newQuantity === currentQuantity) return;
|
||||
|
||||
console.log('Quantity change:', { ticketTypeId, currentQuantity, newQuantity });
|
||||
|
||||
try {
|
||||
// Release existing reservation if any
|
||||
if (currentReservations.has(ticketTypeId)) {
|
||||
console.log('Releasing existing reservation...');
|
||||
await inventoryManager.releaseReservation(currentReservations.get(ticketTypeId).id);
|
||||
const newReservations = new Map(currentReservations);
|
||||
newReservations.delete(ticketTypeId);
|
||||
setCurrentReservations(newReservations);
|
||||
}
|
||||
|
||||
if (newQuantity > 0) {
|
||||
console.log('Reserving tickets:', { ticketTypeId, quantity: newQuantity });
|
||||
// Reserve new tickets
|
||||
const reservation = await inventoryManager.reserveTickets(ticketTypeId, newQuantity, 15);
|
||||
console.log('Reservation successful:', reservation);
|
||||
|
||||
const newReservations = new Map(currentReservations);
|
||||
newReservations.set(ticketTypeId, reservation);
|
||||
setCurrentReservations(newReservations);
|
||||
|
||||
// Update selected tickets
|
||||
const ticketType = event.ticket_types?.find(tt => tt.id === ticketTypeId);
|
||||
const newSelected = new Map(selectedTickets);
|
||||
newSelected.set(ticketTypeId, {
|
||||
quantity: newQuantity,
|
||||
price: typeof ticketType?.price === 'string' ? Math.round(parseFloat(ticketType.price) * 100) : ticketType?.price,
|
||||
name: ticketType?.name,
|
||||
reservation_id: reservation.id
|
||||
});
|
||||
setSelectedTickets(newSelected);
|
||||
} else {
|
||||
// Remove from selected tickets
|
||||
const newSelected = new Map(selectedTickets);
|
||||
newSelected.delete(ticketTypeId);
|
||||
setSelectedTickets(newSelected);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
console.error('Error details:', error);
|
||||
alert(error.message || 'Error reserving tickets. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotals = () => {
|
||||
let subtotal = 0;
|
||||
let totalQuantity = 0;
|
||||
|
||||
for (const ticket of selectedTickets.values()) {
|
||||
subtotal += ticket.quantity * ticket.price;
|
||||
totalQuantity += ticket.quantity;
|
||||
}
|
||||
|
||||
if (totalQuantity === 0) {
|
||||
return { subtotal: 0, platformFee: 0, total: 0 };
|
||||
}
|
||||
|
||||
const avgPrice = subtotal / totalQuantity;
|
||||
const breakdown = calculateFeeBreakdown(avgPrice / 100, totalQuantity, feeStructure);
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
platformFee: breakdown.totalPlatformFee,
|
||||
total: subtotal + breakdown.totalPlatformFee
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedTickets.size === 0) return;
|
||||
|
||||
try {
|
||||
// Create purchase attempt
|
||||
const items = Array.from(selectedTickets.entries()).map(([ticketTypeId, ticket]) => ({
|
||||
ticket_type_id: ticketTypeId,
|
||||
quantity: ticket.quantity,
|
||||
unit_price: ticket.price / 100
|
||||
}));
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
const purchaseAttempt = await inventoryManager.createPurchaseAttempt(
|
||||
event.id,
|
||||
email,
|
||||
name,
|
||||
items,
|
||||
totals.platformFee / 100
|
||||
);
|
||||
|
||||
alert('Checkout integration coming soon! Your tickets are reserved.');
|
||||
console.log('Purchase attempt created:', purchaseAttempt);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating purchase:', error);
|
||||
alert(error.message || 'Error processing purchase. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const validatePresaleCode = async () => {
|
||||
if (!presaleCode.trim()) {
|
||||
setPresaleCodeError('Please enter a presale code');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/presale/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: presaleCode.trim(),
|
||||
event_id: event.id,
|
||||
customer_email: email || null,
|
||||
customer_session: sessionStorage.getItem('checkout_session') || null
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setPresaleCodeValidated(true);
|
||||
setPresaleCodeData(data);
|
||||
setPresaleCodeError('');
|
||||
// Store session for future validation
|
||||
if (!sessionStorage.getItem('checkout_session')) {
|
||||
sessionStorage.setItem('checkout_session', Math.random().toString(36));
|
||||
}
|
||||
} else {
|
||||
setPresaleCodeError(data.error || 'Invalid presale code');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating presale code:', error);
|
||||
setPresaleCodeError('Error validating code. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDescription = (ticketTypeId: string) => {
|
||||
const newExpanded = new Set(expandedDescriptions);
|
||||
if (newExpanded.has(ticketTypeId)) {
|
||||
newExpanded.delete(ticketTypeId);
|
||||
} else {
|
||||
newExpanded.add(ticketTypeId);
|
||||
}
|
||||
setExpandedDescriptions(newExpanded);
|
||||
};
|
||||
|
||||
const truncateDescription = (description: string, maxLength: number = 100) => {
|
||||
if (description.length <= maxLength) return description;
|
||||
return description.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Loading ticket availability...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Note: Header moved to parent component */}
|
||||
|
||||
{/* Presale Code Entry - Only show if presale is active */}
|
||||
{hasActivePresale && !presaleCodeValidated && (
|
||||
<div className="mb-6 p-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-2xl">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="presale-code" className="block text-sm font-semibold text-blue-900 mb-2">
|
||||
Presale Code Required
|
||||
</label>
|
||||
<input
|
||||
id="presale-code"
|
||||
type="text"
|
||||
value={presaleCode}
|
||||
onChange={(e) => {
|
||||
setPresaleCode(e.target.value.toUpperCase());
|
||||
setPresaleCodeError('');
|
||||
}}
|
||||
placeholder="Enter your presale code"
|
||||
className="w-full px-4 py-3 border-2 border-blue-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 text-slate-900 placeholder-blue-400 bg-white hover:border-blue-400"
|
||||
/>
|
||||
{presaleCodeError && (
|
||||
<p className="text-red-600 text-sm mt-2 font-medium">{presaleCodeError}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={validatePresaleCode}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl hover:from-blue-700 hover:to-indigo-700 font-semibold text-sm whitespace-nowrap transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Apply Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presale Code Success - Compact version */}
|
||||
{presaleCodeValidated && presaleCodeData && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-900">
|
||||
Presale access granted
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPresaleCodeValidated(false);
|
||||
setPresaleCodeData(null);
|
||||
setPresaleCode('');
|
||||
}}
|
||||
className="text-green-600 hover:text-green-800 text-sm font-medium"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Type Selection */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{event.ticket_types
|
||||
?.filter(tt => tt.is_active)
|
||||
?.filter(ticketType => {
|
||||
const avail = availability.get(ticketType.id);
|
||||
return avail ? shouldShowTicketType(avail, availabilitySettings) : true;
|
||||
})
|
||||
?.filter(ticketType => {
|
||||
// If ticket type requires presale code, check if user has validated one
|
||||
// and if the presale code gives access to this ticket type
|
||||
if (ticketType.requires_presale_code) {
|
||||
if (!presaleCodeValidated || !presaleCodeData) {
|
||||
return false;
|
||||
}
|
||||
// Check if presale code gives access to this ticket type
|
||||
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
||||
(accessibleType: any) => accessibleType.id === ticketType.id
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
?.map(ticketType => {
|
||||
const avail = availability.get(ticketType.id);
|
||||
const selectedQuantity = selectedTickets.get(ticketType.id)?.quantity || 0;
|
||||
const price = typeof ticketType.price === 'string' ? parseFloat(ticketType.price) : (ticketType.price / 100);
|
||||
|
||||
// Get formatted availability display
|
||||
const availabilityDisplay = avail
|
||||
? formatAvailabilityDisplay(avail, availabilitySettings)
|
||||
: { text: 'Loading...', className: 'text-gray-500', showExactCount: false, isLowStock: false, isSoldOut: false };
|
||||
|
||||
return (
|
||||
<div key={ticketType.id} className={`border-2 rounded-2xl p-6 transition-all duration-200 ${
|
||||
availabilityDisplay.isSoldOut
|
||||
? 'bg-slate-50 opacity-75 border-slate-200'
|
||||
: selectedQuantity > 0
|
||||
? 'bg-gradient-to-br from-emerald-50 to-green-50 border-emerald-300 shadow-lg'
|
||||
: 'bg-white border-slate-200 hover:border-slate-300 hover:shadow-md'
|
||||
}`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="text-xl font-semibold text-slate-900">{ticketType.name}</h3>
|
||||
{availabilityDisplay.isLowStock && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-orange-400 to-amber-400 text-white">
|
||||
Low Stock
|
||||
</span>
|
||||
)}
|
||||
{selectedQuantity > 0 && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-400 to-green-400 text-white">
|
||||
{selectedQuantity} Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ticketType.description && (
|
||||
<div className="mb-4 p-3 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<p className="text-sm text-slate-700 leading-relaxed whitespace-pre-line">
|
||||
{expandedDescriptions.has(ticketType.id)
|
||||
? ticketType.description
|
||||
: truncateDescription(ticketType.description)
|
||||
}
|
||||
</p>
|
||||
{ticketType.description.length > 100 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDescription(ticketType.id)}
|
||||
className="mt-2 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{expandedDescriptions.has(ticketType.id) ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-2xl font-bold text-slate-900">
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-sm ml-3 font-medium ${availabilityDisplay.className}`}>
|
||||
{availabilityDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuantityChange(ticketType.id, Math.max(0, selectedQuantity - 1))}
|
||||
disabled={selectedQuantity <= 0 || availabilityDisplay.isSoldOut}
|
||||
className={`w-10 h-10 rounded-xl border-2 font-bold text-lg transition-all duration-200 ${
|
||||
selectedQuantity <= 0 || availabilityDisplay.isSoldOut
|
||||
? 'border-slate-200 text-slate-300 cursor-not-allowed bg-slate-50'
|
||||
: 'border-slate-300 text-slate-600 hover:border-red-400 hover:text-red-600 hover:bg-red-50 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
|
||||
<div className="w-12 text-center">
|
||||
<span className="text-lg font-semibold text-slate-900">{selectedQuantity}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuantityChange(ticketType.id, selectedQuantity + 1)}
|
||||
disabled={selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut}
|
||||
className={`w-10 h-10 rounded-xl border-2 font-bold text-lg transition-all duration-200 ${
|
||||
selectedQuantity >= (avail?.available || 0) || availabilityDisplay.isSoldOut
|
||||
? 'border-slate-200 text-slate-300 cursor-not-allowed bg-slate-50'
|
||||
: 'border-slate-300 text-slate-600 hover:border-green-400 hover:text-green-600 hover:bg-green-50 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show message if no tickets available without presale code */}
|
||||
{event.ticket_types?.filter(tt => tt.is_active).length > 0 &&
|
||||
event.ticket_types?.filter(tt => tt.is_active)
|
||||
?.filter(ticketType => {
|
||||
const avail = availability.get(ticketType.id);
|
||||
return avail ? shouldShowTicketType(avail, availabilitySettings) : true;
|
||||
})
|
||||
?.filter(ticketType => {
|
||||
if (ticketType.requires_presale_code) {
|
||||
if (!presaleCodeValidated || !presaleCodeData) {
|
||||
return false;
|
||||
}
|
||||
const hasAccess = presaleCodeData.accessible_ticket_types?.some(
|
||||
(accessibleType: any) => accessibleType.id === ticketType.id
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).length === 0 && (
|
||||
<div className="text-center py-6 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="w-12 h-12 mx-auto text-yellow-400 mb-3">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-yellow-900 mb-2">Presale Access Required</h3>
|
||||
<p className="text-yellow-700 text-sm">
|
||||
This event is currently in presale. Enter your presale code above to access tickets.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reservation Timer */}
|
||||
{currentReservations.size > 0 && (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-200 rounded-2xl p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-6 w-6 text-amber-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-amber-800">
|
||||
Tickets reserved for {timeRemaining}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Summary */}
|
||||
{selectedTickets.size > 0 && (
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white border-2 border-slate-200 rounded-2xl p-6 shadow-lg">
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-4 flex items-center">
|
||||
<div className="w-3 h-3 bg-gradient-to-r from-emerald-500 to-green-500 rounded-full mr-3"></div>
|
||||
Order Summary
|
||||
</h3>
|
||||
<div className="space-y-3 mb-4">
|
||||
{Array.from(selectedTickets.entries()).map(([ticketTypeId, ticket]) => (
|
||||
<div key={ticketTypeId} className="flex justify-between items-center p-3 bg-white rounded-xl border border-slate-200">
|
||||
<span className="font-medium text-slate-900">{ticket.quantity}x {ticket.name}</span>
|
||||
<span className="font-semibold text-slate-900">${((ticket.quantity * ticket.price) / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-2 border-slate-200 pt-4">
|
||||
<div className="flex justify-between text-slate-600 mb-2">
|
||||
<span>Subtotal:</span>
|
||||
<span>${(totals.subtotal / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600 mb-3">
|
||||
<span>Platform fee:</span>
|
||||
<span>${(totals.platformFee / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold text-slate-900 pt-3 border-t border-slate-200">
|
||||
<span>Total:</span>
|
||||
<span>${(totals.total / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Information - Only show when tickets are selected */}
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="block w-full px-4 py-3 border-2 border-slate-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 text-slate-900 placeholder-slate-400 bg-white hover:border-slate-300"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="block w-full px-4 py-3 border-2 border-slate-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 text-slate-900 placeholder-slate-400 bg-white hover:border-slate-300"
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 px-6 rounded-2xl font-semibold text-lg transition-all duration-200 bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-700 hover:to-green-700 text-white shadow-xl hover:shadow-2xl transform hover:scale-[1.02]"
|
||||
>
|
||||
Complete Purchase
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call to Action - Show when no tickets selected */}
|
||||
{selectedTickets.size === 0 && (
|
||||
<div className="text-center py-8 px-6 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-dashed border-slate-300">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-slate-400 to-slate-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">Select Your Tickets</h3>
|
||||
<p className="text-slate-500">Choose your preferred seating and quantity above to continue</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Secure checkout powered by Stripe • Tickets reserved for 15 minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/layouts/Layout.astro
Normal file
48
src/layouts/Layout.astro
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
import Footer from '../components/Footer.astro';
|
||||
import CookieConsent from '../components/CookieConsent.astro';
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="Professional ticketing platform for events" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<!-- Skip Links for Accessibility -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
<a href="#navigation" class="skip-link">Skip to navigation</a>
|
||||
|
||||
<div class="flex-1">
|
||||
<main id="main-content" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
<CookieConsent />
|
||||
|
||||
<!-- Initialize accessibility features -->
|
||||
<script>
|
||||
import { initializeAccessibility, initializeHighContrastSupport, initializeReducedMotionSupport } from '../lib/accessibility';
|
||||
|
||||
// Initialize all accessibility features
|
||||
initializeAccessibility();
|
||||
initializeHighContrastSupport();
|
||||
initializeReducedMotionSupport();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import '../styles/global.css';
|
||||
</style>
|
||||
42
src/layouts/LoginLayout.astro
Normal file
42
src/layouts/LoginLayout.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
import CookieConsent from '../components/CookieConsent.astro';
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="Professional ticketing platform for events" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Skip Links for Accessibility -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
<slot />
|
||||
|
||||
<CookieConsent />
|
||||
|
||||
<!-- Initialize accessibility features -->
|
||||
<script>
|
||||
import { initializeAccessibility, initializeHighContrastSupport, initializeReducedMotionSupport } from '../lib/accessibility';
|
||||
|
||||
// Initialize all accessibility features
|
||||
initializeAccessibility();
|
||||
initializeHighContrastSupport();
|
||||
initializeReducedMotionSupport();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import '../styles/global.css';
|
||||
</style>
|
||||
82
src/layouts/SecureLayout.astro
Normal file
82
src/layouts/SecureLayout.astro
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
showBackLink?: boolean;
|
||||
backLinkUrl?: string;
|
||||
backLinkText?: string;
|
||||
showLogo?: boolean;
|
||||
}
|
||||
|
||||
const { title, showBackLink = false, backLinkUrl = "/dashboard", backLinkText = "← Back", showLogo = false } = Astro.props;
|
||||
|
||||
import Layout from './Layout.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
---
|
||||
|
||||
<Layout title={title}>
|
||||
<style>
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<!-- Animated background elements -->
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-purple-600/20 to-pink-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-blue-600/20 to-cyan-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-gradient-to-br from-indigo-600/10 to-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
{showLogo && (
|
||||
<div class="absolute top-8 left-8 z-10">
|
||||
<img src="/images/logo.png" alt="Black Canyon Tickets" class="h-12 w-auto opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Navigation
|
||||
title={title}
|
||||
showBackLink={showBackLink}
|
||||
backLinkUrl={backLinkUrl}
|
||||
backLinkText={backLinkText}
|
||||
/>
|
||||
|
||||
<main class="relative">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
280
src/lib/accessibility.ts
Normal file
280
src/lib/accessibility.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// Accessibility utilities and helpers
|
||||
|
||||
/**
|
||||
* Generate unique IDs for form elements and ARIA relationships
|
||||
*/
|
||||
export function generateUniqueId(prefix: string = 'element'): string {
|
||||
return `${prefix}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce messages to screen readers
|
||||
*/
|
||||
export function announceToScreenReader(message: string, priority: 'polite' | 'assertive' = 'polite') {
|
||||
const announcement = document.createElement('div');
|
||||
announcement.setAttribute('aria-live', priority);
|
||||
announcement.setAttribute('aria-atomic', 'true');
|
||||
announcement.className = 'sr-only';
|
||||
announcement.textContent = message;
|
||||
|
||||
document.body.appendChild(announcement);
|
||||
|
||||
// Remove after announcement
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(announcement);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage focus for modal dialogs
|
||||
*/
|
||||
export class FocusManager {
|
||||
private focusableElements: NodeListOf<HTMLElement> | null = null;
|
||||
private firstFocusableElement: HTMLElement | null = null;
|
||||
private lastFocusableElement: HTMLElement | null = null;
|
||||
private previouslyFocusedElement: HTMLElement | null = null;
|
||||
|
||||
/**
|
||||
* Initialize focus management for a container
|
||||
*/
|
||||
public init(container: HTMLElement) {
|
||||
this.previouslyFocusedElement = document.activeElement as HTMLElement;
|
||||
this.focusableElements = container.querySelectorAll(
|
||||
'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (this.focusableElements.length > 0) {
|
||||
this.firstFocusableElement = this.focusableElements[0];
|
||||
this.lastFocusableElement = this.focusableElements[this.focusableElements.length - 1];
|
||||
|
||||
// Focus first element
|
||||
this.firstFocusableElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation within the container
|
||||
*/
|
||||
public handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === this.firstFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.lastFocusableElement?.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === this.lastFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.firstFocusableElement?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore focus to previously focused element
|
||||
*/
|
||||
public restoreFocus() {
|
||||
if (this.previouslyFocusedElement) {
|
||||
this.previouslyFocusedElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip link functionality
|
||||
*/
|
||||
export function initializeSkipLinks() {
|
||||
const skipLinks = document.querySelectorAll('.skip-link');
|
||||
|
||||
skipLinks.forEach(link => {
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const target = document.querySelector((event.target as HTMLAnchorElement).getAttribute('href')!);
|
||||
if (target) {
|
||||
(target as HTMLElement).focus();
|
||||
target.scrollIntoView();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance form accessibility
|
||||
*/
|
||||
export function enhanceFormAccessibility() {
|
||||
const forms = document.querySelectorAll('form');
|
||||
|
||||
forms.forEach(form => {
|
||||
// Add ARIA labels to form controls without labels
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (!input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) {
|
||||
const label = form.querySelector(`label[for="${input.id}"]`);
|
||||
if (!label && input.getAttribute('placeholder')) {
|
||||
input.setAttribute('aria-label', input.getAttribute('placeholder')!);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add error message associations
|
||||
const errorMessages = form.querySelectorAll('[data-error-for]');
|
||||
errorMessages.forEach(error => {
|
||||
const inputId = error.getAttribute('data-error-for');
|
||||
const input = form.querySelector(`#${inputId}`);
|
||||
if (input) {
|
||||
const errorId = generateUniqueId('error');
|
||||
error.id = errorId;
|
||||
input.setAttribute('aria-describedby', errorId);
|
||||
input.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard navigation to custom components
|
||||
*/
|
||||
export function addKeyboardNavigation() {
|
||||
// Custom dropdown navigation
|
||||
const dropdowns = document.querySelectorAll('[role="combobox"]');
|
||||
dropdowns.forEach(dropdown => {
|
||||
dropdown.addEventListener('keydown', (event) => {
|
||||
const key = event.key;
|
||||
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
// Handle dropdown navigation
|
||||
} else if (key === 'Escape') {
|
||||
// Close dropdown
|
||||
dropdown.blur();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Tab navigation for card grids
|
||||
const cardGrids = document.querySelectorAll('[data-card-grid]');
|
||||
cardGrids.forEach(grid => {
|
||||
const cards = grid.querySelectorAll('[data-card]');
|
||||
cards.forEach((card, index) => {
|
||||
card.addEventListener('keydown', (event) => {
|
||||
const key = event.key;
|
||||
let nextIndex = index;
|
||||
|
||||
if (key === 'ArrowRight' || key === 'ArrowDown') {
|
||||
nextIndex = Math.min(index + 1, cards.length - 1);
|
||||
} else if (key === 'ArrowLeft' || key === 'ArrowUp') {
|
||||
nextIndex = Math.max(index - 1, 0);
|
||||
} else if (key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (key === 'End') {
|
||||
nextIndex = cards.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex !== index) {
|
||||
event.preventDefault();
|
||||
(cards[nextIndex] as HTMLElement).focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Improve color contrast for dynamic content
|
||||
*/
|
||||
export function validateColorContrast() {
|
||||
// This would typically integrate with a color contrast checking library
|
||||
console.log('Color contrast validation would run here');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all accessibility enhancements
|
||||
*/
|
||||
export function initializeAccessibility() {
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeSkipLinks();
|
||||
enhanceFormAccessibility();
|
||||
addKeyboardNavigation();
|
||||
validateColorContrast();
|
||||
});
|
||||
} else {
|
||||
initializeSkipLinks();
|
||||
enhanceFormAccessibility();
|
||||
addKeyboardNavigation();
|
||||
validateColorContrast();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen reader utility class
|
||||
*/
|
||||
export class ScreenReaderSupport {
|
||||
private static liveRegion: HTMLElement | null = null;
|
||||
|
||||
public static announce(message: string, priority: 'off' | 'polite' | 'assertive' = 'polite') {
|
||||
if (!this.liveRegion) {
|
||||
this.createLiveRegion();
|
||||
}
|
||||
|
||||
if (this.liveRegion) {
|
||||
this.liveRegion.setAttribute('aria-live', priority);
|
||||
this.liveRegion.textContent = message;
|
||||
|
||||
// Clear after announcement
|
||||
setTimeout(() => {
|
||||
if (this.liveRegion) {
|
||||
this.liveRegion.textContent = '';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private static createLiveRegion() {
|
||||
this.liveRegion = document.createElement('div');
|
||||
this.liveRegion.className = 'sr-only';
|
||||
this.liveRegion.setAttribute('aria-live', 'polite');
|
||||
this.liveRegion.setAttribute('aria-atomic', 'true');
|
||||
document.body.appendChild(this.liveRegion);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High contrast mode detection and support
|
||||
*/
|
||||
export function initializeHighContrastSupport() {
|
||||
// Detect if user prefers high contrast
|
||||
const prefersHighContrast = window.matchMedia('(prefers-contrast: high)');
|
||||
|
||||
function applyHighContrast(matches: boolean) {
|
||||
if (matches) {
|
||||
document.documentElement.classList.add('high-contrast');
|
||||
} else {
|
||||
document.documentElement.classList.remove('high-contrast');
|
||||
}
|
||||
}
|
||||
|
||||
applyHighContrast(prefersHighContrast.matches);
|
||||
prefersHighContrast.addEventListener('change', (e) => applyHighContrast(e.matches));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduced motion support
|
||||
*/
|
||||
export function initializeReducedMotionSupport() {
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
function applyReducedMotion(matches: boolean) {
|
||||
if (matches) {
|
||||
document.documentElement.classList.add('reduce-motion');
|
||||
} else {
|
||||
document.documentElement.classList.remove('reduce-motion');
|
||||
}
|
||||
}
|
||||
|
||||
applyReducedMotion(prefersReducedMotion.matches);
|
||||
prefersReducedMotion.addEventListener('change', (e) => applyReducedMotion(e.matches));
|
||||
}
|
||||
285
src/lib/addons.ts
Normal file
285
src/lib/addons.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
// Add-ons management utilities for Black Canyon Tickets
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface AddOnType {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pricing_type: 'per_event' | 'monthly' | 'annual' | 'per_ticket';
|
||||
price_cents: number;
|
||||
category: 'feature' | 'service' | 'analytics' | 'marketing' | 'subscription';
|
||||
is_active: boolean;
|
||||
requires_setup: boolean;
|
||||
feature_flags: Record<string, boolean>;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface EventAddOn {
|
||||
id: string;
|
||||
event_id: string;
|
||||
add_on_type_id: string;
|
||||
organization_id: string;
|
||||
purchase_price_cents: number;
|
||||
status: 'active' | 'cancelled' | 'expired';
|
||||
purchased_at: string;
|
||||
expires_at?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AddOnWithAccess extends AddOnType {
|
||||
has_access: boolean;
|
||||
purchased_at?: string;
|
||||
}
|
||||
|
||||
// Get all available add-ons for an organization/event
|
||||
export async function getAvailableAddOns(
|
||||
organizationId: string,
|
||||
eventId?: string
|
||||
): Promise<AddOnWithAccess[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.rpc('get_available_addons', {
|
||||
p_organization_id: organizationId,
|
||||
p_event_id: eventId || null
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data.map((item: any) => ({
|
||||
id: item.addon_id,
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
pricing_type: item.pricing_type,
|
||||
price_cents: item.price_cents,
|
||||
category: item.category,
|
||||
is_active: true,
|
||||
requires_setup: false,
|
||||
feature_flags: {},
|
||||
sort_order: 0,
|
||||
has_access: item.has_access,
|
||||
purchased_at: item.purchased_at
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching available add-ons:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has access to specific feature
|
||||
export async function hasFeatureAccess(
|
||||
organizationId: string,
|
||||
eventId: string | null,
|
||||
featureFlag: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.rpc('has_feature_access', {
|
||||
p_organization_id: organizationId,
|
||||
p_event_id: eventId,
|
||||
p_feature_flag: featureFlag
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data === true;
|
||||
} catch (error) {
|
||||
console.error('Error checking feature access:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Purchase an add-on for an event
|
||||
export async function purchaseEventAddOn(
|
||||
eventId: string,
|
||||
addOnTypeId: string,
|
||||
organizationId: string,
|
||||
priceCents: number,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<{ success: boolean; addOnId?: string; error?: string }> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('event_add_ons')
|
||||
.insert([{
|
||||
event_id: eventId,
|
||||
add_on_type_id: addOnTypeId,
|
||||
organization_id: organizationId,
|
||||
purchase_price_cents: priceCents,
|
||||
status: 'active',
|
||||
metadata: metadata || {}
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return { success: true, addOnId: data.id };
|
||||
} catch (error) {
|
||||
console.error('Error purchasing add-on:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get event add-ons for a specific event
|
||||
export async function getEventAddOns(eventId: string): Promise<EventAddOn[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('event_add_ons')
|
||||
.select(`
|
||||
*,
|
||||
add_on_types (
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
feature_flags
|
||||
)
|
||||
`)
|
||||
.eq('event_id', eventId)
|
||||
.eq('status', 'active');
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching event add-ons:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Format price for display
|
||||
export function formatAddOnPrice(priceCents: number, pricingType: string): string {
|
||||
const price = priceCents / 100;
|
||||
const formattedPrice = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(price);
|
||||
|
||||
switch (pricingType) {
|
||||
case 'per_event':
|
||||
return `${formattedPrice} per event`;
|
||||
case 'monthly':
|
||||
return `${formattedPrice}/month`;
|
||||
case 'annual':
|
||||
return `${formattedPrice}/year`;
|
||||
case 'per_ticket':
|
||||
return `${formattedPrice} per ticket`;
|
||||
default:
|
||||
return formattedPrice;
|
||||
}
|
||||
}
|
||||
|
||||
// Get add-on category icon
|
||||
export function getAddOnCategoryIcon(category: string): string {
|
||||
const icons = {
|
||||
feature: '⚡',
|
||||
service: '🎯',
|
||||
analytics: '📊',
|
||||
marketing: '📢',
|
||||
subscription: '⭐'
|
||||
};
|
||||
return icons[category as keyof typeof icons] || '🔧';
|
||||
}
|
||||
|
||||
// Get add-on category color
|
||||
export function getAddOnCategoryColor(category: string): string {
|
||||
const colors = {
|
||||
feature: 'blue',
|
||||
service: 'green',
|
||||
analytics: 'purple',
|
||||
marketing: 'orange',
|
||||
subscription: 'indigo'
|
||||
};
|
||||
return colors[category as keyof typeof colors] || 'gray';
|
||||
}
|
||||
|
||||
// Calculate total add-on revenue for organization
|
||||
export async function calculateAddOnRevenue(organizationId: string): Promise<{
|
||||
totalRevenue: number;
|
||||
eventAddOns: number;
|
||||
subscriptionRevenue: number;
|
||||
}> {
|
||||
try {
|
||||
// Event add-ons revenue
|
||||
const { data: eventAddOns, error: eventError } = await supabase
|
||||
.from('event_add_ons')
|
||||
.select('purchase_price_cents')
|
||||
.eq('organization_id', organizationId)
|
||||
.eq('status', 'active');
|
||||
|
||||
if (eventError) throw eventError;
|
||||
|
||||
const eventRevenue = (eventAddOns || [])
|
||||
.reduce((sum, addon) => sum + addon.purchase_price_cents, 0);
|
||||
|
||||
// Subscription revenue (simplified - would need proper subscription tracking)
|
||||
const { data: subscriptions, error: subError } = await supabase
|
||||
.from('organization_subscriptions')
|
||||
.select(`
|
||||
add_on_types (price_cents)
|
||||
`)
|
||||
.eq('organization_id', organizationId)
|
||||
.eq('status', 'active');
|
||||
|
||||
if (subError) throw subError;
|
||||
|
||||
const subscriptionRevenue = (subscriptions || [])
|
||||
.reduce((sum, sub: any) => sum + (sub.add_on_types?.price_cents || 0), 0);
|
||||
|
||||
return {
|
||||
totalRevenue: eventRevenue + subscriptionRevenue,
|
||||
eventAddOns: eventRevenue,
|
||||
subscriptionRevenue
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calculating add-on revenue:', error);
|
||||
return {
|
||||
totalRevenue: 0,
|
||||
eventAddOns: 0,
|
||||
subscriptionRevenue: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Common feature flags
|
||||
export const FEATURE_FLAGS = {
|
||||
SEATING_MAPS: 'seating_maps',
|
||||
AI_DESCRIPTION: 'ai_description',
|
||||
ADVANCED_ANALYTICS: 'advanced_analytics',
|
||||
EMAIL_MARKETING: 'email_marketing',
|
||||
PRIORITY_SUPPORT: 'priority_support',
|
||||
CUSTOM_BRANDING: 'custom_branding',
|
||||
SOCIAL_MEDIA_TOOLS: 'social_media_tools',
|
||||
ADVANCED_GUEST_MANAGEMENT: 'advanced_guest_management',
|
||||
TICKET_SCANNER: 'ticket_scanner',
|
||||
ALL_FEATURES: 'all_features'
|
||||
} as const;
|
||||
|
||||
// Popular add-on bundles for upselling
|
||||
export const POPULAR_BUNDLES = [
|
||||
{
|
||||
name: 'Starter Bundle',
|
||||
description: 'Perfect for your first premium event',
|
||||
addons: ['ai-event-description', 'ticket-scanner'],
|
||||
originalPrice: 1000, // $10
|
||||
bundlePrice: 800, // $8 (20% discount)
|
||||
savings: 200
|
||||
},
|
||||
{
|
||||
name: 'Professional Bundle',
|
||||
description: 'Everything you need for a successful event',
|
||||
addons: ['seating-maps', 'premium-analytics', 'ticket-scanner', 'guest-list-pro'],
|
||||
originalPrice: 4000, // $40
|
||||
bundlePrice: 3000, // $30 (25% discount)
|
||||
savings: 1000
|
||||
},
|
||||
{
|
||||
name: 'Complete Bundle',
|
||||
description: 'All automated features for maximum impact',
|
||||
addons: ['seating-maps', 'premium-analytics', 'ticket-scanner', 'guest-list-pro', 'ai-event-description', 'custom-event-branding'],
|
||||
originalPrice: 6000, // $60
|
||||
bundlePrice: 4500, // $45 (25% discount)
|
||||
savings: 1500
|
||||
}
|
||||
] as const;
|
||||
419
src/lib/analytics.ts
Normal file
419
src/lib/analytics.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { supabase } from './supabase';
|
||||
import type { Database } from './database.types';
|
||||
|
||||
// Types for analytics data
|
||||
export interface SalesMetrics {
|
||||
totalRevenue: number;
|
||||
netRevenue: number;
|
||||
platformFees: number;
|
||||
ticketsSold: number;
|
||||
averageTicketPrice: number;
|
||||
conversionRate: number;
|
||||
refundRate: number;
|
||||
}
|
||||
|
||||
export interface SalesByTimeframe {
|
||||
date: string;
|
||||
revenue: number;
|
||||
ticketsSold: number;
|
||||
averagePrice: number;
|
||||
}
|
||||
|
||||
export interface TicketTypePerformance {
|
||||
ticketTypeId: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantitySold: number;
|
||||
quantityAvailable: number;
|
||||
revenue: number;
|
||||
sellThroughRate: number;
|
||||
}
|
||||
|
||||
export interface RevenueBreakdown {
|
||||
grossRevenue: number;
|
||||
platformFees: number;
|
||||
netRevenue: number;
|
||||
stripeFees: number;
|
||||
organizerPayout: number;
|
||||
}
|
||||
|
||||
export interface SalesAnalyticsData {
|
||||
metrics: SalesMetrics;
|
||||
revenueBreakdown: RevenueBreakdown;
|
||||
salesByDay: SalesByTimeframe[];
|
||||
salesByHour: SalesByTimeframe[];
|
||||
ticketTypePerformance: TicketTypePerformance[];
|
||||
topSellingTickets: TicketTypePerformance[];
|
||||
recentSales: any[];
|
||||
}
|
||||
|
||||
// Analytics calculation functions
|
||||
export class EventAnalytics {
|
||||
private eventId: string;
|
||||
|
||||
constructor(eventId: string) {
|
||||
this.eventId = eventId;
|
||||
}
|
||||
|
||||
// Get comprehensive analytics data for an event
|
||||
async getAnalyticsData(): Promise<SalesAnalyticsData> {
|
||||
const [
|
||||
metrics,
|
||||
revenueBreakdown,
|
||||
salesByDay,
|
||||
salesByHour,
|
||||
ticketTypePerformance,
|
||||
recentSales
|
||||
] = await Promise.all([
|
||||
this.getSalesMetrics(),
|
||||
this.getRevenueBreakdown(),
|
||||
this.getSalesByTimeframe('day'),
|
||||
this.getSalesByTimeframe('hour'),
|
||||
this.getTicketTypePerformance(),
|
||||
this.getRecentSales()
|
||||
]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
revenueBreakdown,
|
||||
salesByDay,
|
||||
salesByHour,
|
||||
ticketTypePerformance,
|
||||
topSellingTickets: ticketTypePerformance.sort((a, b) => b.quantitySold - a.quantitySold).slice(0, 5),
|
||||
recentSales
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate key sales metrics
|
||||
async getSalesMetrics(): Promise<SalesMetrics> {
|
||||
try {
|
||||
// Get ticket sales data
|
||||
const { data: tickets, error: ticketsError } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
id,
|
||||
price,
|
||||
platform_fee_charged,
|
||||
created_at,
|
||||
ticket_types!inner(
|
||||
event_id
|
||||
)
|
||||
`)
|
||||
.eq('ticket_types.event_id', this.eventId);
|
||||
|
||||
if (ticketsError) throw ticketsError;
|
||||
|
||||
// Get ticket types for total capacity
|
||||
const { data: ticketTypes, error: typesError } = await supabase
|
||||
.from('ticket_types')
|
||||
.select('quantity_available')
|
||||
.eq('event_id', this.eventId);
|
||||
|
||||
if (typesError) throw typesError;
|
||||
|
||||
const ticketsSold = tickets?.length || 0;
|
||||
const totalCapacity = ticketTypes?.reduce((sum, type) => sum + (type.quantity_available || 0), 0) || 0;
|
||||
const totalRevenue = tickets?.reduce((sum, ticket) => sum + (ticket.price || 0), 0) || 0;
|
||||
const platformFees = tickets?.reduce((sum, ticket) => sum + (ticket.platform_fee_charged || 0), 0) || 0;
|
||||
const netRevenue = totalRevenue - platformFees;
|
||||
const averageTicketPrice = ticketsSold > 0 ? totalRevenue / ticketsSold : 0;
|
||||
const conversionRate = totalCapacity > 0 ? (ticketsSold / totalCapacity) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
netRevenue,
|
||||
platformFees,
|
||||
ticketsSold,
|
||||
averageTicketPrice,
|
||||
conversionRate,
|
||||
refundRate: 0 // TODO: Implement refunds tracking
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calculating sales metrics:', error);
|
||||
return {
|
||||
totalRevenue: 0,
|
||||
netRevenue: 0,
|
||||
platformFees: 0,
|
||||
ticketsSold: 0,
|
||||
averageTicketPrice: 0,
|
||||
conversionRate: 0,
|
||||
refundRate: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get detailed revenue breakdown
|
||||
async getRevenueBreakdown(): Promise<RevenueBreakdown> {
|
||||
try {
|
||||
const { data: tickets, error } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
price,
|
||||
platform_fee_charged,
|
||||
stripe_fee_charged,
|
||||
ticket_types!inner(
|
||||
event_id
|
||||
)
|
||||
`)
|
||||
.eq('ticket_types.event_id', this.eventId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const grossRevenue = tickets?.reduce((sum, ticket) => sum + (ticket.price || 0), 0) || 0;
|
||||
const platformFees = tickets?.reduce((sum, ticket) => sum + (ticket.platform_fee_charged || 0), 0) || 0;
|
||||
const stripeFees = tickets?.reduce((sum, ticket) => sum + (ticket.stripe_fee_charged || 0), 0) || 0;
|
||||
const netRevenue = grossRevenue - platformFees;
|
||||
const organizerPayout = grossRevenue - platformFees - stripeFees;
|
||||
|
||||
return {
|
||||
grossRevenue,
|
||||
platformFees,
|
||||
netRevenue,
|
||||
stripeFees,
|
||||
organizerPayout
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calculating revenue breakdown:', error);
|
||||
return {
|
||||
grossRevenue: 0,
|
||||
platformFees: 0,
|
||||
netRevenue: 0,
|
||||
stripeFees: 0,
|
||||
organizerPayout: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get sales data grouped by timeframe (day or hour)
|
||||
async getSalesByTimeframe(timeframe: 'day' | 'hour'): Promise<SalesByTimeframe[]> {
|
||||
try {
|
||||
const dateFormat = timeframe === 'day' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH24:00:00';
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
price,
|
||||
created_at,
|
||||
ticket_types!inner(
|
||||
event_id
|
||||
)
|
||||
`)
|
||||
.eq('ticket_types.event_id', this.eventId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Group sales by timeframe
|
||||
const salesMap = new Map<string, { revenue: number; count: number }>();
|
||||
|
||||
tickets?.forEach(ticket => {
|
||||
const date = new Date(ticket.created_at);
|
||||
let key: string;
|
||||
|
||||
if (timeframe === 'day') {
|
||||
key = date.toISOString().split('T')[0];
|
||||
} else {
|
||||
key = `${date.toISOString().split('T')[0]} ${date.getHours().toString().padStart(2, '0')}:00:00`;
|
||||
}
|
||||
|
||||
const existing = salesMap.get(key) || { revenue: 0, count: 0 };
|
||||
salesMap.set(key, {
|
||||
revenue: existing.revenue + (ticket.price || 0),
|
||||
count: existing.count + 1
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by date
|
||||
return Array.from(salesMap.entries())
|
||||
.map(([date, data]) => ({
|
||||
date,
|
||||
revenue: data.revenue,
|
||||
ticketsSold: data.count,
|
||||
averagePrice: data.count > 0 ? data.revenue / data.count : 0
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
} catch (error) {
|
||||
console.error('Error getting sales by timeframe:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get performance metrics for each ticket type
|
||||
async getTicketTypePerformance(): Promise<TicketTypePerformance[]> {
|
||||
try {
|
||||
// Get ticket types with sales data
|
||||
const { data: ticketTypes, error: typesError } = await supabase
|
||||
.from('ticket_types')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
price,
|
||||
quantity_available,
|
||||
tickets(id, price)
|
||||
`)
|
||||
.eq('event_id', this.eventId);
|
||||
|
||||
if (typesError) throw typesError;
|
||||
|
||||
return ticketTypes?.map(type => {
|
||||
const quantitySold = type.tickets?.length || 0;
|
||||
const revenue = type.tickets?.reduce((sum: number, ticket: any) => sum + (ticket.price || 0), 0) || 0;
|
||||
const sellThroughRate = type.quantity_available > 0 ? (quantitySold / type.quantity_available) * 100 : 0;
|
||||
|
||||
return {
|
||||
ticketTypeId: type.id,
|
||||
name: type.name,
|
||||
price: type.price || 0,
|
||||
quantitySold,
|
||||
quantityAvailable: type.quantity_available || 0,
|
||||
revenue,
|
||||
sellThroughRate
|
||||
};
|
||||
}) || [];
|
||||
} catch (error) {
|
||||
console.error('Error getting ticket type performance:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent sales transactions
|
||||
async getRecentSales(limit: number = 20): Promise<any[]> {
|
||||
try {
|
||||
const { data: tickets, error } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
id,
|
||||
price,
|
||||
purchaser_name,
|
||||
purchaser_email,
|
||||
created_at,
|
||||
ticket_types!inner(
|
||||
event_id,
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('ticket_types.event_id', this.eventId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return tickets || [];
|
||||
} catch (error) {
|
||||
console.error('Error getting recent sales:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get sales velocity (sales per hour/day trends)
|
||||
async getSalesVelocity(): Promise<{ current: number; trend: 'up' | 'down' | 'stable' }> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
||||
|
||||
const { data: recentSales, error: recentError } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
id,
|
||||
created_at,
|
||||
ticket_types!inner(event_id)
|
||||
`)
|
||||
.eq('ticket_types.event_id', this.eventId)
|
||||
.gte('created_at', oneDayAgo.toISOString());
|
||||
|
||||
const { data: previousSales, error: previousError } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
id,
|
||||
created_at,
|
||||
ticket_types!inner(event_id)
|
||||
`)
|
||||
.eq('ticket_types.event_id', this.eventId)
|
||||
.gte('created_at', twoDaysAgo.toISOString())
|
||||
.lt('created_at', oneDayAgo.toISOString());
|
||||
|
||||
if (recentError || previousError) throw recentError || previousError;
|
||||
|
||||
const currentVelocity = recentSales?.length || 0;
|
||||
const previousVelocity = previousSales?.length || 0;
|
||||
|
||||
let trend: 'up' | 'down' | 'stable' = 'stable';
|
||||
if (currentVelocity > previousVelocity * 1.1) trend = 'up';
|
||||
else if (currentVelocity < previousVelocity * 0.9) trend = 'down';
|
||||
|
||||
return { current: currentVelocity, trend };
|
||||
} catch (error) {
|
||||
console.error('Error calculating sales velocity:', error);
|
||||
return { current: 0, trend: 'stable' };
|
||||
}
|
||||
}
|
||||
|
||||
// Format currency values
|
||||
static formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Format percentage values
|
||||
static formatPercentage(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Format large numbers
|
||||
static formatNumber(value: number): string {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Export data to CSV
|
||||
export function exportAnalyticsToCSV(data: SalesAnalyticsData, eventTitle: string): void {
|
||||
const csvContent = [
|
||||
// Summary metrics
|
||||
['Sales Analytics Report', eventTitle],
|
||||
['Generated', new Date().toISOString()],
|
||||
[''],
|
||||
['SUMMARY METRICS'],
|
||||
['Total Revenue', EventAnalytics.formatCurrency(data.metrics.totalRevenue)],
|
||||
['Net Revenue', EventAnalytics.formatCurrency(data.metrics.netRevenue)],
|
||||
['Platform Fees', EventAnalytics.formatCurrency(data.metrics.platformFees)],
|
||||
['Tickets Sold', data.metrics.ticketsSold.toString()],
|
||||
['Average Ticket Price', EventAnalytics.formatCurrency(data.metrics.averageTicketPrice)],
|
||||
['Conversion Rate', EventAnalytics.formatPercentage(data.metrics.conversionRate)],
|
||||
[''],
|
||||
['TICKET TYPE PERFORMANCE'],
|
||||
['Ticket Type', 'Price', 'Sold', 'Available', 'Revenue', 'Sell-through Rate'],
|
||||
...data.ticketTypePerformance.map(type => [
|
||||
type.name,
|
||||
EventAnalytics.formatCurrency(type.price),
|
||||
type.quantitySold.toString(),
|
||||
type.quantityAvailable.toString(),
|
||||
EventAnalytics.formatCurrency(type.revenue),
|
||||
EventAnalytics.formatPercentage(type.sellThroughRate)
|
||||
]),
|
||||
[''],
|
||||
['DAILY SALES'],
|
||||
['Date', 'Revenue', 'Tickets Sold', 'Average Price'],
|
||||
...data.salesByDay.map(day => [
|
||||
day.date,
|
||||
EventAnalytics.formatCurrency(day.revenue),
|
||||
day.ticketsSold.toString(),
|
||||
EventAnalytics.formatCurrency(day.averagePrice)
|
||||
])
|
||||
];
|
||||
|
||||
const csv = csvContent.map(row => row.join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_analytics_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
294
src/lib/auth.ts
Normal file
294
src/lib/auth.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { supabase } from './supabase';
|
||||
import { logSecurityEvent, logUserActivity } from './logger';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
|
||||
export interface AuthContext {
|
||||
user: User;
|
||||
session: Session;
|
||||
isAdmin?: boolean;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side authentication verification
|
||||
* Validates the auth token from cookies or headers
|
||||
*/
|
||||
export async function verifyAuth(request: Request): Promise<AuthContext | null> {
|
||||
try {
|
||||
// Get auth token from Authorization header or cookies
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const cookieHeader = request.headers.get('Cookie');
|
||||
|
||||
let accessToken: string | null = null;
|
||||
|
||||
// Try Authorization header first
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Try cookies if no auth header
|
||||
if (!accessToken && cookieHeader) {
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
accessToken = cookies['sb-access-token'] || cookies['supabase-auth-token'];
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify the token with Supabase
|
||||
const { data: { user }, error } = await supabase.auth.getUser(accessToken);
|
||||
|
||||
if (error || !user) {
|
||||
// Log failed authentication attempt
|
||||
logSecurityEvent({
|
||||
type: 'auth_failure',
|
||||
ipAddress: getClientIPFromHeaders(request),
|
||||
userAgent: request.headers.get('User-Agent') || undefined,
|
||||
severity: 'medium',
|
||||
details: { error: error?.message, reason: 'invalid_token' }
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get user's organization
|
||||
const { data: userRecord } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id, role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
// Mock session object (since we're doing server-side verification)
|
||||
const session: Session = {
|
||||
access_token: accessToken,
|
||||
refresh_token: '', // Not needed for verification
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() / 1000 + 3600,
|
||||
token_type: 'bearer',
|
||||
user
|
||||
};
|
||||
|
||||
// Log successful authentication
|
||||
logUserActivity({
|
||||
action: 'auth_success',
|
||||
userId: user.id,
|
||||
ipAddress: getClientIPFromHeaders(request),
|
||||
userAgent: request.headers.get('User-Agent') || undefined,
|
||||
details: { organizationId: userRecord?.organization_id, role: userRecord?.role }
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
session,
|
||||
isAdmin: userRecord?.role === 'admin',
|
||||
organizationId: userRecord?.organization_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Auth verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware function to protect routes
|
||||
*/
|
||||
export async function requireAuth(request: Request): Promise<AuthContext> {
|
||||
const auth = await verifyAuth(request);
|
||||
|
||||
if (!auth) {
|
||||
logSecurityEvent({
|
||||
type: 'access_denied',
|
||||
ipAddress: getClientIPFromHeaders(request),
|
||||
userAgent: request.headers.get('User-Agent') || undefined,
|
||||
severity: 'low',
|
||||
details: { reason: 'no_authentication' }
|
||||
});
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware function to require admin access
|
||||
*/
|
||||
export async function requireAdmin(request: Request): Promise<AuthContext> {
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
if (!auth.isAdmin) {
|
||||
logSecurityEvent({
|
||||
type: 'access_denied',
|
||||
userId: auth.user.id,
|
||||
ipAddress: getClientIPFromHeaders(request),
|
||||
userAgent: request.headers.get('User-Agent') || undefined,
|
||||
severity: 'medium',
|
||||
details: { reason: 'insufficient_privileges', requiredRole: 'admin' }
|
||||
});
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to a specific organization
|
||||
*/
|
||||
export async function requireOrganizationAccess(
|
||||
request: Request,
|
||||
organizationId: string
|
||||
): Promise<AuthContext> {
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
if (auth.organizationId !== organizationId && !auth.isAdmin) {
|
||||
logSecurityEvent({
|
||||
type: 'access_denied',
|
||||
userId: auth.user.id,
|
||||
ipAddress: getClientIPFromHeaders(request),
|
||||
userAgent: request.headers.get('User-Agent') || undefined,
|
||||
severity: 'high',
|
||||
details: {
|
||||
reason: 'organization_access_violation',
|
||||
userOrganization: auth.organizationId,
|
||||
requestedOrganization: organizationId
|
||||
}
|
||||
});
|
||||
throw new Error('Access denied to this organization');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSRF token
|
||||
*/
|
||||
export function generateCSRFToken(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSRF token
|
||||
*/
|
||||
export function verifyCSRFToken(request: Request, sessionToken: string): boolean {
|
||||
const submittedToken = request.headers.get('X-CSRF-Token') ||
|
||||
request.headers.get('X-Requested-With');
|
||||
|
||||
return submittedToken === sessionToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting - simple in-memory implementation
|
||||
* For production, use Redis or a proper rate limiting service
|
||||
*/
|
||||
const rateLimitStore = new Map<string, { count: number; lastReset: number }>();
|
||||
|
||||
export function checkRateLimit(
|
||||
identifier: string,
|
||||
maxRequests: number = 10,
|
||||
windowMs: number = 60000
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMs;
|
||||
|
||||
let entry = rateLimitStore.get(identifier);
|
||||
|
||||
if (!entry || entry.lastReset < windowStart) {
|
||||
entry = { count: 0, lastReset: now };
|
||||
rateLimitStore.set(identifier, entry);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
|
||||
// Clean up old entries periodically
|
||||
if (Math.random() < 0.01) { // 1% chance
|
||||
cleanupRateLimit(windowStart);
|
||||
}
|
||||
|
||||
const isAllowed = entry.count <= maxRequests;
|
||||
|
||||
// Log rate limit violations
|
||||
if (!isAllowed) {
|
||||
logSecurityEvent({
|
||||
type: 'rate_limit',
|
||||
ipAddress: identifier.includes(':') ? identifier.split(':')[1] : identifier,
|
||||
severity: 'medium',
|
||||
details: {
|
||||
maxRequests,
|
||||
windowMs,
|
||||
currentCount: entry.count,
|
||||
identifier
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return isAllowed;
|
||||
}
|
||||
|
||||
function cleanupRateLimit(cutoff: number) {
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (entry.lastReset < cutoff) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookies from cookie header
|
||||
*/
|
||||
function parseCookies(cookieHeader: string): Record<string, string> {
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
cookieHeader.split(';').forEach(cookie => {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
if (name && rest.length > 0) {
|
||||
cookies[name] = rest.join('=');
|
||||
}
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create secure response with auth headers
|
||||
*/
|
||||
export function createAuthResponse(
|
||||
body: string | object,
|
||||
status: number = 200,
|
||||
additionalHeaders: Record<string, string> = {}
|
||||
): Response {
|
||||
const headers = {
|
||||
'Content-Type': typeof body === 'string' ? 'text/plain' : 'application/json',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
...additionalHeaders
|
||||
};
|
||||
|
||||
return new Response(
|
||||
typeof body === 'string' ? body : JSON.stringify(body),
|
||||
{ status, headers }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address for rate limiting
|
||||
*/
|
||||
export function getClientIP(request: Request): string {
|
||||
return getClientIPFromHeaders(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract IP from headers
|
||||
*/
|
||||
function getClientIPFromHeaders(request: Request): string {
|
||||
// Try various headers that might contain the real IP
|
||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||
const realIP = request.headers.get('X-Real-IP');
|
||||
const cfConnectingIP = request.headers.get('CF-Connecting-IP');
|
||||
|
||||
if (cfConnectingIP) return cfConnectingIP;
|
||||
if (realIP) return realIP;
|
||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||
|
||||
// Fallback to connection IP (may not be available in all environments)
|
||||
return request.headers.get('X-Client-IP') || 'unknown';
|
||||
}
|
||||
126
src/lib/availabilityDisplay.ts
Normal file
126
src/lib/availabilityDisplay.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Utility functions for availability display logic
|
||||
|
||||
export interface AvailabilityInfo {
|
||||
available: number;
|
||||
total: number;
|
||||
reserved: number;
|
||||
sold: number;
|
||||
is_available: boolean;
|
||||
}
|
||||
|
||||
export interface EventAvailabilitySettings {
|
||||
availability_display_mode: 'available_only' | 'show_quantity' | 'smart_threshold';
|
||||
availability_threshold: number;
|
||||
show_sold_out: boolean;
|
||||
low_stock_threshold: number;
|
||||
availability_messages: {
|
||||
available: string;
|
||||
low_stock: string;
|
||||
sold_out: string;
|
||||
unlimited: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AvailabilityDisplay {
|
||||
text: string;
|
||||
className: string;
|
||||
showExactCount: boolean;
|
||||
isLowStock: boolean;
|
||||
isSoldOut: boolean;
|
||||
}
|
||||
|
||||
export function formatAvailabilityDisplay(
|
||||
availability: AvailabilityInfo,
|
||||
settings: EventAvailabilitySettings
|
||||
): AvailabilityDisplay {
|
||||
const {
|
||||
availability_display_mode,
|
||||
availability_threshold,
|
||||
low_stock_threshold,
|
||||
availability_messages
|
||||
} = settings;
|
||||
|
||||
const { available, total, is_available } = availability;
|
||||
const isUnlimited = total === 999999;
|
||||
const isLowStock = !isUnlimited && available <= low_stock_threshold && available > 0;
|
||||
const isSoldOut = !is_available;
|
||||
|
||||
// Determine if we should show exact count
|
||||
let showExactCount = false;
|
||||
switch (availability_display_mode) {
|
||||
case 'show_quantity':
|
||||
showExactCount = true;
|
||||
break;
|
||||
case 'smart_threshold':
|
||||
showExactCount = !isUnlimited && available <= availability_threshold;
|
||||
break;
|
||||
case 'available_only':
|
||||
default:
|
||||
showExactCount = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate display text
|
||||
let text: string;
|
||||
let className: string;
|
||||
|
||||
if (isSoldOut) {
|
||||
text = availability_messages.sold_out;
|
||||
className = 'text-red-600';
|
||||
} else if (isUnlimited) {
|
||||
text = availability_messages.unlimited;
|
||||
className = 'text-green-600';
|
||||
} else if (showExactCount) {
|
||||
if (isLowStock) {
|
||||
text = availability_messages.low_stock.replace('{count}', available.toString());
|
||||
className = 'text-orange-600';
|
||||
} else {
|
||||
text = `${available} available`;
|
||||
className = 'text-green-600';
|
||||
}
|
||||
} else {
|
||||
// Just show "Available" without count
|
||||
if (isLowStock) {
|
||||
// Even in available_only mode, we might want to show low stock warning
|
||||
text = availability_messages.low_stock.replace('{count}', available.toString());
|
||||
className = 'text-orange-600';
|
||||
} else {
|
||||
text = availability_messages.available;
|
||||
className = 'text-green-600';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
className,
|
||||
showExactCount,
|
||||
isLowStock,
|
||||
isSoldOut
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldShowTicketType(
|
||||
availability: AvailabilityInfo,
|
||||
settings: EventAvailabilitySettings
|
||||
): boolean {
|
||||
// If sold out and show_sold_out is false, hide the ticket type
|
||||
if (!availability.is_available && !settings.show_sold_out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default settings for events that don't have these fields set
|
||||
export const defaultAvailabilitySettings: EventAvailabilitySettings = {
|
||||
availability_display_mode: 'available_only',
|
||||
availability_threshold: 10,
|
||||
show_sold_out: true,
|
||||
low_stock_threshold: 5,
|
||||
availability_messages: {
|
||||
available: 'Available',
|
||||
low_stock: '{count} left',
|
||||
sold_out: 'Sold out',
|
||||
unlimited: 'Available'
|
||||
}
|
||||
};
|
||||
642
src/lib/backup.ts
Normal file
642
src/lib/backup.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { logError, logUserActivity } from './logger';
|
||||
import { captureException } from './sentry';
|
||||
|
||||
// Environment variables
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL!;
|
||||
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY!;
|
||||
|
||||
// Create admin client for backup operations
|
||||
const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||
|
||||
/**
|
||||
* Backup configuration
|
||||
*/
|
||||
interface BackupConfig {
|
||||
retention: {
|
||||
daily: number; // Days to keep daily backups
|
||||
weekly: number; // Weeks to keep weekly backups
|
||||
monthly: number; // Months to keep monthly backups
|
||||
};
|
||||
tables: string[]; // Tables to backup
|
||||
storage: {
|
||||
bucket: string; // Storage bucket name
|
||||
path: string; // Path prefix for backups
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_BACKUP_CONFIG: BackupConfig = {
|
||||
retention: {
|
||||
daily: 7,
|
||||
weekly: 4,
|
||||
monthly: 12
|
||||
},
|
||||
tables: [
|
||||
'users',
|
||||
'organizations',
|
||||
'events',
|
||||
'tickets',
|
||||
'payouts',
|
||||
'audit_logs'
|
||||
],
|
||||
storage: {
|
||||
bucket: 'backups',
|
||||
path: 'database'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Backup metadata
|
||||
*/
|
||||
interface BackupMetadata {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'daily' | 'weekly' | 'monthly';
|
||||
size: number;
|
||||
tables: string[];
|
||||
checksum: string;
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database backup manager
|
||||
*/
|
||||
export class BackupManager {
|
||||
private config: BackupConfig;
|
||||
|
||||
constructor(config: BackupConfig = DEFAULT_BACKUP_CONFIG) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full database backup
|
||||
*/
|
||||
async createBackup(type: 'daily' | 'weekly' | 'monthly' = 'daily'): Promise<BackupMetadata> {
|
||||
const backupId = `${type}-${Date.now()}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const metadata: BackupMetadata = {
|
||||
id: backupId,
|
||||
timestamp,
|
||||
type,
|
||||
size: 0,
|
||||
tables: this.config.tables,
|
||||
checksum: '',
|
||||
status: 'in_progress'
|
||||
};
|
||||
|
||||
try {
|
||||
logUserActivity({
|
||||
action: 'backup_started',
|
||||
userId: 'system',
|
||||
resourceType: 'database',
|
||||
resourceId: backupId
|
||||
});
|
||||
|
||||
// Create backup data
|
||||
const backupData: Record<string, any[]> = {};
|
||||
let totalSize = 0;
|
||||
|
||||
for (const table of this.config.tables) {
|
||||
try {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from(table)
|
||||
.select('*');
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to backup table ${table}: ${error.message}`);
|
||||
}
|
||||
|
||||
backupData[table] = data || [];
|
||||
totalSize += JSON.stringify(data).length;
|
||||
} catch (error) {
|
||||
console.error(`Error backing up table ${table}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup file
|
||||
const backupContent = JSON.stringify({
|
||||
metadata: {
|
||||
id: backupId,
|
||||
timestamp,
|
||||
type,
|
||||
tables: this.config.tables,
|
||||
version: '1.0'
|
||||
},
|
||||
data: backupData
|
||||
}, null, 2);
|
||||
|
||||
// Calculate checksum
|
||||
const checksum = await this.calculateChecksum(backupContent);
|
||||
metadata.checksum = checksum;
|
||||
metadata.size = backupContent.length;
|
||||
|
||||
// Upload to storage
|
||||
const fileName = `${this.config.storage.path}/${backupId}.json`;
|
||||
|
||||
const { error: uploadError } = await supabaseAdmin.storage
|
||||
.from(this.config.storage.bucket)
|
||||
.upload(fileName, backupContent, {
|
||||
contentType: 'application/json',
|
||||
cacheControl: '3600'
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error(`Failed to upload backup: ${uploadError.message}`);
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
await this.saveBackupMetadata(metadata);
|
||||
|
||||
metadata.status = 'completed';
|
||||
|
||||
logUserActivity({
|
||||
action: 'backup_completed',
|
||||
userId: 'system',
|
||||
resourceType: 'database',
|
||||
resourceId: backupId,
|
||||
details: {
|
||||
size: metadata.size,
|
||||
tables: metadata.tables.length,
|
||||
checksum: metadata.checksum
|
||||
}
|
||||
});
|
||||
|
||||
return metadata;
|
||||
|
||||
} catch (error) {
|
||||
metadata.status = 'failed';
|
||||
metadata.error = error.message;
|
||||
|
||||
logError(error, {
|
||||
requestId: backupId,
|
||||
additionalContext: {
|
||||
operation: 'database_backup',
|
||||
type,
|
||||
tables: this.config.tables
|
||||
}
|
||||
});
|
||||
|
||||
captureException(error, {
|
||||
additionalData: {
|
||||
backupId,
|
||||
type,
|
||||
tables: this.config.tables
|
||||
}
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore database from backup
|
||||
*/
|
||||
async restoreBackup(backupId: string, options: {
|
||||
tables?: string[];
|
||||
dryRun?: boolean;
|
||||
confirmRestore?: boolean;
|
||||
} = {}): Promise<void> {
|
||||
if (!options.confirmRestore) {
|
||||
throw new Error('Restore confirmation required. Set confirmRestore: true');
|
||||
}
|
||||
|
||||
try {
|
||||
logUserActivity({
|
||||
action: 'restore_started',
|
||||
userId: 'system',
|
||||
resourceType: 'database',
|
||||
resourceId: backupId
|
||||
});
|
||||
|
||||
// Download backup file
|
||||
const fileName = `${this.config.storage.path}/${backupId}.json`;
|
||||
|
||||
const { data: backupFile, error: downloadError } = await supabaseAdmin.storage
|
||||
.from(this.config.storage.bucket)
|
||||
.download(fileName);
|
||||
|
||||
if (downloadError) {
|
||||
throw new Error(`Failed to download backup: ${downloadError.message}`);
|
||||
}
|
||||
|
||||
// Parse backup data
|
||||
const backupContent = await backupFile.text();
|
||||
const backup = JSON.parse(backupContent);
|
||||
|
||||
// Verify checksum
|
||||
const expectedChecksum = await this.calculateChecksum(backupContent);
|
||||
if (backup.metadata.checksum !== expectedChecksum) {
|
||||
throw new Error('Backup file integrity check failed');
|
||||
}
|
||||
|
||||
const tablesToRestore = options.tables || backup.metadata.tables;
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('DRY RUN: Would restore tables:', tablesToRestore);
|
||||
console.log('Backup metadata:', backup.metadata);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore each table
|
||||
for (const table of tablesToRestore) {
|
||||
if (!backup.data[table]) {
|
||||
console.warn(`Table ${table} not found in backup`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear existing data (be very careful here!)
|
||||
const { error: deleteError } = await supabaseAdmin
|
||||
.from(table)
|
||||
.delete()
|
||||
.neq('id', '00000000-0000-0000-0000-000000000000'); // Delete all rows
|
||||
|
||||
if (deleteError) {
|
||||
throw new Error(`Failed to clear table ${table}: ${deleteError.message}`);
|
||||
}
|
||||
|
||||
// Insert backup data
|
||||
const { error: insertError } = await supabaseAdmin
|
||||
.from(table)
|
||||
.insert(backup.data[table]);
|
||||
|
||||
if (insertError) {
|
||||
throw new Error(`Failed to restore table ${table}: ${insertError.message}`);
|
||||
}
|
||||
|
||||
console.log(`Restored ${backup.data[table].length} rows to table ${table}`);
|
||||
} catch (error) {
|
||||
console.error(`Error restoring table ${table}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logUserActivity({
|
||||
action: 'restore_completed',
|
||||
userId: 'system',
|
||||
resourceType: 'database',
|
||||
resourceId: backupId,
|
||||
details: {
|
||||
tables: tablesToRestore
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError(error, {
|
||||
requestId: backupId,
|
||||
additionalContext: {
|
||||
operation: 'database_restore',
|
||||
tables: options.tables
|
||||
}
|
||||
});
|
||||
|
||||
captureException(error, {
|
||||
additionalData: {
|
||||
backupId,
|
||||
tables: options.tables
|
||||
}
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available backups
|
||||
*/
|
||||
async listBackups(): Promise<BackupMetadata[]> {
|
||||
try {
|
||||
const { data: files, error } = await supabaseAdmin.storage
|
||||
.from(this.config.storage.bucket)
|
||||
.list(this.config.storage.path);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to list backups: ${error.message}`);
|
||||
}
|
||||
|
||||
const backups: BackupMetadata[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name.endsWith('.json')) {
|
||||
try {
|
||||
const metadata = await this.getBackupMetadata(file.name.replace('.json', ''));
|
||||
if (metadata) {
|
||||
backups.push(metadata);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get metadata for backup ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return backups.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
} catch (error) {
|
||||
logError(error, {
|
||||
additionalContext: {
|
||||
operation: 'list_backups'
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backups based on retention policy
|
||||
*/
|
||||
async cleanupBackups(): Promise<void> {
|
||||
try {
|
||||
const backups = await this.listBackups();
|
||||
const now = new Date();
|
||||
const backupsToDelete: string[] = [];
|
||||
|
||||
for (const backup of backups) {
|
||||
const backupDate = new Date(backup.timestamp);
|
||||
const ageInDays = (now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
let shouldDelete = false;
|
||||
|
||||
switch (backup.type) {
|
||||
case 'daily':
|
||||
shouldDelete = ageInDays > this.config.retention.daily;
|
||||
break;
|
||||
case 'weekly':
|
||||
shouldDelete = ageInDays > (this.config.retention.weekly * 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
shouldDelete = ageInDays > (this.config.retention.monthly * 30);
|
||||
break;
|
||||
}
|
||||
|
||||
if (shouldDelete) {
|
||||
backupsToDelete.push(backup.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old backups
|
||||
for (const backupId of backupsToDelete) {
|
||||
try {
|
||||
const fileName = `${this.config.storage.path}/${backupId}.json`;
|
||||
|
||||
const { error } = await supabaseAdmin.storage
|
||||
.from(this.config.storage.bucket)
|
||||
.remove([fileName]);
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to delete backup ${backupId}:`, error);
|
||||
} else {
|
||||
console.log(`Deleted old backup: ${backupId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting backup ${backupId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logUserActivity({
|
||||
action: 'backup_cleanup',
|
||||
userId: 'system',
|
||||
resourceType: 'database',
|
||||
details: {
|
||||
deletedCount: backupsToDelete.length,
|
||||
backupIds: backupsToDelete
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError(error, {
|
||||
additionalContext: {
|
||||
operation: 'cleanup_backups'
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file checksum
|
||||
*/
|
||||
private async calculateChecksum(content: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(content);
|
||||
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} else {
|
||||
// Fallback for Node.js environment
|
||||
const crypto = require('crypto');
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save backup metadata
|
||||
*/
|
||||
private async saveBackupMetadata(metadata: BackupMetadata): Promise<void> {
|
||||
// This would typically save to a metadata table
|
||||
// For now, we'll store it as a separate file
|
||||
const metadataFileName = `${this.config.storage.path}/metadata/${metadata.id}.json`;
|
||||
|
||||
const { error } = await supabaseAdmin.storage
|
||||
.from(this.config.storage.bucket)
|
||||
.upload(metadataFileName, JSON.stringify(metadata, null, 2), {
|
||||
contentType: 'application/json',
|
||||
cacheControl: '3600'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.warn(`Failed to save backup metadata: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup metadata
|
||||
*/
|
||||
private async getBackupMetadata(backupId: string): Promise<BackupMetadata | null> {
|
||||
try {
|
||||
const metadataFileName = `${this.config.storage.path}/metadata/${backupId}.json`;
|
||||
|
||||
const { data, error } = await supabaseAdmin.storage
|
||||
.from(this.config.storage.bucket)
|
||||
.download(metadataFileName);
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await data.text();
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled backup runner
|
||||
*/
|
||||
export class BackupScheduler {
|
||||
private backupManager: BackupManager;
|
||||
private intervals: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(backupManager: BackupManager) {
|
||||
this.backupManager = backupManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start automated backups
|
||||
*/
|
||||
startScheduledBackups() {
|
||||
// Daily backups at 2 AM
|
||||
this.scheduleBackup('daily', '0 2 * * *', 'daily');
|
||||
|
||||
// Weekly backups on Sunday at 3 AM
|
||||
this.scheduleBackup('weekly', '0 3 * * 0', 'weekly');
|
||||
|
||||
// Monthly backups on the 1st at 4 AM
|
||||
this.scheduleBackup('monthly', '0 4 1 * *', 'monthly');
|
||||
|
||||
console.log('Backup scheduler started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all scheduled backups
|
||||
*/
|
||||
stopScheduledBackups() {
|
||||
for (const [name, interval] of this.intervals) {
|
||||
clearInterval(interval);
|
||||
console.log(`Stopped ${name} backup schedule`);
|
||||
}
|
||||
this.intervals.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a backup with cron-like syntax (simplified)
|
||||
*/
|
||||
private scheduleBackup(name: string, cronExpression: string, type: 'daily' | 'weekly' | 'monthly') {
|
||||
// For production, use a proper cron library like node-cron
|
||||
// This is a simplified version for demonstration
|
||||
|
||||
const runBackup = async () => {
|
||||
try {
|
||||
console.log(`Starting ${name} backup...`);
|
||||
await this.backupManager.createBackup(type);
|
||||
console.log(`${name} backup completed successfully`);
|
||||
|
||||
// Cleanup old backups after successful backup
|
||||
await this.backupManager.cleanupBackups();
|
||||
} catch (error) {
|
||||
console.error(`${name} backup failed:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// For demonstration, we'll run backups based on simple intervals
|
||||
// In production, replace with proper cron scheduling
|
||||
let intervalMs: number;
|
||||
|
||||
switch (type) {
|
||||
case 'daily':
|
||||
intervalMs = 24 * 60 * 60 * 1000; // 24 hours
|
||||
break;
|
||||
case 'weekly':
|
||||
intervalMs = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
break;
|
||||
case 'monthly':
|
||||
intervalMs = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
break;
|
||||
}
|
||||
|
||||
const interval = setInterval(runBackup, intervalMs);
|
||||
this.intervals.set(name, interval);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instances
|
||||
export const backupManager = new BackupManager();
|
||||
export const backupScheduler = new BackupScheduler(backupManager);
|
||||
|
||||
// Disaster recovery utilities
|
||||
export const DisasterRecovery = {
|
||||
/**
|
||||
* Create a point-in-time recovery backup
|
||||
*/
|
||||
async createPointInTimeBackup(label: string): Promise<BackupMetadata> {
|
||||
const customConfig = {
|
||||
...DEFAULT_BACKUP_CONFIG,
|
||||
storage: {
|
||||
bucket: 'backups',
|
||||
path: `disaster-recovery/${label}`
|
||||
}
|
||||
};
|
||||
|
||||
const manager = new BackupManager(customConfig);
|
||||
return await manager.createBackup('daily');
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify system integrity after recovery
|
||||
*/
|
||||
async verifySystemIntegrity(): Promise<{
|
||||
status: 'healthy' | 'degraded' | 'critical';
|
||||
checks: Array<{
|
||||
name: string;
|
||||
status: 'pass' | 'fail';
|
||||
message: string;
|
||||
}>;
|
||||
}> {
|
||||
const checks = [];
|
||||
|
||||
// Check database connectivity
|
||||
try {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('users')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
checks.push({
|
||||
name: 'Database Connectivity',
|
||||
status: error ? 'fail' : 'pass',
|
||||
message: error ? error.message : 'Database is accessible'
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: 'Database Connectivity',
|
||||
status: 'fail',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Check critical tables exist
|
||||
const criticalTables = ['users', 'organizations', 'events', 'tickets'];
|
||||
for (const table of criticalTables) {
|
||||
try {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from(table)
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
checks.push({
|
||||
name: `Table ${table}`,
|
||||
status: error ? 'fail' : 'pass',
|
||||
message: error ? error.message : `Table ${table} is accessible`
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: `Table ${table}`,
|
||||
status: 'fail',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
const failedChecks = checks.filter(check => check.status === 'fail').length;
|
||||
const status = failedChecks === 0 ? 'healthy' :
|
||||
failedChecks <= 2 ? 'degraded' : 'critical';
|
||||
|
||||
return { status, checks };
|
||||
}
|
||||
};
|
||||
1823
src/lib/database.types.ts
Normal file
1823
src/lib/database.types.ts
Normal file
File diff suppressed because it is too large
Load Diff
568
src/lib/email.ts
Normal file
568
src/lib/email.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import { Resend } from 'resend';
|
||||
import QRCode from 'qrcode';
|
||||
import { logUserActivity } from './logger';
|
||||
|
||||
// Initialize Resend
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
// Email configuration
|
||||
export const EMAIL_CONFIG = {
|
||||
FROM_EMAIL: 'Black Canyon Tickets <tickets@blackcanyontickets.com>',
|
||||
FROM_NAME: 'Black Canyon Tickets',
|
||||
SUPPORT_EMAIL: 'support@blackcanyontickets.com',
|
||||
DOMAIN: process.env.PUBLIC_APP_URL || 'https://portal.blackcanyontickets.com'
|
||||
};
|
||||
|
||||
// Validate email configuration
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn('RESEND_API_KEY environment variable is not set. Email functionality will be disabled.');
|
||||
}
|
||||
|
||||
export interface TicketEmailData {
|
||||
ticketId: string;
|
||||
ticketUuid: string;
|
||||
eventTitle: string;
|
||||
eventVenue: string;
|
||||
eventDate: string;
|
||||
eventTime: string;
|
||||
ticketType: string;
|
||||
seatInfo?: string;
|
||||
price: number;
|
||||
purchaserName: string;
|
||||
purchaserEmail: string;
|
||||
organizerName: string;
|
||||
organizerEmail: string;
|
||||
qrCodeUrl: string;
|
||||
orderNumber: string;
|
||||
totalAmount: number;
|
||||
platformFee: number;
|
||||
eventDescription?: string;
|
||||
eventAddress?: string;
|
||||
additionalInfo?: string;
|
||||
}
|
||||
|
||||
export interface OrderConfirmationData {
|
||||
orderNumber: string;
|
||||
purchaserName: string;
|
||||
purchaserEmail: string;
|
||||
eventTitle: string;
|
||||
eventVenue: string;
|
||||
eventDate: string;
|
||||
totalAmount: number;
|
||||
platformFee: number;
|
||||
tickets: Array<{
|
||||
type: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
seatInfo?: string;
|
||||
}>;
|
||||
organizerName: string;
|
||||
refundPolicy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code data URL for email
|
||||
*/
|
||||
async function generateQRCodeDataURL(ticketUuid: string): Promise<string> {
|
||||
try {
|
||||
const qrData = `${EMAIL_CONFIG.DOMAIN}/verify/${ticketUuid}`;
|
||||
const qrCodeDataURL = await QRCode.toDataURL(qrData, {
|
||||
errorCorrectionLevel: 'M',
|
||||
type: 'image/png',
|
||||
quality: 0.92,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
},
|
||||
width: 200
|
||||
});
|
||||
return qrCodeDataURL;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ticket confirmation email HTML
|
||||
*/
|
||||
function createTicketEmailHTML(data: TicketEmailData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Your Ticket for ${data.eventTitle}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.ticket-section {
|
||||
background-color: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
.qr-section {
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.event-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.detail-item {
|
||||
background-color: #f8fafc;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-value {
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f1f5f9;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.important-note {
|
||||
background-color: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.event-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 24px;">🎫 Your Ticket is Ready!</h1>
|
||||
<p style="margin: 10px 0 0; opacity: 0.9;">You're all set for ${data.eventTitle}</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi ${data.purchaserName},</p>
|
||||
|
||||
<p>Thanks for your purchase! Your ticket for <strong>${data.eventTitle}</strong> is confirmed and ready to use.</p>
|
||||
|
||||
<div class="ticket-section">
|
||||
<h2 style="margin-top: 0; color: #1e293b; font-size: 18px;">📍 Event Details</h2>
|
||||
<div class="event-details">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Event</div>
|
||||
<div class="detail-value">${data.eventTitle}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Date & Time</div>
|
||||
<div class="detail-value">${data.eventDate} at ${data.eventTime}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Venue</div>
|
||||
<div class="detail-value">${data.eventVenue}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Ticket Type</div>
|
||||
<div class="detail-value">${data.ticketType}${data.seatInfo ? ` - ${data.seatInfo}` : ''}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Order Number</div>
|
||||
<div class="detail-value">${data.orderNumber}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Amount Paid</div>
|
||||
<div class="detail-value">$${(data.totalAmount / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
<h3 style="color: #1e293b; margin-top: 0;">📱 Your Digital Ticket</h3>
|
||||
<p style="color: #64748b; margin-bottom: 20px;">Present this QR code at the venue for entry</p>
|
||||
<img src="${data.qrCodeUrl}" alt="Ticket QR Code" style="max-width: 200px; height: auto;" />
|
||||
<p style="font-size: 12px; color: #64748b; margin-top: 15px;">
|
||||
Ticket ID: ${data.ticketUuid}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="important-note">
|
||||
<strong>📋 Important Information:</strong>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>Save this email or screenshot the QR code</li>
|
||||
<li>Arrive 15-30 minutes early for entry</li>
|
||||
<li>Present a valid ID if required</li>
|
||||
<li>This ticket is non-transferable unless specified</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
${data.additionalInfo ? `
|
||||
<div style="background-color: #e0f2fe; border-radius: 6px; padding: 15px; margin: 20px 0;">
|
||||
<strong>Additional Information:</strong>
|
||||
<p style="margin: 10px 0 0;">${data.additionalInfo}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/e/${data.eventTitle.toLowerCase().replace(/\s+/g, '-')}" class="button">
|
||||
View Event Details
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p>Questions? Contact the event organizer at <a href="mailto:${data.organizerEmail}">${data.organizerEmail}</a> or our support team at <a href="mailto:${EMAIL_CONFIG.SUPPORT_EMAIL}">${EMAIL_CONFIG.SUPPORT_EMAIL}</a>.</p>
|
||||
|
||||
<p>We hope you have a great time at the event!</p>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px;">
|
||||
Best regards,<br>
|
||||
The Black Canyon Tickets Team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b;">
|
||||
This email was sent by Black Canyon Tickets.<br>
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/privacy" style="color: #3b82f6;">Privacy Policy</a> |
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/terms" style="color: #3b82f6;">Terms of Service</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create order confirmation email HTML
|
||||
*/
|
||||
function createOrderConfirmationHTML(data: OrderConfirmationData): string {
|
||||
const ticketList = data.tickets.map(ticket =>
|
||||
`<li>${ticket.quantity}x ${ticket.type}${ticket.seatInfo ? ` (${ticket.seatInfo})` : ''} - $${(ticket.price / 100).toFixed(2)} each</li>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Order Confirmation - ${data.eventTitle}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.order-summary {
|
||||
background-color: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f1f5f9;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 24px;">✅ Order Confirmed!</h1>
|
||||
<p style="margin: 10px 0 0; opacity: 0.9;">Order #${data.orderNumber}</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi ${data.purchaserName},</p>
|
||||
|
||||
<p>Your order for <strong>${data.eventTitle}</strong> has been confirmed! You'll receive individual ticket emails shortly with QR codes for entry.</p>
|
||||
|
||||
<div class="order-summary">
|
||||
<h3 style="margin-top: 0; color: #1e293b;">📋 Order Summary</h3>
|
||||
<p><strong>Event:</strong> ${data.eventTitle}<br>
|
||||
<strong>Venue:</strong> ${data.eventVenue}<br>
|
||||
<strong>Date:</strong> ${data.eventDate}</p>
|
||||
|
||||
<h4 style="color: #1e293b;">Tickets Purchased:</h4>
|
||||
<ul>
|
||||
${ticketList}
|
||||
</ul>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 20px 0;">
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
|
||||
<span>Subtotal:</span>
|
||||
<span>$${((data.totalAmount - data.platformFee) / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
|
||||
<span>Platform Fee:</span>
|
||||
<span>$${(data.platformFee / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin: 10px 0; font-weight: bold; font-size: 18px; border-top: 1px solid #e2e8f0; padding-top: 10px;">
|
||||
<span>Total:</span>
|
||||
<span>$${(data.totalAmount / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>Your individual ticket emails with QR codes will arrive within the next few minutes. If you don't receive them, please check your spam folder.</p>
|
||||
|
||||
${data.refundPolicy ? `
|
||||
<div style="background-color: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 20px 0;">
|
||||
<strong>Refund Policy:</strong>
|
||||
<p style="margin: 10px 0 0;">${data.refundPolicy}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<p>Questions about your order? Contact ${data.organizerName} at <a href="mailto:${data.purchaserEmail}">${data.purchaserEmail}</a> or our support team at <a href="mailto:${EMAIL_CONFIG.SUPPORT_EMAIL}">${EMAIL_CONFIG.SUPPORT_EMAIL}</a>.</p>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px;">
|
||||
Best regards,<br>
|
||||
The Black Canyon Tickets Team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b;">
|
||||
This email was sent by Black Canyon Tickets.<br>
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/privacy" style="color: #3b82f6;">Privacy Policy</a> |
|
||||
<a href="${EMAIL_CONFIG.DOMAIN}/terms" style="color: #3b82f6;">Terms of Service</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ticket confirmation email
|
||||
*/
|
||||
export async function sendTicketConfirmationEmail(ticketData: TicketEmailData): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn('Email service not configured. Skipping ticket confirmation email.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate QR code
|
||||
const qrCodeDataURL = await generateQRCodeDataURL(ticketData.ticketUuid);
|
||||
const emailData = { ...ticketData, qrCodeUrl: qrCodeDataURL };
|
||||
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: EMAIL_CONFIG.FROM_EMAIL,
|
||||
to: [ticketData.purchaserEmail],
|
||||
subject: `Your ticket for ${ticketData.eventTitle}`,
|
||||
html: createTicketEmailHTML(emailData),
|
||||
attachments: [
|
||||
{
|
||||
filename: `ticket-${ticketData.ticketUuid}.png`,
|
||||
content: qrCodeDataURL.split(',')[1], // Remove data URL prefix
|
||||
contentType: 'image/png'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log successful email send
|
||||
logUserActivity({
|
||||
action: 'ticket_email_sent',
|
||||
userId: '', // No user context for email
|
||||
details: {
|
||||
ticketId: ticketData.ticketId,
|
||||
recipientEmail: ticketData.purchaserEmail,
|
||||
eventTitle: ticketData.eventTitle,
|
||||
emailId: data?.id
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Ticket confirmation email sent successfully:', data?.id);
|
||||
} catch (error) {
|
||||
console.error('Error sending ticket confirmation email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order confirmation email
|
||||
*/
|
||||
export async function sendOrderConfirmationEmail(orderData: OrderConfirmationData): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn('Email service not configured. Skipping order confirmation email.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: EMAIL_CONFIG.FROM_EMAIL,
|
||||
to: [orderData.purchaserEmail],
|
||||
subject: `Order confirmed for ${orderData.eventTitle} - #${orderData.orderNumber}`,
|
||||
html: createOrderConfirmationHTML(orderData)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log successful email send
|
||||
logUserActivity({
|
||||
action: 'order_confirmation_email_sent',
|
||||
userId: '', // No user context for email
|
||||
details: {
|
||||
orderNumber: orderData.orderNumber,
|
||||
recipientEmail: orderData.purchaserEmail,
|
||||
eventTitle: orderData.eventTitle,
|
||||
totalAmount: orderData.totalAmount,
|
||||
emailId: data?.id
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Order confirmation email sent successfully:', data?.id);
|
||||
} catch (error) {
|
||||
console.error('Error sending order confirmation email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send organizer notification email
|
||||
*/
|
||||
export async function sendOrganizerNotificationEmail(data: {
|
||||
organizerEmail: string;
|
||||
organizerName: string;
|
||||
eventTitle: string;
|
||||
purchaserName: string;
|
||||
purchaserEmail: string;
|
||||
ticketType: string;
|
||||
amount: number;
|
||||
orderNumber: string;
|
||||
}): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: emailData, error } = await resend.emails.send({
|
||||
from: EMAIL_CONFIG.FROM_EMAIL,
|
||||
to: [data.organizerEmail],
|
||||
subject: `New ticket sale for ${data.eventTitle}`,
|
||||
html: `
|
||||
<h2>New Ticket Sale</h2>
|
||||
<p>Hi ${data.organizerName},</p>
|
||||
<p>You have a new ticket sale for <strong>${data.eventTitle}</strong>!</p>
|
||||
<ul>
|
||||
<li><strong>Customer:</strong> ${data.purchaserName} (${data.purchaserEmail})</li>
|
||||
<li><strong>Ticket Type:</strong> ${data.ticketType}</li>
|
||||
<li><strong>Amount:</strong> $${(data.amount / 100).toFixed(2)}</li>
|
||||
<li><strong>Order:</strong> #${data.orderNumber}</li>
|
||||
</ul>
|
||||
<p>View your full sales report at <a href="${EMAIL_CONFIG.DOMAIN}/dashboard">your dashboard</a>.</p>
|
||||
`
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error sending organizer notification:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending organizer notification email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email configuration
|
||||
*/
|
||||
export async function testEmailConfiguration(): Promise<boolean> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await resend.emails.send({
|
||||
from: EMAIL_CONFIG.FROM_EMAIL,
|
||||
to: ['test@example.com'], // This will fail but tests the connection
|
||||
subject: 'Test email configuration',
|
||||
html: '<p>This is a test email.</p>'
|
||||
});
|
||||
|
||||
// We expect this to fail with invalid email, but connection should work
|
||||
return error?.message?.includes('Invalid') || false;
|
||||
} catch (error) {
|
||||
console.error('Email configuration test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
428
src/lib/eventScraper.ts
Normal file
428
src/lib/eventScraper.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './database.types';
|
||||
import { logSecurityEvent, logError } from './logger';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Environment variables
|
||||
const supabaseUrl = process.env.SUPABASE_URL || import.meta.env.SUPABASE_URL || 'https://zctjaivtfyfxokfaemek.supabase.co';
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || import.meta.env.SUPABASE_SERVICE_KEY || '';
|
||||
|
||||
// Configuration
|
||||
const REDIRECT_URL = 'https://blackcanyontickets.com/events';
|
||||
const BASE_URL = 'https://blackcanyontickets.com';
|
||||
const LAST_SLUG_FILE = path.join(process.cwd(), 'logs', 'last_scraped_slug.txt');
|
||||
const SCRAPER_ORGANIZATION_ID = process.env.SCRAPER_ORGANIZATION_ID || 'scraped-events-org';
|
||||
|
||||
// Create Supabase client with proper types
|
||||
let supabase: ReturnType<typeof createClient<Database>> | null = null;
|
||||
|
||||
try {
|
||||
if (supabaseUrl && supabaseServiceKey) {
|
||||
supabase = createClient<Database>(supabaseUrl, supabaseServiceKey);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to initialize Supabase client for scraper', error);
|
||||
}
|
||||
|
||||
interface ScrapedEventDetails {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
venue?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
imageUrl?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current event slug by following the redirect from /events
|
||||
*/
|
||||
async function getCurrentEventSlug(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(REDIRECT_URL, {
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; BCT-Event-Scraper/1.0)'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 302 || response.status === 301) {
|
||||
const location = response.headers.get('location');
|
||||
if (location) {
|
||||
// Extract slug from the redirect URL
|
||||
const url = new URL(location, BASE_URL);
|
||||
return url.pathname;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logError('Failed to get current event slug', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse event details from the event page
|
||||
*/
|
||||
async function fetchEventDetails(slug: string): Promise<ScrapedEventDetails | null> {
|
||||
try {
|
||||
const eventUrl = `${BASE_URL}${slug}`;
|
||||
const response = await fetch(eventUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; BCT-Event-Scraper/1.0)'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Extract event details - these selectors may need adjustment based on actual HTML structure
|
||||
const title = $('h1').first().text().trim() ||
|
||||
$('[data-event-title]').text().trim() ||
|
||||
$('title').text().trim().split(' - ')[0];
|
||||
|
||||
const description = $('[data-event-description]').text().trim() ||
|
||||
$('.event-description').text().trim() ||
|
||||
$('meta[name="description"]').attr('content') ||
|
||||
'';
|
||||
|
||||
const venue = $('[data-event-venue]').text().trim() ||
|
||||
$('.venue-name').text().trim() ||
|
||||
$('.event-venue').text().trim() ||
|
||||
'Black Canyon Tickets Venue';
|
||||
|
||||
// Try to extract date/time information
|
||||
const dateTimeText = $('[data-event-date]').text().trim() ||
|
||||
$('[data-event-time]').text().trim() ||
|
||||
$('.event-date').text().trim() ||
|
||||
$('.event-time').text().trim();
|
||||
|
||||
// Try to extract image
|
||||
const imageUrl = $('[data-event-image]').attr('src') ||
|
||||
$('.event-image img').attr('src') ||
|
||||
$('meta[property="og:image"]').attr('content') ||
|
||||
$('img[alt*="event" i]').first().attr('src');
|
||||
|
||||
// Determine category based on content
|
||||
const category = determineCategoryFromContent($, title, description);
|
||||
|
||||
// Parse dates if available
|
||||
const { startTime, endTime } = parseDateTimeFromContent(dateTimeText, $);
|
||||
|
||||
return {
|
||||
slug,
|
||||
title: title || 'Featured Event',
|
||||
description: description.length > 0 ? description.substring(0, 500) : undefined,
|
||||
venue,
|
||||
startTime,
|
||||
endTime,
|
||||
imageUrl: imageUrl ? new URL(imageUrl, BASE_URL).toString() : undefined,
|
||||
category
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logError(`Failed to fetch event details for ${slug}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine event category based on content analysis
|
||||
*/
|
||||
function determineCategoryFromContent($: cheerio.CheerioAPI, title: string, description: string): string {
|
||||
const content = (title + ' ' + description).toLowerCase();
|
||||
|
||||
// Define category keywords
|
||||
const categoryKeywords = {
|
||||
music: ['concert', 'music', 'band', 'performance', 'singer', 'acoustic', 'jazz', 'classical', 'rock', 'pop'],
|
||||
arts: ['art', 'gallery', 'exhibition', 'theater', 'theatre', 'play', 'drama', 'dance', 'ballet'],
|
||||
community: ['community', 'festival', 'fair', 'celebration', 'parade', 'market', 'fundraiser', 'charity'],
|
||||
business: ['business', 'networking', 'conference', 'seminar', 'workshop', 'meetup', 'corporate'],
|
||||
food: ['food', 'wine', 'tasting', 'dinner', 'restaurant', 'culinary', 'chef', 'cooking'],
|
||||
sports: ['sports', 'race', 'marathon', 'golf', 'tournament', 'athletic', 'competition', 'game']
|
||||
};
|
||||
|
||||
// Find the category with the most matches
|
||||
let bestCategory = 'community';
|
||||
let maxMatches = 0;
|
||||
|
||||
for (const [category, keywords] of Object.entries(categoryKeywords)) {
|
||||
const matches = keywords.filter(keyword => content.includes(keyword)).length;
|
||||
if (matches > maxMatches) {
|
||||
maxMatches = matches;
|
||||
bestCategory = category;
|
||||
}
|
||||
}
|
||||
|
||||
return bestCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date/time information from content
|
||||
*/
|
||||
function parseDateTimeFromContent(dateTimeText: string, $: cheerio.CheerioAPI): { startTime?: string; endTime?: string } {
|
||||
if (!dateTimeText) {
|
||||
// Default to a future date if no date found
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30); // 30 days from now
|
||||
return {
|
||||
startTime: futureDate.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to parse the date/time
|
||||
// This is a simplified parser - could be enhanced based on actual format
|
||||
const date = new Date(dateTimeText);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return {
|
||||
startTime: date.toISOString()
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
// Fallback to future date
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
return {
|
||||
startTime: futureDate.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the last seen slug from file
|
||||
*/
|
||||
async function loadLastSeenSlug(): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(LAST_SLUG_FILE, 'utf-8');
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the last seen slug to file
|
||||
*/
|
||||
async function saveLastSeenSlug(slug: string): Promise<void> {
|
||||
try {
|
||||
// Ensure logs directory exists
|
||||
await fs.mkdir(path.dirname(LAST_SLUG_FILE), { recursive: true });
|
||||
await fs.writeFile(LAST_SLUG_FILE, slug);
|
||||
} catch (error) {
|
||||
logError('Failed to save last seen slug', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add scraped event to the database as a featured event
|
||||
*/
|
||||
async function addScrapedEventToDatabase(eventDetails: ScrapedEventDetails): Promise<boolean> {
|
||||
if (!supabase) {
|
||||
logError('Supabase client not available for adding scraped event');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a deterministic ID based on the slug to avoid duplicates
|
||||
const eventId = `scraped-${eventDetails.slug.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
|
||||
// Check if event already exists
|
||||
const { data: existingEvent } = await supabase
|
||||
.from('events')
|
||||
.select('id')
|
||||
.eq('id', eventId)
|
||||
.single();
|
||||
|
||||
if (existingEvent) {
|
||||
console.log(`Event ${eventId} already exists, skipping`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Insert the new event as featured and public
|
||||
const { error } = await supabase
|
||||
.from('events')
|
||||
.insert({
|
||||
id: eventId,
|
||||
title: eventDetails.title,
|
||||
slug: `external-${eventDetails.slug.split('/').pop()}` || eventId,
|
||||
description: eventDetails.description,
|
||||
venue: eventDetails.venue || 'Black Canyon Tickets Venue',
|
||||
start_time: eventDetails.startTime || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
end_time: eventDetails.endTime,
|
||||
image_url: eventDetails.imageUrl,
|
||||
category: eventDetails.category,
|
||||
is_featured: true,
|
||||
is_public: true,
|
||||
is_published: true,
|
||||
external_source: 'scraper',
|
||||
organization_id: SCRAPER_ORGANIZATION_ID,
|
||||
created_by: SCRAPER_ORGANIZATION_ID // This will need to be a valid user ID
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logError('Failed to insert scraped event into database', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully added featured event: ${eventDetails.title}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError('Error adding scraped event to database', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main scraper function - detects new events and adds them as featured
|
||||
*/
|
||||
export async function runEventScraper(): Promise<{ success: boolean; message: string; newEvent?: ScrapedEventDetails }> {
|
||||
try {
|
||||
console.log('🔍 Starting event scraper...');
|
||||
|
||||
// Get current event slug
|
||||
const currentSlug = await getCurrentEventSlug();
|
||||
if (!currentSlug) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No event redirect found on blackcanyontickets.com/events'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found current event slug: ${currentSlug}`);
|
||||
|
||||
// Check if this is a new event
|
||||
const lastSeenSlug = await loadLastSeenSlug();
|
||||
if (currentSlug === lastSeenSlug) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No new event detected (same as last seen)'
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch event details
|
||||
const eventDetails = await fetchEventDetails(currentSlug);
|
||||
if (!eventDetails) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to extract event details from ${currentSlug}`
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`📅 New event found: ${eventDetails.title}`);
|
||||
|
||||
// Add to database as featured event
|
||||
const added = await addScrapedEventToDatabase(eventDetails);
|
||||
if (!added) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to add event to database'
|
||||
};
|
||||
}
|
||||
|
||||
// Save the current slug as last seen
|
||||
await saveLastSeenSlug(currentSlug);
|
||||
|
||||
// Log the successful scraping
|
||||
logSecurityEvent({
|
||||
type: 'scraper_success',
|
||||
severity: 'info',
|
||||
details: {
|
||||
slug: currentSlug,
|
||||
title: eventDetails.title,
|
||||
venue: eventDetails.venue,
|
||||
category: eventDetails.category
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully scraped and added featured event: ${eventDetails.title}`,
|
||||
newEvent: eventDetails
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logError('Event scraper failed', error);
|
||||
|
||||
logSecurityEvent({
|
||||
type: 'scraper_error',
|
||||
severity: 'high',
|
||||
details: { error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Event scraper encountered an error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize scraper organization if it doesn't exist
|
||||
*/
|
||||
export async function initializeScraperOrganization(): Promise<boolean> {
|
||||
if (!supabase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if scraper organization exists
|
||||
const { data: existingOrg } = await supabase
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('id', SCRAPER_ORGANIZATION_ID)
|
||||
.single();
|
||||
|
||||
if (existingOrg) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create scraper organization
|
||||
const { error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
.insert({
|
||||
id: SCRAPER_ORGANIZATION_ID,
|
||||
name: 'Black Canyon Tickets - Scraped Events',
|
||||
logo: null,
|
||||
stripe_account_id: null
|
||||
});
|
||||
|
||||
if (orgError) {
|
||||
logError('Failed to create scraper organization', orgError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create scraper user
|
||||
const { error: userError } = await supabase
|
||||
.from('users')
|
||||
.insert({
|
||||
id: SCRAPER_ORGANIZATION_ID,
|
||||
email: 'scraper@blackcanyontickets.com',
|
||||
name: 'Event Scraper',
|
||||
organization_id: SCRAPER_ORGANIZATION_ID
|
||||
});
|
||||
|
||||
if (userError) {
|
||||
logError('Failed to create scraper user', userError);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Initialized scraper organization and user');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to initialize scraper organization', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
570
src/lib/firebaseEventScraper.ts
Normal file
570
src/lib/firebaseEventScraper.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './database.types';
|
||||
import { logSecurityEvent, logError } from './logger';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Environment variables
|
||||
const supabaseUrl = process.env.SUPABASE_URL || import.meta.env.SUPABASE_URL || 'https://zctjaivtfyfxokfaemek.supabase.co';
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || import.meta.env.SUPABASE_SERVICE_KEY || '';
|
||||
|
||||
// Firebase configuration
|
||||
const FIREBASE_PROJECT_ID = process.env.FIREBASE_PROJECT_ID || 'black-canyon-tickets-bct';
|
||||
const FIREBASE_API_KEY = process.env.FIREBASE_API_KEY || 'AIzaSyDpXpjfQcNO_Lz7OuzINzZJG6pQXFOOLxI';
|
||||
const FIREBASE_ADMIN_EMAIL = process.env.FIREBASE_ADMIN_EMAIL || 'Tyler@touchofcarepcp.com';
|
||||
const FIREBASE_ADMIN_PASSWORD = process.env.FIREBASE_ADMIN_PASSWORD || '^A@6qDIOah*qNf)^i)1tbqtY';
|
||||
|
||||
const LAST_SYNC_FILE = path.join(process.cwd(), 'logs', 'last_firebase_sync.txt');
|
||||
const SCRAPER_ORGANIZATION_ID = process.env.SCRAPER_ORGANIZATION_ID || 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||
const BCT_VENUE_ID = 'b47ac10b-58cc-4372-a567-0e02b2c3d479'; // Black Canyon Tickets venue
|
||||
|
||||
// Create Supabase client with proper types
|
||||
let supabase: ReturnType<typeof createClient<Database>> | null = null;
|
||||
|
||||
try {
|
||||
if (supabaseUrl && supabaseServiceKey) {
|
||||
supabase = createClient<Database>(supabaseUrl, supabaseServiceKey);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to initialize Supabase client for scraper', error);
|
||||
}
|
||||
|
||||
interface FirebaseEvent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
location: string;
|
||||
datetime: string;
|
||||
images?: string[];
|
||||
tickets: Array<{
|
||||
type: string;
|
||||
price: string;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
interface ProcessedEvent {
|
||||
firebaseId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
venue: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
imageUrl?: string;
|
||||
category: string;
|
||||
priceRange: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Firebase and get an ID token
|
||||
*/
|
||||
async function authenticateFirebase(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${FIREBASE_API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: FIREBASE_ADMIN_EMAIL,
|
||||
password: FIREBASE_ADMIN_PASSWORD,
|
||||
returnSecureToken: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Firebase auth failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.idToken;
|
||||
} catch (error) {
|
||||
logError('Firebase authentication failed', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all events from Firebase Firestore
|
||||
*/
|
||||
async function fetchFirebaseEvents(idToken: string): Promise<FirebaseEvent[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://firestore.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/databases/(default)/documents/events`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${idToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Firebase events fetch failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.documents) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.documents.map((doc: any) => {
|
||||
const fields = doc.fields;
|
||||
const documentId = doc.name.split('/').pop();
|
||||
|
||||
return {
|
||||
id: documentId,
|
||||
name: fields.name?.stringValue || '',
|
||||
description: fields.description?.stringValue || '',
|
||||
location: fields.location?.stringValue || '',
|
||||
datetime: fields.datetime?.stringValue || '',
|
||||
images: fields.images?.arrayValue?.values?.map((v: any) => v.stringValue) || [],
|
||||
tickets: fields.tickets?.arrayValue?.values?.map((v: any) => ({
|
||||
type: v.mapValue.fields.type?.stringValue || '',
|
||||
price: v.mapValue.fields.price?.stringValue || '0',
|
||||
})) || [],
|
||||
createdAt: fields.createdAt?.timestampValue || doc.createTime,
|
||||
updateTime: doc.updateTime,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to fetch Firebase events', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine event category based on content
|
||||
*/
|
||||
function categorizeEvent(name: string, description: string): string {
|
||||
const content = (name + ' ' + description).toLowerCase();
|
||||
|
||||
const categoryKeywords = {
|
||||
music: ['concert', 'band', 'music', 'guitar', 'song', 'album', 'tour', 'performance'],
|
||||
community: ['fair', 'festival', 'county', 'community', 'celebration', 'rodeo', 'carnival'],
|
||||
sports: ['rodeo', 'bull', 'riding', 'horse', 'competition', 'race', 'athletic'],
|
||||
arts: ['theater', 'theatre', 'art', 'dance', 'performance', 'show'],
|
||||
food: ['food', 'wine', 'tasting', 'dinner', 'culinary'],
|
||||
business: ['conference', 'meeting', 'workshop', 'seminar', 'networking'],
|
||||
};
|
||||
|
||||
let bestCategory = 'community';
|
||||
let maxMatches = 0;
|
||||
|
||||
for (const [category, keywords] of Object.entries(categoryKeywords)) {
|
||||
const matches = keywords.filter(keyword => content.includes(keyword)).length;
|
||||
if (matches > maxMatches) {
|
||||
maxMatches = matches;
|
||||
bestCategory = category;
|
||||
}
|
||||
}
|
||||
|
||||
return bestCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date from Firebase datetime string
|
||||
*/
|
||||
function parseEventDate(datetime: string): { startTime: string; endTime?: string } {
|
||||
try {
|
||||
// Handle various date formats
|
||||
let date: Date;
|
||||
|
||||
if (datetime.includes('August')) {
|
||||
// Parse formats like "August 8, 2025" or "August 6-9, 2025"
|
||||
const year = datetime.match(/202\d/)?.[0] || new Date().getFullYear().toString();
|
||||
|
||||
if (datetime.includes('-')) {
|
||||
// Range format like "August 6-9, 2025"
|
||||
const match = datetime.match(/(\w+)\s+(\d+)-(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
const [, month, startDay, endDay, yr] = match;
|
||||
const startDate = new Date(`${month} ${startDay}, ${yr}`);
|
||||
const endDate = new Date(`${month} ${endDay}, ${yr}`);
|
||||
|
||||
return {
|
||||
startTime: startDate.toISOString(),
|
||||
endTime: endDate.toISOString(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Single date format like "August 8, 2025"
|
||||
date = new Date(datetime);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return {
|
||||
startTime: date.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try direct date parsing
|
||||
date = new Date(datetime);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return {
|
||||
startTime: date.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Default to future date if parsing fails
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
return {
|
||||
startTime: futureDate.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
// Fallback to future date
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
return {
|
||||
startTime: futureDate.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate price range from tickets
|
||||
*/
|
||||
function calculatePriceRange(tickets: Array<{ type: string; price: string }>): string {
|
||||
if (tickets.length === 0) {
|
||||
return 'Price TBA';
|
||||
}
|
||||
|
||||
const prices = tickets
|
||||
.map(ticket => parseFloat(ticket.price))
|
||||
.filter(price => !isNaN(price))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (prices.length === 0) {
|
||||
return 'Price TBA';
|
||||
}
|
||||
|
||||
const min = prices[0];
|
||||
const max = prices[prices.length - 1];
|
||||
|
||||
if (min === max) {
|
||||
return `$${min.toFixed(2)}`;
|
||||
}
|
||||
|
||||
return `$${min.toFixed(2)} - $${max.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Firebase event into our format
|
||||
*/
|
||||
function processFirebaseEvent(firebaseEvent: FirebaseEvent): ProcessedEvent {
|
||||
const { startTime, endTime } = parseEventDate(firebaseEvent.datetime);
|
||||
|
||||
return {
|
||||
firebaseId: firebaseEvent.id,
|
||||
title: firebaseEvent.name,
|
||||
description: firebaseEvent.description.substring(0, 500), // Limit description length
|
||||
venue: firebaseEvent.location,
|
||||
startTime,
|
||||
endTime,
|
||||
imageUrl: firebaseEvent.images && firebaseEvent.images.length > 0 ? firebaseEvent.images[0] : undefined,
|
||||
category: categorizeEvent(firebaseEvent.name, firebaseEvent.description),
|
||||
priceRange: calculatePriceRange(firebaseEvent.tickets),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load last sync timestamp
|
||||
*/
|
||||
async function loadLastSyncTime(): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(LAST_SYNC_FILE, 'utf-8');
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save last sync timestamp
|
||||
*/
|
||||
async function saveLastSyncTime(timestamp: string): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(LAST_SYNC_FILE), { recursive: true });
|
||||
await fs.writeFile(LAST_SYNC_FILE, timestamp);
|
||||
} catch (error) {
|
||||
logError('Failed to save last sync time', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if event already exists in our database
|
||||
*/
|
||||
async function eventExistsInDatabase(firebaseId: string): Promise<boolean> {
|
||||
if (!supabase) {
|
||||
console.log(`❌ No Supabase client for checking event ${firebaseId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for events with this Firebase ID in the description
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.select('id, title, external_source')
|
||||
.eq('external_source', 'firebase')
|
||||
.eq('organization_id', SCRAPER_ORGANIZATION_ID)
|
||||
.ilike('description', `%firebase_id:${firebaseId}%`)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.log(`🔍 Event firebase-${firebaseId} not found in database: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
console.log(`✅ Event ${firebaseId} already exists: ${data.title}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log(`❌ Error checking event ${firebaseId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Firebase event to our database
|
||||
*/
|
||||
async function addEventToDatabase(processedEvent: ProcessedEvent): Promise<boolean> {
|
||||
if (!supabase) {
|
||||
console.log('❌ Supabase client not available for adding Firebase event');
|
||||
logError('Supabase client not available for adding Firebase event');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate a proper UUID for the event ID (can't use string concatenation)
|
||||
const eventId = crypto.randomUUID();
|
||||
console.log(`💾 Attempting to insert event with ID: ${eventId} (Firebase ID: ${processedEvent.firebaseId})`);
|
||||
|
||||
// Insert the new event as featured and public
|
||||
const { error } = await supabase
|
||||
.from('events')
|
||||
.insert({
|
||||
id: eventId,
|
||||
title: processedEvent.title,
|
||||
slug: `firebase-event-${processedEvent.firebaseId.toLowerCase()}`,
|
||||
description: `${processedEvent.description}\n\n[firebase_id:${processedEvent.firebaseId}]`, // Hidden identifier
|
||||
venue: processedEvent.venue,
|
||||
venue_id: BCT_VENUE_ID,
|
||||
start_time: processedEvent.startTime,
|
||||
end_time: processedEvent.endTime,
|
||||
image_url: processedEvent.imageUrl,
|
||||
category: processedEvent.category,
|
||||
is_featured: true,
|
||||
is_public: true,
|
||||
is_published: true,
|
||||
external_source: 'firebase',
|
||||
organization_id: SCRAPER_ORGANIZATION_ID,
|
||||
created_by: SCRAPER_ORGANIZATION_ID,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.log(`❌ Database insert failed for ${processedEvent.title}:`, error);
|
||||
logError('Failed to insert Firebase event into database', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ Added featured event: ${processedEvent.title} (${processedEvent.priceRange})`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.log(`💥 Exception adding event ${processedEvent.title}:`, error);
|
||||
logError('Error adding Firebase event to database', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Firebase scraper function
|
||||
*/
|
||||
export async function runFirebaseEventScraper(): Promise<{ success: boolean; message: string; newEvents?: ProcessedEvent[] }> {
|
||||
try {
|
||||
console.log('🔍 Starting Firebase event scraper...');
|
||||
|
||||
// Authenticate with Firebase
|
||||
const idToken = await authenticateFirebase();
|
||||
if (!idToken) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to authenticate with Firebase',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ Authenticated with Firebase');
|
||||
|
||||
// Ensure scraper organization exists
|
||||
try {
|
||||
const orgInitialized = await initializeScraperOrganization();
|
||||
if (!orgInitialized) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to initialize Black Canyon Tickets organization',
|
||||
debug: { step: 'organization_init_failed' },
|
||||
};
|
||||
}
|
||||
} catch (orgError) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Organization initialization error: ${orgError instanceof Error ? orgError.message : 'Unknown error'}`,
|
||||
debug: { step: 'organization_init_exception', error: orgError },
|
||||
};
|
||||
}
|
||||
console.log('✅ Black Canyon Tickets organization ready');
|
||||
|
||||
// Fetch events from Firebase
|
||||
const firebaseEvents = await fetchFirebaseEvents(idToken);
|
||||
console.log(`📅 Found ${firebaseEvents.length} events in Firebase`);
|
||||
|
||||
if (firebaseEvents.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No events found in Firebase',
|
||||
};
|
||||
}
|
||||
|
||||
// Process and filter new events
|
||||
const newEvents: ProcessedEvent[] = [];
|
||||
|
||||
console.log('🔍 Processing Firebase events...');
|
||||
for (const firebaseEvent of firebaseEvents) {
|
||||
console.log(`📅 Processing: ${firebaseEvent.name} (ID: ${firebaseEvent.id})`);
|
||||
|
||||
const exists = await eventExistsInDatabase(firebaseEvent.id);
|
||||
|
||||
if (!exists) {
|
||||
console.log(`🆕 Adding new event: ${firebaseEvent.name}`);
|
||||
const processedEvent = processFirebaseEvent(firebaseEvent);
|
||||
const added = await addEventToDatabase(processedEvent);
|
||||
|
||||
if (added) {
|
||||
newEvents.push(processedEvent);
|
||||
console.log(`✅ Successfully added: ${processedEvent.title}`);
|
||||
} else {
|
||||
console.log(`❌ Failed to add: ${firebaseEvent.name}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`⏭️ Event already exists: ${firebaseEvent.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save sync timestamp
|
||||
await saveLastSyncTime(new Date().toISOString());
|
||||
|
||||
// Log successful sync
|
||||
logSecurityEvent({
|
||||
type: 'firebase_scraper_success',
|
||||
severity: 'info',
|
||||
details: {
|
||||
totalEvents: firebaseEvents.length,
|
||||
newEvents: newEvents.length,
|
||||
syncTime: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const message = newEvents.length > 0
|
||||
? `Successfully synced ${newEvents.length} new events from Firebase`
|
||||
: `All Firebase events are already synchronized (found ${firebaseEvents.length} events in Firebase)`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
newEvents: newEvents.length > 0 ? newEvents : undefined,
|
||||
debug: {
|
||||
firebaseEventsCount: firebaseEvents.length,
|
||||
firebaseEventTitles: firebaseEvents.map(e => e.name),
|
||||
newEventsCount: newEvents.length,
|
||||
processedEvents: firebaseEvents.map(e => ({
|
||||
name: e.name,
|
||||
id: e.id,
|
||||
processed: true
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logError('Firebase event scraper failed', error);
|
||||
|
||||
logSecurityEvent({
|
||||
type: 'firebase_scraper_error',
|
||||
severity: 'high',
|
||||
details: { error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Firebase event scraper encountered an error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize scraper organization if it doesn't exist
|
||||
*/
|
||||
export async function initializeScraperOrganization(): Promise<boolean> {
|
||||
if (!supabase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if scraper organization exists
|
||||
console.log(`🔍 Checking for organization: ${SCRAPER_ORGANIZATION_ID}`);
|
||||
const { data: existingOrg, error: checkError } = await supabase
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('id', SCRAPER_ORGANIZATION_ID)
|
||||
.single();
|
||||
|
||||
if (existingOrg) {
|
||||
console.log('✅ Organization already exists');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('🆕 Creating new organization:', checkError?.message);
|
||||
|
||||
// Create scraper organization
|
||||
const { error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
.insert({
|
||||
id: SCRAPER_ORGANIZATION_ID,
|
||||
name: 'Black Canyon Tickets',
|
||||
logo: null,
|
||||
stripe_account_id: null,
|
||||
});
|
||||
|
||||
if (orgError) {
|
||||
console.log('❌ Failed to create organization:', orgError);
|
||||
logError('Failed to create scraper organization', orgError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create scraper user
|
||||
const { error: userError } = await supabase
|
||||
.from('users')
|
||||
.insert({
|
||||
id: SCRAPER_ORGANIZATION_ID,
|
||||
email: 'scraper@blackcanyontickets.com',
|
||||
name: 'Black Canyon Tickets Event Manager',
|
||||
organization_id: SCRAPER_ORGANIZATION_ID,
|
||||
});
|
||||
|
||||
if (userError) {
|
||||
console.log('❌ Failed to create user:', userError);
|
||||
logError('Failed to create scraper user', userError);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Initialized Firebase scraper organization and user');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to initialize scraper organization', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
230
src/lib/inventory.ts
Normal file
230
src/lib/inventory.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// Client-side inventory management library
|
||||
|
||||
export interface TicketAvailability {
|
||||
available: number;
|
||||
total: number;
|
||||
reserved: number;
|
||||
sold: number;
|
||||
is_available: boolean;
|
||||
}
|
||||
|
||||
export interface TicketReservation {
|
||||
id: string;
|
||||
ticket_type_id: string;
|
||||
quantity: number;
|
||||
expires_at: string;
|
||||
seat_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PurchaseItem {
|
||||
ticket_type_id: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
seat_id?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseAttempt {
|
||||
id: string;
|
||||
session_id: string;
|
||||
total_amount: number;
|
||||
platform_fee: number;
|
||||
expires_at: string;
|
||||
status: string;
|
||||
items: any[];
|
||||
reservations: string[];
|
||||
}
|
||||
|
||||
class InventoryManager {
|
||||
private baseUrl: string;
|
||||
public sessionId: string;
|
||||
private reservations: Map<string, TicketReservation> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = '/api/inventory';
|
||||
this.sessionId = this.getOrCreateSessionId();
|
||||
}
|
||||
|
||||
private getOrCreateSessionId(): string {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
// Fallback for server-side rendering
|
||||
return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
let sessionId = sessionStorage.getItem('ticket_session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
sessionStorage.setItem('ticket_session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async getAvailability(ticketTypeId: string): Promise<TicketAvailability> {
|
||||
const url = `${this.baseUrl}/availability/${encodeURIComponent(ticketTypeId)}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to get availability');
|
||||
}
|
||||
|
||||
return data.availability;
|
||||
}
|
||||
|
||||
async reserveTickets(
|
||||
ticketTypeId: string,
|
||||
quantity: number,
|
||||
holdMinutes: number = 15,
|
||||
seatIds?: string[]
|
||||
): Promise<TicketReservation> {
|
||||
const response = await fetch(`${this.baseUrl}/reserve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_type_id: ticketTypeId,
|
||||
quantity,
|
||||
session_id: this.sessionId,
|
||||
hold_minutes: holdMinutes,
|
||||
seat_ids: seatIds
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to reserve tickets');
|
||||
}
|
||||
|
||||
// Store reservation locally
|
||||
this.reservations.set(data.reservation.id, data.reservation);
|
||||
|
||||
// Set up auto-release timer
|
||||
this.scheduleAutoRelease(data.reservation);
|
||||
|
||||
return data.reservation;
|
||||
}
|
||||
|
||||
async releaseReservation(reservationId: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/release`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reservation_id: reservationId,
|
||||
session_id: this.sessionId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to release reservation');
|
||||
}
|
||||
|
||||
// Remove from local storage
|
||||
this.reservations.delete(reservationId);
|
||||
}
|
||||
|
||||
async createPurchaseAttempt(
|
||||
eventId: string,
|
||||
purchaserEmail: string,
|
||||
purchaserName: string,
|
||||
items: PurchaseItem[],
|
||||
platformFee: number = 0,
|
||||
holdMinutes: number = 30
|
||||
): Promise<PurchaseAttempt> {
|
||||
const response = await fetch(`${this.baseUrl}/purchase-attempt`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: this.sessionId,
|
||||
event_id: eventId,
|
||||
purchaser_email: purchaserEmail,
|
||||
purchaser_name: purchaserName,
|
||||
items,
|
||||
platform_fee: platformFee,
|
||||
hold_minutes: holdMinutes
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to create purchase attempt');
|
||||
}
|
||||
|
||||
return data.purchase_attempt;
|
||||
}
|
||||
|
||||
private scheduleAutoRelease(reservation: TicketReservation): void {
|
||||
const expiresAt = new Date(reservation.expires_at).getTime();
|
||||
const now = Date.now();
|
||||
const timeUntilExpiry = expiresAt - now;
|
||||
|
||||
if (timeUntilExpiry > 0) {
|
||||
setTimeout(() => {
|
||||
this.reservations.delete(reservation.id);
|
||||
// Optionally notify user that reservation expired
|
||||
this.onReservationExpired?.(reservation);
|
||||
}, timeUntilExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all active reservations for this session
|
||||
getActiveReservations(): TicketReservation[] {
|
||||
return Array.from(this.reservations.values());
|
||||
}
|
||||
|
||||
// Release all active reservations
|
||||
async releaseAllReservations(): Promise<void> {
|
||||
const promises = Array.from(this.reservations.keys()).map(id =>
|
||||
this.releaseReservation(id).catch(console.error)
|
||||
);
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// Get time remaining for a reservation in milliseconds
|
||||
getTimeRemaining(reservation: TicketReservation): number {
|
||||
const expiresAt = new Date(reservation.expires_at).getTime();
|
||||
const now = Date.now();
|
||||
return Math.max(0, expiresAt - now);
|
||||
}
|
||||
|
||||
// Format time remaining as a readable string
|
||||
formatTimeRemaining(reservation: TicketReservation): string {
|
||||
const ms = this.getTimeRemaining(reservation);
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Callback for when a reservation expires
|
||||
onReservationExpired?: (reservation: TicketReservation) => void;
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const inventoryManager = new InventoryManager();
|
||||
|
||||
// Only run browser-specific code if we're in the browser
|
||||
if (typeof window !== 'undefined') {
|
||||
// Cleanup reservations when page unloads
|
||||
window.addEventListener('beforeunload', () => {
|
||||
inventoryManager.releaseAllReservations().catch(console.error);
|
||||
});
|
||||
|
||||
// Auto-cleanup expired reservations every minute
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, reservation] of inventoryManager['reservations']) {
|
||||
if (new Date(reservation.expires_at).getTime() <= now) {
|
||||
inventoryManager['reservations'].delete(id);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
274
src/lib/logger.ts
Normal file
274
src/lib/logger.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import winston from 'winston';
|
||||
import { captureException, captureMessage, addBreadcrumb } from './sentry';
|
||||
|
||||
// Define log levels
|
||||
const logLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
// Define log colors
|
||||
const logColors = {
|
||||
error: 'red',
|
||||
warn: 'yellow',
|
||||
info: 'green',
|
||||
http: 'magenta',
|
||||
debug: 'white',
|
||||
};
|
||||
|
||||
// Add colors to winston
|
||||
winston.addColors(logColors);
|
||||
|
||||
// Define log format
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf(
|
||||
(info) => `${info.timestamp} ${info.level}: ${info.message}`
|
||||
),
|
||||
);
|
||||
|
||||
// Define transports
|
||||
const transports = [
|
||||
// Console transport
|
||||
new winston.transports.Console({
|
||||
format: logFormat,
|
||||
}),
|
||||
|
||||
// Error log file
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
}),
|
||||
|
||||
// Combined log file
|
||||
new winston.transports.File({
|
||||
filename: 'logs/combined.log',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
|
||||
levels: logLevels,
|
||||
transports,
|
||||
});
|
||||
|
||||
// Security event logging
|
||||
export interface SecurityEvent {
|
||||
type: 'auth_failure' | 'rate_limit' | 'suspicious_activity' | 'access_denied' | 'data_breach';
|
||||
userId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
details?: Record<string, any>;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
}
|
||||
|
||||
export function logSecurityEvent(event: SecurityEvent) {
|
||||
logger.warn('SECURITY_EVENT', {
|
||||
type: event.type,
|
||||
userId: event.userId,
|
||||
ipAddress: event.ipAddress,
|
||||
userAgent: event.userAgent,
|
||||
severity: event.severity,
|
||||
details: event.details,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// In production, you might also send this to a security monitoring service
|
||||
if (event.severity === 'critical') {
|
||||
logger.error('CRITICAL_SECURITY_EVENT', event);
|
||||
// TODO: Send alert to security team
|
||||
}
|
||||
}
|
||||
|
||||
// API request logging
|
||||
export interface APILogEntry {
|
||||
method: string;
|
||||
url: string;
|
||||
statusCode: number;
|
||||
responseTime: number;
|
||||
userId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function logAPIRequest(entry: APILogEntry) {
|
||||
const level = entry.statusCode >= 500 ? 'error' :
|
||||
entry.statusCode >= 400 ? 'warn' : 'info';
|
||||
|
||||
logger.log(level, 'API_REQUEST', {
|
||||
method: entry.method,
|
||||
url: entry.url,
|
||||
statusCode: entry.statusCode,
|
||||
responseTime: entry.responseTime,
|
||||
userId: entry.userId,
|
||||
ipAddress: entry.ipAddress,
|
||||
userAgent: entry.userAgent,
|
||||
error: entry.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Payment event logging
|
||||
export interface PaymentEvent {
|
||||
type: 'payment_started' | 'payment_completed' | 'payment_failed' | 'refund_requested' | 'refund_completed';
|
||||
userId?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paymentIntentId?: string;
|
||||
eventId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function logPaymentEvent(event: PaymentEvent) {
|
||||
const level = event.type.includes('failed') ? 'error' : 'info';
|
||||
|
||||
logger.log(level, 'PAYMENT_EVENT', {
|
||||
type: event.type,
|
||||
userId: event.userId,
|
||||
amount: event.amount,
|
||||
currency: event.currency,
|
||||
paymentIntentId: event.paymentIntentId,
|
||||
eventId: event.eventId,
|
||||
error: event.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// User activity logging
|
||||
export interface UserActivity {
|
||||
action: string;
|
||||
userId: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function logUserActivity(activity: UserActivity) {
|
||||
logger.info('USER_ACTIVITY', {
|
||||
action: activity.action,
|
||||
userId: activity.userId,
|
||||
resourceType: activity.resourceType,
|
||||
resourceId: activity.resourceId,
|
||||
ipAddress: activity.ipAddress,
|
||||
userAgent: activity.userAgent,
|
||||
details: activity.details,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Error logging with context
|
||||
export interface ErrorContext {
|
||||
userId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
requestId?: string;
|
||||
additionalContext?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function logError(error: Error, context?: ErrorContext) {
|
||||
logger.error('APPLICATION_ERROR', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
userId: context?.userId,
|
||||
ipAddress: context?.ipAddress,
|
||||
userAgent: context?.userAgent,
|
||||
requestId: context?.requestId,
|
||||
additionalContext: context?.additionalContext,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Also send to Sentry
|
||||
captureException(error, {
|
||||
userId: context?.userId,
|
||||
userEmail: context?.userAgent, // We don't have email in context, would need to be added
|
||||
requestId: context?.requestId,
|
||||
additionalData: {
|
||||
ipAddress: context?.ipAddress,
|
||||
userAgent: context?.userAgent,
|
||||
...context?.additionalContext
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Performance logging
|
||||
export interface PerformanceMetrics {
|
||||
operation: string;
|
||||
duration: number;
|
||||
userId?: string;
|
||||
additionalMetrics?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function logPerformance(metrics: PerformanceMetrics) {
|
||||
logger.info('PERFORMANCE_METRICS', {
|
||||
operation: metrics.operation,
|
||||
duration: metrics.duration,
|
||||
userId: metrics.userId,
|
||||
additionalMetrics: metrics.additionalMetrics,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Business metrics logging
|
||||
export interface BusinessMetrics {
|
||||
metric: string;
|
||||
value: number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function logBusinessMetrics(metrics: BusinessMetrics) {
|
||||
logger.info('BUSINESS_METRICS', {
|
||||
metric: metrics.metric,
|
||||
value: metrics.value,
|
||||
tags: metrics.tags,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Audit trail logging
|
||||
export interface AuditEvent {
|
||||
action: string;
|
||||
userId: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
oldValues?: Record<string, any>;
|
||||
newValues?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export function logAuditEvent(event: AuditEvent) {
|
||||
logger.info('AUDIT_TRAIL', {
|
||||
action: event.action,
|
||||
userId: event.userId,
|
||||
resourceType: event.resourceType,
|
||||
resourceId: event.resourceId,
|
||||
oldValues: event.oldValues,
|
||||
newValues: event.newValues,
|
||||
ipAddress: event.ipAddress,
|
||||
userAgent: event.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Also log to database for compliance
|
||||
// This would integrate with your audit_logs table
|
||||
}
|
||||
|
||||
// Export the main logger instance
|
||||
export default logger;
|
||||
394
src/lib/performance.ts
Normal file
394
src/lib/performance.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { logPerformance } from './logger';
|
||||
import { startTransaction, addBreadcrumb } from './sentry';
|
||||
|
||||
/**
|
||||
* Performance monitoring utilities
|
||||
*/
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private startTime: number;
|
||||
private endTime?: number;
|
||||
private name: string;
|
||||
private sentryTransaction: any;
|
||||
|
||||
constructor(name: string, operation: string = 'custom') {
|
||||
this.name = name;
|
||||
this.startTime = Date.now();
|
||||
this.sentryTransaction = startTransaction(name, operation);
|
||||
|
||||
addBreadcrumb(`Started ${name}`, 'performance', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the end of the performance measurement
|
||||
*/
|
||||
end(additionalMetrics?: Record<string, number>) {
|
||||
this.endTime = Date.now();
|
||||
const duration = this.endTime - this.startTime;
|
||||
|
||||
// Log to our custom logger
|
||||
logPerformance({
|
||||
operation: this.name,
|
||||
duration,
|
||||
additionalMetrics
|
||||
});
|
||||
|
||||
// Finish Sentry transaction
|
||||
if (this.sentryTransaction) {
|
||||
this.sentryTransaction.setTag('duration', duration.toString());
|
||||
if (additionalMetrics) {
|
||||
Object.entries(additionalMetrics).forEach(([key, value]) => {
|
||||
this.sentryTransaction.setTag(key, value.toString());
|
||||
});
|
||||
}
|
||||
this.sentryTransaction.finish();
|
||||
}
|
||||
|
||||
addBreadcrumb(`Completed ${this.name} in ${duration}ms`, 'performance', 'info');
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current duration without ending the measurement
|
||||
*/
|
||||
getCurrentDuration(): number {
|
||||
return Date.now() - this.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor database query performance
|
||||
*/
|
||||
export class DatabaseMonitor {
|
||||
private static instance: DatabaseMonitor;
|
||||
private queryTimes: Map<string, number[]> = new Map();
|
||||
|
||||
static getInstance(): DatabaseMonitor {
|
||||
if (!DatabaseMonitor.instance) {
|
||||
DatabaseMonitor.instance = new DatabaseMonitor();
|
||||
}
|
||||
return DatabaseMonitor.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a database query
|
||||
*/
|
||||
trackQuery(query: string, duration: number, table?: string) {
|
||||
const key = table || 'unknown';
|
||||
if (!this.queryTimes.has(key)) {
|
||||
this.queryTimes.set(key, []);
|
||||
}
|
||||
|
||||
this.queryTimes.get(key)!.push(duration);
|
||||
|
||||
// Log slow queries
|
||||
if (duration > 1000) { // Queries over 1 second
|
||||
console.warn(`Slow query detected: ${query} took ${duration}ms`);
|
||||
addBreadcrumb(`Slow query: ${query.substring(0, 100)}...`, 'database', 'warning', {
|
||||
duration,
|
||||
table
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up old metrics (keep only last 100 per table)
|
||||
const times = this.queryTimes.get(key)!;
|
||||
if (times.length > 100) {
|
||||
times.splice(0, times.length - 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average query time for a table
|
||||
*/
|
||||
getAverageQueryTime(table: string): number {
|
||||
const times = this.queryTimes.get(table);
|
||||
if (!times || times.length === 0) return 0;
|
||||
|
||||
return times.reduce((sum, time) => sum + time, 0) / times.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance metrics for all tables
|
||||
*/
|
||||
getMetrics(): Record<string, { avg: number; max: number; count: number }> {
|
||||
const metrics: Record<string, { avg: number; max: number; count: number }> = {};
|
||||
|
||||
for (const [table, times] of this.queryTimes.entries()) {
|
||||
if (times.length === 0) continue;
|
||||
|
||||
metrics[table] = {
|
||||
avg: times.reduce((sum, time) => sum + time, 0) / times.length,
|
||||
max: Math.max(...times),
|
||||
count: times.length
|
||||
};
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor API endpoint performance
|
||||
*/
|
||||
export class APIMonitor {
|
||||
private static metrics: Map<string, { times: number[]; errors: number }> = new Map();
|
||||
|
||||
/**
|
||||
* Track API response time
|
||||
*/
|
||||
static trackEndpoint(endpoint: string, method: string, duration: number, statusCode: number) {
|
||||
const key = `${method} ${endpoint}`;
|
||||
|
||||
if (!this.metrics.has(key)) {
|
||||
this.metrics.set(key, { times: [], errors: 0 });
|
||||
}
|
||||
|
||||
const metric = this.metrics.get(key)!;
|
||||
metric.times.push(duration);
|
||||
|
||||
if (statusCode >= 400) {
|
||||
metric.errors++;
|
||||
}
|
||||
|
||||
// Clean up old metrics
|
||||
if (metric.times.length > 100) {
|
||||
metric.times.splice(0, metric.times.length - 100);
|
||||
}
|
||||
|
||||
// Log slow API calls
|
||||
if (duration > 5000) { // API calls over 5 seconds
|
||||
console.warn(`Slow API call: ${key} took ${duration}ms`);
|
||||
addBreadcrumb(`Slow API call: ${key}`, 'http', 'warning', {
|
||||
duration,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API performance metrics
|
||||
*/
|
||||
static getMetrics(): Record<string, { avg: number; max: number; count: number; errorRate: number }> {
|
||||
const metrics: Record<string, { avg: number; max: number; count: number; errorRate: number }> = {};
|
||||
|
||||
for (const [endpoint, data] of this.metrics.entries()) {
|
||||
if (data.times.length === 0) continue;
|
||||
|
||||
metrics[endpoint] = {
|
||||
avg: data.times.reduce((sum, time) => sum + time, 0) / data.times.length,
|
||||
max: Math.max(...data.times),
|
||||
count: data.times.length,
|
||||
errorRate: data.errors / data.times.length
|
||||
};
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory usage monitoring
|
||||
*/
|
||||
export class MemoryMonitor {
|
||||
private static lastCheck = Date.now();
|
||||
private static samples: Array<{ timestamp: number; usage: NodeJS.MemoryUsage }> = [];
|
||||
|
||||
/**
|
||||
* Take a memory usage sample
|
||||
*/
|
||||
static sample() {
|
||||
const now = Date.now();
|
||||
const usage = process.memoryUsage();
|
||||
|
||||
this.samples.push({ timestamp: now, usage });
|
||||
|
||||
// Keep only last 100 samples
|
||||
if (this.samples.length > 100) {
|
||||
this.samples.splice(0, this.samples.length - 100);
|
||||
}
|
||||
|
||||
// Log memory warning if usage is high
|
||||
const heapUsedMB = usage.heapUsed / 1024 / 1024;
|
||||
if (heapUsedMB > 512) { // Over 512MB
|
||||
console.warn(`High memory usage: ${heapUsedMB.toFixed(2)}MB`);
|
||||
addBreadcrumb(`High memory usage: ${heapUsedMB.toFixed(2)}MB`, 'memory', 'warning', {
|
||||
heapUsed: usage.heapUsed,
|
||||
heapTotal: usage.heapTotal,
|
||||
external: usage.external
|
||||
});
|
||||
}
|
||||
|
||||
this.lastCheck = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory usage trends
|
||||
*/
|
||||
static getTrends(): {
|
||||
current: NodeJS.MemoryUsage;
|
||||
average: Partial<NodeJS.MemoryUsage>;
|
||||
peak: Partial<NodeJS.MemoryUsage>;
|
||||
} {
|
||||
if (this.samples.length === 0) {
|
||||
return {
|
||||
current: process.memoryUsage(),
|
||||
average: {},
|
||||
peak: {}
|
||||
};
|
||||
}
|
||||
|
||||
const current = this.samples[this.samples.length - 1].usage;
|
||||
|
||||
// Calculate averages
|
||||
const avgHeapUsed = this.samples.reduce((sum, s) => sum + s.usage.heapUsed, 0) / this.samples.length;
|
||||
const avgHeapTotal = this.samples.reduce((sum, s) => sum + s.usage.heapTotal, 0) / this.samples.length;
|
||||
|
||||
// Find peaks
|
||||
const peakHeapUsed = Math.max(...this.samples.map(s => s.usage.heapUsed));
|
||||
const peakHeapTotal = Math.max(...this.samples.map(s => s.usage.heapTotal));
|
||||
|
||||
return {
|
||||
current,
|
||||
average: {
|
||||
heapUsed: avgHeapUsed,
|
||||
heapTotal: avgHeapTotal
|
||||
},
|
||||
peak: {
|
||||
heapUsed: peakHeapUsed,
|
||||
heapTotal: peakHeapTotal
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start automatic memory monitoring
|
||||
*/
|
||||
static startMonitoring(intervalMs: number = 60000) { // Default: every minute
|
||||
setInterval(() => {
|
||||
this.sample();
|
||||
}, intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Vitals monitoring for the frontend
|
||||
*/
|
||||
export const WebVitalsMonitor = {
|
||||
/**
|
||||
* Monitor Core Web Vitals
|
||||
*/
|
||||
initWebVitals() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Monitor Largest Contentful Paint (LCP)
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'largest-contentful-paint') {
|
||||
addBreadcrumb(`LCP: ${entry.startTime.toFixed(2)}ms`, 'performance', 'info');
|
||||
|
||||
if (entry.startTime > 2500) { // LCP > 2.5s is poor
|
||||
console.warn(`Poor LCP: ${entry.startTime.toFixed(2)}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint'] });
|
||||
|
||||
// Monitor First Input Delay (FID)
|
||||
const fidObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'first-input') {
|
||||
const fid = entry.processingStart - entry.startTime;
|
||||
addBreadcrumb(`FID: ${fid.toFixed(2)}ms`, 'performance', 'info');
|
||||
|
||||
if (fid > 100) { // FID > 100ms is poor
|
||||
console.warn(`Poor FID: ${fid.toFixed(2)}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fidObserver.observe({ entryTypes: ['first-input'] });
|
||||
|
||||
// Monitor Cumulative Layout Shift (CLS)
|
||||
let clsValue = 0;
|
||||
const clsObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (clsValue > 0.1) { // CLS > 0.1 is poor
|
||||
console.warn(`Poor CLS: ${clsValue.toFixed(4)}`);
|
||||
}
|
||||
});
|
||||
|
||||
clsObserver.observe({ entryTypes: ['layout-shift'] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Monitor page load performance
|
||||
*/
|
||||
trackPageLoad() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
|
||||
const metrics = {
|
||||
domContentLoaded: perfData.domContentLoadedEventEnd - perfData.domContentLoadedEventStart,
|
||||
domComplete: perfData.domComplete - perfData.navigationStart,
|
||||
loadComplete: perfData.loadEventEnd - perfData.navigationStart,
|
||||
firstByte: perfData.responseStart - perfData.requestStart
|
||||
};
|
||||
|
||||
addBreadcrumb('Page load metrics', 'performance', 'info', metrics);
|
||||
|
||||
// Log slow page loads
|
||||
if (metrics.loadComplete > 3000) { // Over 3 seconds
|
||||
console.warn(`Slow page load: ${metrics.loadComplete}ms`);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
export function measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
||||
const monitor = new PerformanceMonitor(name, 'async');
|
||||
|
||||
return fn()
|
||||
.then(result => {
|
||||
monitor.end();
|
||||
return result;
|
||||
})
|
||||
.catch(error => {
|
||||
monitor.end();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
export function measureSync<T>(name: string, fn: () => T): T {
|
||||
const monitor = new PerformanceMonitor(name, 'sync');
|
||||
|
||||
try {
|
||||
const result = fn();
|
||||
monitor.end();
|
||||
return result;
|
||||
} catch (error) {
|
||||
monitor.end();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Start memory monitoring automatically
|
||||
MemoryMonitor.startMonitoring();
|
||||
|
||||
// Export all monitors
|
||||
export { DatabaseMonitor, APIMonitor, MemoryMonitor };
|
||||
202
src/lib/qr.ts
Normal file
202
src/lib/qr.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export interface TicketData {
|
||||
uuid: string;
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
purchaserName: string;
|
||||
purchaserEmail: string;
|
||||
venue: string;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export async function generateQRCode(ticketData: TicketData): Promise<string> {
|
||||
try {
|
||||
// Create QR code data URL
|
||||
const qrData = JSON.stringify({
|
||||
uuid: ticketData.uuid,
|
||||
eventId: ticketData.eventId,
|
||||
type: 'ticket'
|
||||
});
|
||||
|
||||
const qrCodeDataURL = await QRCode.toDataURL(qrData, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#1F2937', // Dark gray
|
||||
light: '#FFFFFF' // White
|
||||
}
|
||||
});
|
||||
|
||||
return qrCodeDataURL;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
throw new Error('Failed to generate QR code');
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateTicketHTML(ticketData: TicketData): Promise<string> {
|
||||
const qrCodeDataURL = await generateQRCode(ticketData);
|
||||
|
||||
const ticketHTML = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Your Ticket - ${ticketData.eventTitle}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
.ticket {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.ticket-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ticket-header p {
|
||||
margin: 8px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ticket-body {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
.ticket-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.info-item {
|
||||
text-align: center;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.qr-section {
|
||||
text-align: center;
|
||||
border-top: 2px dashed #e5e7eb;
|
||||
padding-top: 32px;
|
||||
}
|
||||
.qr-code {
|
||||
margin: 0 auto 16px;
|
||||
display: block;
|
||||
}
|
||||
.qr-instructions {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ticket-footer {
|
||||
background: #f9fafb;
|
||||
padding: 16px 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.ticket-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ticket">
|
||||
<div class="ticket-header">
|
||||
<h1>${ticketData.eventTitle}</h1>
|
||||
<p>Your ticket confirmation</p>
|
||||
</div>
|
||||
|
||||
<div class="ticket-body">
|
||||
<div class="ticket-info">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Event Date & Time</div>
|
||||
<div class="info-value">${new Date(ticketData.startTime).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}</div>
|
||||
<div class="info-value">${new Date(ticketData.startTime).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Venue</div>
|
||||
<div class="info-value">${ticketData.venue}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Ticket Holder</div>
|
||||
<div class="info-value">${ticketData.purchaserName}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Ticket ID</div>
|
||||
<div class="info-value">${ticketData.uuid.substring(0, 8).toUpperCase()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
<img src="${qrCodeDataURL}" alt="Ticket QR Code" class="qr-code" />
|
||||
<div class="qr-instructions">
|
||||
<strong>Show this QR code at the door</strong><br>
|
||||
Keep this email handy or take a screenshot for easy access.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ticket-footer">
|
||||
Powered by Black Canyon Tickets • Questions? Contact the event organizer
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return ticketHTML;
|
||||
}
|
||||
|
||||
export function parseQRCode(qrData: string): { uuid: string; eventId: string; type: string } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(qrData);
|
||||
if (parsed.type === 'ticket' && parsed.uuid && parsed.eventId) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error parsing QR code:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
79
src/lib/scanner-lock.ts
Normal file
79
src/lib/scanner-lock.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
export interface ScannerLockData {
|
||||
eventId: string;
|
||||
pin: string;
|
||||
organizerEmail: string;
|
||||
eventTitle: string;
|
||||
eventStartTime: string;
|
||||
}
|
||||
|
||||
export interface UnlockAttemptData {
|
||||
eventId: string;
|
||||
pin: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
deviceInfo?: string;
|
||||
}
|
||||
|
||||
export async function hashPin(pin: string): Promise<string> {
|
||||
if (!pin || pin.length !== 4 || !/^\d{4}$/.test(pin)) {
|
||||
throw new Error('PIN must be exactly 4 digits');
|
||||
}
|
||||
|
||||
return await bcrypt.hash(pin, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPin(pin: string, hash: string): Promise<boolean> {
|
||||
if (!pin || pin.length !== 4 || !/^\d{4}$/.test(pin)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await bcrypt.compare(pin, hash);
|
||||
} catch (error) {
|
||||
console.error('PIN verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRandomPin(): string {
|
||||
return Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
}
|
||||
|
||||
export function validatePin(pin: string): boolean {
|
||||
return /^\d{4}$/.test(pin);
|
||||
}
|
||||
|
||||
export function getDeviceInfo(userAgent?: string): string {
|
||||
if (!userAgent) return 'Unknown device';
|
||||
|
||||
const device = userAgent.includes('Mobile') ? 'Mobile' : 'Desktop';
|
||||
const browser = userAgent.includes('Chrome') ? 'Chrome' :
|
||||
userAgent.includes('Firefox') ? 'Firefox' :
|
||||
userAgent.includes('Safari') ? 'Safari' : 'Unknown';
|
||||
|
||||
return `${device} - ${browser}`;
|
||||
}
|
||||
|
||||
export interface ScannerLockConfig {
|
||||
lockTimeoutMinutes?: number;
|
||||
maxUnlockAttempts?: number;
|
||||
lockoutDurationMinutes?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCANNER_LOCK_CONFIG: ScannerLockConfig = {
|
||||
lockTimeoutMinutes: 1440, // 24 hours
|
||||
maxUnlockAttempts: 5,
|
||||
lockoutDurationMinutes: 15
|
||||
};
|
||||
|
||||
export function shouldLockExpire(createdAt: string, config: ScannerLockConfig = DEFAULT_SCANNER_LOCK_CONFIG): boolean {
|
||||
const lockTime = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const expirationTime = new Date(lockTime.getTime() + (config.lockTimeoutMinutes! * 60 * 1000));
|
||||
|
||||
return now > expirationTime;
|
||||
}
|
||||
276
src/lib/sentry.ts
Normal file
276
src/lib/sentry.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
// Sentry configuration
|
||||
export const SENTRY_CONFIG = {
|
||||
DSN: process.env.SENTRY_DSN,
|
||||
ENVIRONMENT: process.env.NODE_ENV || 'development',
|
||||
RELEASE: process.env.SENTRY_RELEASE || 'unknown',
|
||||
SAMPLE_RATE: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||
TRACES_SAMPLE_RATE: process.env.NODE_ENV === 'production' ? 0.1 : 1.0
|
||||
};
|
||||
|
||||
// Initialize Sentry
|
||||
if (SENTRY_CONFIG.DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_CONFIG.DSN,
|
||||
environment: SENTRY_CONFIG.ENVIRONMENT,
|
||||
release: SENTRY_CONFIG.RELEASE,
|
||||
sampleRate: SENTRY_CONFIG.SAMPLE_RATE,
|
||||
tracesSampleRate: SENTRY_CONFIG.TRACES_SAMPLE_RATE,
|
||||
|
||||
// Configure integrations
|
||||
integrations: [
|
||||
// HTTP integration for tracking HTTP requests
|
||||
new Sentry.Integrations.Http({ tracing: true }),
|
||||
|
||||
// Express integration if using Express
|
||||
// new Sentry.Integrations.Express({ app }),
|
||||
|
||||
// Database integration
|
||||
new Sentry.Integrations.Postgres(),
|
||||
],
|
||||
|
||||
// Configure beforeSend to filter sensitive data
|
||||
beforeSend(event, hint) {
|
||||
// Filter out sensitive information
|
||||
if (event.request) {
|
||||
// Remove sensitive headers
|
||||
if (event.request.headers) {
|
||||
delete event.request.headers['authorization'];
|
||||
delete event.request.headers['cookie'];
|
||||
delete event.request.headers['x-api-key'];
|
||||
}
|
||||
|
||||
// Remove sensitive query parameters
|
||||
if (event.request.query_string) {
|
||||
const sensitiveParams = ['password', 'token', 'key', 'secret'];
|
||||
for (const param of sensitiveParams) {
|
||||
if (event.request.query_string.includes(param)) {
|
||||
event.request.query_string = event.request.query_string.replace(
|
||||
new RegExp(`${param}=[^&]*`, 'gi'),
|
||||
`${param}=[FILTERED]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out sensitive data from breadcrumbs
|
||||
if (event.breadcrumbs) {
|
||||
event.breadcrumbs = event.breadcrumbs.map(breadcrumb => {
|
||||
if (breadcrumb.data) {
|
||||
const filteredData = { ...breadcrumb.data };
|
||||
for (const key in filteredData) {
|
||||
if (key.toLowerCase().includes('password') ||
|
||||
key.toLowerCase().includes('token') ||
|
||||
key.toLowerCase().includes('key') ||
|
||||
key.toLowerCase().includes('secret')) {
|
||||
filteredData[key] = '[FILTERED]';
|
||||
}
|
||||
}
|
||||
breadcrumb.data = filteredData;
|
||||
}
|
||||
return breadcrumb;
|
||||
});
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
// Configure error filtering
|
||||
beforeBreadcrumb(breadcrumb, hint) {
|
||||
// Filter out noisy breadcrumbs
|
||||
if (breadcrumb.category === 'console' && breadcrumb.level === 'log') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Sentry initialized successfully');
|
||||
} else {
|
||||
console.warn('Sentry DSN not configured. Error monitoring disabled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture an exception with additional context
|
||||
*/
|
||||
export function captureException(error: Error, context?: {
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
requestId?: string;
|
||||
additionalData?: Record<string, any>;
|
||||
}) {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
console.error('Sentry not configured, logging error locally:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
if (context?.userId) {
|
||||
scope.setUser({ id: context.userId, email: context.userEmail });
|
||||
}
|
||||
|
||||
if (context?.requestId) {
|
||||
scope.setTag('requestId', context.requestId);
|
||||
}
|
||||
|
||||
if (context?.additionalData) {
|
||||
scope.setContext('additional', context.additionalData);
|
||||
}
|
||||
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a message with additional context
|
||||
*/
|
||||
export function captureMessage(message: string, level: 'fatal' | 'error' | 'warning' | 'info' | 'debug' = 'info', context?: {
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
requestId?: string;
|
||||
additionalData?: Record<string, any>;
|
||||
}) {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
console.log('Sentry not configured, logging message locally:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
if (context?.userId) {
|
||||
scope.setUser({ id: context.userId, email: context.userEmail });
|
||||
}
|
||||
|
||||
if (context?.requestId) {
|
||||
scope.setTag('requestId', context.requestId);
|
||||
}
|
||||
|
||||
if (context?.additionalData) {
|
||||
scope.setContext('additional', context.additionalData);
|
||||
}
|
||||
|
||||
Sentry.captureMessage(message, level);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track performance transactions
|
||||
*/
|
||||
export function startTransaction(name: string, operation: string = 'http') {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Sentry.startTransaction({
|
||||
name,
|
||||
op: operation
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user context for current scope
|
||||
*/
|
||||
export function setUserContext(userId: string, userEmail?: string, userData?: Record<string, any>) {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.setUser({
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
...userData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set additional context
|
||||
*/
|
||||
export function setContext(key: string, context: Record<string, any>) {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.setContext(key, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for debugging
|
||||
*/
|
||||
export function addBreadcrumb(message: string, category: string = 'custom', level: 'fatal' | 'error' | 'warning' | 'info' | 'debug' = 'info', data?: Record<string, any>) {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.addBreadcrumb({
|
||||
message,
|
||||
category,
|
||||
level,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush Sentry (useful for serverless environments)
|
||||
*/
|
||||
export async function flush(timeout: number = 2000): Promise<boolean> {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await Sentry.flush(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for API routes
|
||||
*/
|
||||
export function withSentry<T extends (...args: any[]) => any>(fn: T): T {
|
||||
return ((...args: any[]) => {
|
||||
try {
|
||||
const result = fn(...args);
|
||||
|
||||
// Handle async functions
|
||||
if (result && typeof result.catch === 'function') {
|
||||
return result.catch((error: Error) => {
|
||||
captureException(error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
captureException(error);
|
||||
throw error;
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware for Sentry (if needed)
|
||||
*/
|
||||
export function sentryRequestHandler() {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
return (req: any, res: any, next: any) => next();
|
||||
}
|
||||
|
||||
return Sentry.Handlers.requestHandler();
|
||||
}
|
||||
|
||||
export function sentryErrorHandler() {
|
||||
if (!SENTRY_CONFIG.DSN) {
|
||||
return (error: any, req: any, res: any, next: any) => next(error);
|
||||
}
|
||||
|
||||
return Sentry.Handlers.errorHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for Sentry
|
||||
*/
|
||||
export function healthCheck(): boolean {
|
||||
return !!SENTRY_CONFIG.DSN;
|
||||
}
|
||||
|
||||
// Export Sentry instance for direct use
|
||||
export { Sentry };
|
||||
266
src/lib/stripe.ts
Normal file
266
src/lib/stripe.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
// Stripe configuration for Connect integration
|
||||
export const STRIPE_CONFIG = {
|
||||
// Stripe Connect settings
|
||||
CONNECT_CLIENT_ID: import.meta.env.STRIPE_CONNECT_CLIENT_ID,
|
||||
PUBLISHABLE_KEY: import.meta.env.STRIPE_PUBLISHABLE_KEY,
|
||||
SECRET_KEY: import.meta.env.STRIPE_SECRET_KEY,
|
||||
WEBHOOK_SECRET: import.meta.env.STRIPE_WEBHOOK_SECRET,
|
||||
};
|
||||
|
||||
// Validate required environment variables (only warn in development)
|
||||
if (!STRIPE_CONFIG.SECRET_KEY && typeof window === 'undefined') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Missing STRIPE_SECRET_KEY environment variable - Stripe functionality will be disabled');
|
||||
}
|
||||
}
|
||||
|
||||
if (!STRIPE_CONFIG.PUBLISHABLE_KEY) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Missing STRIPE_PUBLISHABLE_KEY environment variable - Stripe functionality will be disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Stripe instance (server-side only)
|
||||
export const stripe = typeof window === 'undefined' && STRIPE_CONFIG.SECRET_KEY
|
||||
? new Stripe(STRIPE_CONFIG.SECRET_KEY, {
|
||||
apiVersion: '2024-06-20'
|
||||
})
|
||||
: null;
|
||||
|
||||
// Fee structure types
|
||||
export type FeeType = 'percentage' | 'fixed' | 'percentage_plus_fixed';
|
||||
export type FeeModel = 'customer_pays' | 'absorbed_in_price';
|
||||
|
||||
export interface FeeStructure {
|
||||
fee_type: FeeType;
|
||||
fee_percentage: number; // decimal (0.03 = 3%)
|
||||
fee_fixed: number; // cents
|
||||
fee_model: FeeModel;
|
||||
absorb_fee_in_price: boolean;
|
||||
}
|
||||
|
||||
// Default BCT platform fee structure
|
||||
export const DEFAULT_FEE_STRUCTURE: FeeStructure = {
|
||||
fee_type: 'percentage_plus_fixed',
|
||||
fee_percentage: 0.025, // 2.5% BCT platform fee
|
||||
fee_fixed: 150, // $1.50 BCT platform fee
|
||||
fee_model: 'customer_pays',
|
||||
absorb_fee_in_price: false,
|
||||
};
|
||||
|
||||
// Stripe processing fee structure (for total cost calculation)
|
||||
export const STRIPE_FEE_STRUCTURE: FeeStructure = {
|
||||
fee_type: 'percentage_plus_fixed',
|
||||
fee_percentage: 0.0299, // 2.99% Stripe fee
|
||||
fee_fixed: 30, // $0.30 Stripe fee
|
||||
fee_model: 'customer_pays',
|
||||
absorb_fee_in_price: false,
|
||||
};
|
||||
|
||||
// Calculate platform fee for a given ticket price and fee structure
|
||||
export function calculatePlatformFee(ticketPrice: number, feeStructure?: FeeStructure): number {
|
||||
const priceInCents = Math.round(ticketPrice * 100);
|
||||
const fees = feeStructure || DEFAULT_FEE_STRUCTURE;
|
||||
|
||||
let fee = 0;
|
||||
|
||||
switch (fees.fee_type) {
|
||||
case 'percentage':
|
||||
fee = Math.round(priceInCents * fees.fee_percentage);
|
||||
break;
|
||||
case 'fixed':
|
||||
fee = fees.fee_fixed;
|
||||
break;
|
||||
case 'percentage_plus_fixed':
|
||||
fee = Math.round(priceInCents * fees.fee_percentage) + fees.fee_fixed;
|
||||
break;
|
||||
default:
|
||||
fee = Math.round(priceInCents * DEFAULT_FEE_STRUCTURE.fee_percentage) + DEFAULT_FEE_STRUCTURE.fee_fixed;
|
||||
}
|
||||
|
||||
return Math.max(0, fee); // Ensure fee is never negative
|
||||
}
|
||||
|
||||
// Calculate net amount organizer receives
|
||||
export function calculateOrganizerNet(ticketPrice: number, feeStructure?: FeeStructure): number {
|
||||
const priceInCents = Math.round(ticketPrice * 100);
|
||||
const fee = calculatePlatformFee(ticketPrice, feeStructure);
|
||||
return Math.max(0, priceInCents - fee); // Ensure net is never negative
|
||||
}
|
||||
|
||||
// Format fee structure for display
|
||||
export function formatFeeStructure(feeStructure: FeeStructure): string {
|
||||
switch (feeStructure.fee_type) {
|
||||
case 'percentage':
|
||||
return `${(feeStructure.fee_percentage * 100).toFixed(2)}%`;
|
||||
case 'fixed':
|
||||
return `$${(feeStructure.fee_fixed / 100).toFixed(2)}`;
|
||||
case 'percentage_plus_fixed':
|
||||
return `${(feeStructure.fee_percentage * 100).toFixed(2)}% + $${(feeStructure.fee_fixed / 100).toFixed(2)}`;
|
||||
default:
|
||||
return 'Unknown fee structure';
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the display price shown to customers
|
||||
export function calculateDisplayPrice(ticketPrice: number, feeStructure?: FeeStructure): number {
|
||||
const fees = feeStructure || DEFAULT_FEE_STRUCTURE;
|
||||
|
||||
if (fees.fee_model === 'absorbed_in_price') {
|
||||
// If fee is absorbed, the display price includes the platform fee
|
||||
// to maintain the same organizer net, we need to add the fee to the display price
|
||||
const platformFee = calculatePlatformFee(ticketPrice, feeStructure);
|
||||
return Math.round(ticketPrice * 100) + platformFee;
|
||||
} else {
|
||||
// Customer pays fee separately, so display price is just the base ticket price
|
||||
return Math.round(ticketPrice * 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total amount customer actually pays
|
||||
export function calculateCustomerTotal(ticketPrice: number, feeStructure?: FeeStructure): number {
|
||||
const fees = feeStructure || DEFAULT_FEE_STRUCTURE;
|
||||
const priceInCents = Math.round(ticketPrice * 100);
|
||||
|
||||
if (fees.fee_model === 'absorbed_in_price') {
|
||||
// Customer pays only the display price (fee is included)
|
||||
return calculateDisplayPrice(ticketPrice, feeStructure);
|
||||
} else {
|
||||
// Customer pays base price + platform fee
|
||||
const platformFee = calculatePlatformFee(ticketPrice, feeStructure);
|
||||
return priceInCents + platformFee;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Stripe processing fee separately
|
||||
export function calculateStripeFee(amount: number): number {
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
return Math.round(amountInCents * STRIPE_FEE_STRUCTURE.fee_percentage) + STRIPE_FEE_STRUCTURE.fee_fixed;
|
||||
}
|
||||
|
||||
// Calculate complete transaction breakdown including BCT and Stripe fees
|
||||
export function calculateCompleteTransactionBreakdown(ticketPrice: number, quantity: number, feeStructure?: FeeStructure) {
|
||||
const fees = feeStructure || DEFAULT_FEE_STRUCTURE;
|
||||
const bctFeePerTicket = calculatePlatformFee(ticketPrice, feeStructure);
|
||||
const customerTotalPerTicket = calculateCustomerTotal(ticketPrice, feeStructure);
|
||||
const totalCustomerPays = customerTotalPerTicket * quantity;
|
||||
|
||||
// Calculate Stripe fee on the total amount customer pays
|
||||
const stripeFeeTotal = calculateStripeFee(totalCustomerPays / 100);
|
||||
|
||||
// Calculate what organizer actually receives after both BCT and Stripe fees
|
||||
const bctFeeTotal = bctFeePerTicket * quantity;
|
||||
const organizerGrossRevenue = (Math.round(ticketPrice * 100) * quantity);
|
||||
const organizerNetAfterBCT = organizerGrossRevenue - bctFeeTotal;
|
||||
const organizerNetAfterAllFees = organizerNetAfterBCT - stripeFeeTotal;
|
||||
|
||||
return {
|
||||
// Customer perspective
|
||||
ticketPricePerTicket: Math.round(ticketPrice * 100),
|
||||
bctFeePerTicket: bctFeePerTicket,
|
||||
customerTotalPerTicket: customerTotalPerTicket,
|
||||
totalCustomerPays: totalCustomerPays,
|
||||
|
||||
// Breakdown for quantity
|
||||
subtotalBeforeFees: organizerGrossRevenue,
|
||||
bctFeeTotal: bctFeeTotal,
|
||||
stripeFeeTotal: stripeFeeTotal,
|
||||
|
||||
// Organizer perspective
|
||||
organizerGrossRevenue: organizerGrossRevenue,
|
||||
organizerNetAfterBCT: organizerNetAfterBCT,
|
||||
organizerNetAfterAllFees: organizerNetAfterAllFees,
|
||||
|
||||
// Fee model info
|
||||
feeModel: fees.fee_model,
|
||||
feeAbsorbed: fees.absorb_fee_in_price,
|
||||
|
||||
// Formatted strings
|
||||
ticketPricePerTicketFormatted: `$${(Math.round(ticketPrice * 100) / 100).toFixed(2)}`,
|
||||
bctFeePerTicketFormatted: `$${(bctFeePerTicket / 100).toFixed(2)}`,
|
||||
customerTotalPerTicketFormatted: `$${(customerTotalPerTicket / 100).toFixed(2)}`,
|
||||
totalCustomerPaysFormatted: `$${(totalCustomerPays / 100).toFixed(2)}`,
|
||||
subtotalBeforeFeesFormatted: `$${(organizerGrossRevenue / 100).toFixed(2)}`,
|
||||
bctFeeTotalFormatted: `$${(bctFeeTotal / 100).toFixed(2)}`,
|
||||
stripeFeeTotalFormatted: `$${(stripeFeeTotal / 100).toFixed(2)}`,
|
||||
organizerGrossRevenueFormatted: `$${(organizerGrossRevenue / 100).toFixed(2)}`,
|
||||
organizerNetAfterBCTFormatted: `$${(organizerNetAfterBCT / 100).toFixed(2)}`,
|
||||
organizerNetAfterAllFeesFormatted: `$${(organizerNetAfterAllFees / 100).toFixed(2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate fee breakdown for display (legacy function, kept for compatibility)
|
||||
export function calculateFeeBreakdown(ticketPrice: number, quantity: number, feeStructure?: FeeStructure) {
|
||||
const fees = feeStructure || DEFAULT_FEE_STRUCTURE;
|
||||
const subtotal = ticketPrice * quantity;
|
||||
const subtotalCents = Math.round(subtotal * 100);
|
||||
const platformFeePerTicket = calculatePlatformFee(ticketPrice, feeStructure);
|
||||
const totalPlatformFee = platformFeePerTicket * quantity;
|
||||
const organizerNetPerTicket = calculateOrganizerNet(ticketPrice, feeStructure);
|
||||
const totalOrganizerNet = organizerNetPerTicket * quantity;
|
||||
const displayPricePerTicket = calculateDisplayPrice(ticketPrice, feeStructure);
|
||||
const totalDisplayPrice = displayPricePerTicket * quantity;
|
||||
const customerTotalPerTicket = calculateCustomerTotal(ticketPrice, feeStructure);
|
||||
const totalCustomerPays = customerTotalPerTicket * quantity;
|
||||
|
||||
return {
|
||||
// Base amounts
|
||||
subtotal: subtotalCents,
|
||||
platformFeePerTicket,
|
||||
totalPlatformFee,
|
||||
organizerNetPerTicket,
|
||||
totalOrganizerNet,
|
||||
|
||||
// Display and customer totals
|
||||
displayPricePerTicket,
|
||||
totalDisplayPrice,
|
||||
customerTotalPerTicket,
|
||||
totalCustomerPays,
|
||||
|
||||
// Fee model info
|
||||
feeModel: fees.fee_model,
|
||||
feeAbsorbed: fees.absorb_fee_in_price,
|
||||
|
||||
// Formatted strings
|
||||
subtotalFormatted: `$${(subtotalCents / 100).toFixed(2)}`,
|
||||
platformFeePerTicketFormatted: `$${(platformFeePerTicket / 100).toFixed(2)}`,
|
||||
totalPlatformFeeFormatted: `$${(totalPlatformFee / 100).toFixed(2)}`,
|
||||
organizerNetPerTicketFormatted: `$${(organizerNetPerTicket / 100).toFixed(2)}`,
|
||||
totalOrganizerNetFormatted: `$${(totalOrganizerNet / 100).toFixed(2)}`,
|
||||
displayPricePerTicketFormatted: `$${(displayPricePerTicket / 100).toFixed(2)}`,
|
||||
totalDisplayPriceFormatted: `$${(totalDisplayPrice / 100).toFixed(2)}`,
|
||||
customerTotalPerTicketFormatted: `$${(customerTotalPerTicket / 100).toFixed(2)}`,
|
||||
totalCustomerPaysFormatted: `$${(totalCustomerPays / 100).toFixed(2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate Stripe Connect onboarding URL
|
||||
export function generateConnectOnboardingUrl(organizationId: string): string {
|
||||
if (!STRIPE_CONFIG.CONNECT_CLIENT_ID) {
|
||||
throw new Error('Stripe Connect not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: STRIPE_CONFIG.CONNECT_CLIENT_ID,
|
||||
state: organizationId,
|
||||
scope: 'read_write',
|
||||
response_type: 'code',
|
||||
'stripe_user[email]': '', // Will be filled by the form
|
||||
'stripe_user[url]': 'https://portal.blackcanyontickets.com',
|
||||
'stripe_user[country]': 'US',
|
||||
'stripe_user[business_type]': 'individual', // or 'company'
|
||||
});
|
||||
|
||||
return `https://connect.stripe.com/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Types for Stripe Connect
|
||||
export interface StripeConnectAccount {
|
||||
id: string;
|
||||
email: string;
|
||||
details_submitted: boolean;
|
||||
charges_enabled: boolean;
|
||||
payouts_enabled: boolean;
|
||||
}
|
||||
13
src/lib/supabase.ts
Normal file
13
src/lib/supabase.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { Database } from './database.types'
|
||||
|
||||
// Use PUBLIC_ prefixed variables for client-side, fallback to server-side for SSR
|
||||
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || import.meta.env.SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY || import.meta.env.SUPABASE_ANON_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Missing required Supabase environment variables. Make sure SUPABASE_URL and SUPABASE_ANON_KEY are set.')
|
||||
}
|
||||
|
||||
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey)
|
||||
|
||||
113
src/lib/validation.ts
Normal file
113
src/lib/validation.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common validation schemas
|
||||
export const uuidSchema = z.string().uuid();
|
||||
export const emailSchema = z.string().email();
|
||||
export const positiveIntSchema = z.number().int().positive();
|
||||
export const nonNegativeIntSchema = z.number().int().min(0);
|
||||
|
||||
// User authentication schemas
|
||||
export const signInSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: z.string().min(8).max(128)
|
||||
});
|
||||
|
||||
export const signUpSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: z.string().min(8).max(128),
|
||||
name: z.string().min(1).max(100),
|
||||
organizationName: z.string().min(1).max(100).optional()
|
||||
});
|
||||
|
||||
// Event management schemas
|
||||
export const eventSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(5000).optional(),
|
||||
venue: z.string().min(1).max(200),
|
||||
startTime: z.string().datetime(),
|
||||
endTime: z.string().datetime().optional(),
|
||||
organizationId: uuidSchema
|
||||
});
|
||||
|
||||
// Ticket schemas
|
||||
export const ticketTypeSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
price: nonNegativeIntSchema,
|
||||
quantity: positiveIntSchema,
|
||||
eventId: uuidSchema
|
||||
});
|
||||
|
||||
export const purchaseAttemptSchema = z.object({
|
||||
eventId: uuidSchema,
|
||||
purchaserEmail: emailSchema,
|
||||
purchaserName: z.string().min(1).max(100),
|
||||
items: z.array(z.object({
|
||||
ticketTypeId: uuidSchema,
|
||||
quantity: positiveIntSchema
|
||||
})).min(1),
|
||||
totalAmount: positiveIntSchema
|
||||
});
|
||||
|
||||
export const completePurchaseSchema = z.object({
|
||||
purchaseAttemptId: uuidSchema,
|
||||
stripePaymentIntentId: z.string().min(1)
|
||||
});
|
||||
|
||||
// Refund schemas
|
||||
export const refundSchema = z.object({
|
||||
ticketId: uuidSchema,
|
||||
amount: positiveIntSchema,
|
||||
reason: z.string().min(1).max(500)
|
||||
});
|
||||
|
||||
// Admin schemas
|
||||
export const adminActionSchema = z.object({
|
||||
action: z.enum(['create', 'update', 'delete', 'view']),
|
||||
resourceType: z.string().min(1).max(50),
|
||||
resourceId: uuidSchema.optional(),
|
||||
details: z.record(z.any()).optional()
|
||||
});
|
||||
|
||||
// Inventory schemas
|
||||
export const inventoryReserveSchema = z.object({
|
||||
eventId: uuidSchema,
|
||||
items: z.array(z.object({
|
||||
ticketTypeId: uuidSchema,
|
||||
quantity: positiveIntSchema
|
||||
})).min(1)
|
||||
});
|
||||
|
||||
// Validation helper function
|
||||
export function validateRequest<T>(schema: z.ZodSchema<T>, data: unknown): { success: true; data: T } | { success: false; error: string } {
|
||||
try {
|
||||
const result = schema.parse(data);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const firstError = error.errors[0];
|
||||
return {
|
||||
success: false,
|
||||
error: `${firstError.path.join('.')}: ${firstError.message}`
|
||||
};
|
||||
}
|
||||
return { success: false, error: 'Invalid request data' };
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitization helpers
|
||||
export function sanitizeString(str: string): string {
|
||||
return str.trim().replace(/[<>]/g, '');
|
||||
}
|
||||
|
||||
export function sanitizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
// Basic HTML sanitization - remove script tags and dangerous attributes
|
||||
return html
|
||||
.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
||||
.replace(/on\w+="[^"]*"/gi, '')
|
||||
.replace(/on\w+='[^']*'/gi, '')
|
||||
.replace(/javascript:/gi, '');
|
||||
}
|
||||
68
src/middleware.ts
Normal file
68
src/middleware.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { defineMiddleware } from 'astro/middleware';
|
||||
|
||||
export const onRequest = defineMiddleware((context, next) => {
|
||||
// Security headers
|
||||
const securityHeaders = {
|
||||
// HTTPS enforcement
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
|
||||
|
||||
// XSS protection
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
|
||||
// Content type sniffing protection
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
|
||||
// Frame options (clickjacking protection)
|
||||
'X-Frame-Options': 'DENY',
|
||||
|
||||
// Referrer policy
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
|
||||
// Content Security Policy
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://m.stripe.network",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: https: blob:",
|
||||
"connect-src 'self' https://api.stripe.com https://*.supabase.co wss://*.supabase.co",
|
||||
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com",
|
||||
"form-action 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'"
|
||||
].join('; '),
|
||||
|
||||
// Permissions policy
|
||||
'Permissions-Policy': [
|
||||
'camera=(),',
|
||||
'microphone=(),',
|
||||
'geolocation=(),',
|
||||
'payment=(self "https://js.stripe.com")',
|
||||
'usb=(),',
|
||||
'bluetooth=(),',
|
||||
'magnetometer=(),',
|
||||
'gyroscope=(),',
|
||||
'accelerometer=()'
|
||||
].join(' ')
|
||||
};
|
||||
|
||||
// HTTPS redirect in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const proto = context.request.headers.get('x-forwarded-proto');
|
||||
const host = context.request.headers.get('host');
|
||||
|
||||
if (proto === 'http' && host) {
|
||||
return Response.redirect(`https://${host}${context.url.pathname}${context.url.search}`, 301);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with the request
|
||||
return next().then(response => {
|
||||
// Add security headers to response
|
||||
Object.entries(securityHeaders).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
});
|
||||
223
src/pages/404.astro
Normal file
223
src/pages/404.astro
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import PublicHeader from '../components/PublicHeader.astro';
|
||||
---
|
||||
|
||||
<Layout title="Page Not Found - Black Canyon Tickets">
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30">
|
||||
<PublicHeader />
|
||||
|
||||
<!-- 404 Hero Section -->
|
||||
<section class="relative overflow-hidden min-h-screen flex items-center justify-center">
|
||||
<!-- Animated Background -->
|
||||
<div class="absolute inset-0 opacity-30">
|
||||
<div class="absolute top-1/4 left-1/4 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-1/4 right-1/4 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 right-1/3 w-48 h-48 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full blur-3xl animate-pulse delay-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Elements -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-20 left-20 w-8 h-8 bg-blue-200 rounded-full animate-float opacity-60"></div>
|
||||
<div class="absolute top-40 right-32 w-6 h-6 bg-purple-200 rounded-full animate-float opacity-50" style="animation-delay: 1s;"></div>
|
||||
<div class="absolute bottom-40 left-1/3 w-10 h-10 bg-pink-200 rounded-full animate-float opacity-40" style="animation-delay: 2s;"></div>
|
||||
<div class="absolute bottom-20 right-20 w-12 h-12 bg-cyan-200 rounded-full animate-float opacity-70" style="animation-delay: 1.5s;"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<!-- 404 Illustration -->
|
||||
<div class="mb-12">
|
||||
<div class="relative inline-block">
|
||||
<!-- Large 404 Text with Gradient -->
|
||||
<h1 class="text-[12rem] sm:text-[16rem] lg:text-[20rem] font-black leading-none">
|
||||
<span class="bg-gradient-to-br from-gray-200 via-gray-300 to-gray-400 bg-clip-text text-transparent drop-shadow-2xl">
|
||||
404
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<!-- Floating Calendar Icon -->
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 animate-bounce">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl shadow-2xl flex items-center justify-center transform rotate-12 hover:rotate-0 transition-transform duration-500">
|
||||
<svg class="w-12 h-12 text-white" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-4xl lg:text-6xl font-light text-gray-900 mb-6 tracking-tight">
|
||||
Oops! Event Not Found
|
||||
</h2>
|
||||
<p class="text-xl lg:text-2xl text-gray-600 mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
It seems like this page decided to skip the party. Let's get you back to where the action is.
|
||||
</p>
|
||||
|
||||
<!-- Search Suggestion -->
|
||||
<div class="bg-white/70 backdrop-blur-lg border border-white/50 rounded-2xl p-8 shadow-2xl max-w-lg mx-auto mb-8">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Looking for something specific?</h3>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="error-search"
|
||||
placeholder="Search events..."
|
||||
class="w-full px-4 py-3 pr-12 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
|
||||
/>
|
||||
<button
|
||||
id="error-search-btn"
|
||||
class="absolute right-2 top-2 p-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center mb-12">
|
||||
<a
|
||||
href="/calendar"
|
||||
class="group inline-flex items-center space-x-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-4 rounded-xl font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-6 h-6 group-hover:animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span>Browse All Events</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
class="group inline-flex items-center space-x-3 bg-white border-2 border-gray-200 hover:border-gray-300 text-gray-700 hover:text-gray-900 px-8 py-4 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-6 h-6 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
<span>Go Home</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Popular Suggestions -->
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-6">Or explore these popular sections:</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<a
|
||||
href="/calendar?featured=true"
|
||||
class="group p-4 bg-white/50 backdrop-blur-sm border border-white/50 rounded-xl hover:bg-white/70 hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<div class="text-2xl mb-2 group-hover:animate-pulse">⭐</div>
|
||||
<div class="text-sm font-medium text-gray-700">Featured Events</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/calendar?category=music"
|
||||
class="group p-4 bg-white/50 backdrop-blur-sm border border-white/50 rounded-xl hover:bg-white/70 hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<div class="text-2xl mb-2 group-hover:animate-pulse">🎵</div>
|
||||
<div class="text-sm font-medium text-gray-700">Music</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/calendar?category=arts"
|
||||
class="group p-4 bg-white/50 backdrop-blur-sm border border-white/50 rounded-xl hover:bg-white/70 hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<div class="text-2xl mb-2 group-hover:animate-pulse">🎨</div>
|
||||
<div class="text-sm font-medium text-gray-700">Arts</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/calendar?category=community"
|
||||
class="group p-4 bg-white/50 backdrop-blur-sm border border-white/50 rounded-xl hover:bg-white/70 hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<div class="text-2xl mb-2 group-hover:animate-pulse">🤝</div>
|
||||
<div class="text-sm font-medium text-gray-700">Community</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(59, 130, 246, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Interactive hover effects */
|
||||
.hover-lift {
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Search functionality from 404 page
|
||||
const errorSearch = document.getElementById('error-search');
|
||||
const errorSearchBtn = document.getElementById('error-search-btn');
|
||||
|
||||
function performSearch() {
|
||||
const query = errorSearch.value.trim();
|
||||
if (query) {
|
||||
window.location.href = `/calendar?search=${encodeURIComponent(query)}`;
|
||||
}
|
||||
}
|
||||
|
||||
errorSearchBtn?.addEventListener('click', performSearch);
|
||||
|
||||
errorSearch?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Add some interactive animations on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Animate elements on scroll/load
|
||||
const animateElements = document.querySelectorAll('.animate-fade-in-up');
|
||||
animateElements.forEach((el, index) => {
|
||||
setTimeout(() => {
|
||||
el.style.animation = `fadeInUp 0.6s ease-out ${index * 0.1}s both`;
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
162
src/pages/500.astro
Normal file
162
src/pages/500.astro
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import PublicHeader from '../components/PublicHeader.astro';
|
||||
---
|
||||
|
||||
<Layout title="Server Error - Black Canyon Tickets">
|
||||
<div class="min-h-screen bg-gradient-to-br from-red-50 via-white to-orange-50/30">
|
||||
<PublicHeader />
|
||||
|
||||
<!-- 500 Hero Section -->
|
||||
<section class="relative overflow-hidden min-h-screen flex items-center justify-center">
|
||||
<!-- Animated Background -->
|
||||
<div class="absolute inset-0 opacity-20">
|
||||
<div class="absolute top-1/4 left-1/4 w-64 h-64 bg-gradient-to-br from-red-400 to-orange-500 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-gradient-to-br from-orange-400 to-red-500 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
<div class="absolute top-1/2 right-1/3 w-48 h-48 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-full blur-3xl animate-pulse delay-500"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<!-- Error Illustration -->
|
||||
<div class="mb-12">
|
||||
<div class="relative inline-block">
|
||||
<!-- Large 500 Text -->
|
||||
<h1 class="text-[8rem] sm:text-[12rem] lg:text-[16rem] font-black leading-none">
|
||||
<span class="bg-gradient-to-br from-red-200 via-orange-300 to-red-400 bg-clip-text text-transparent drop-shadow-2xl">
|
||||
500
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<!-- Floating Warning Icon -->
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 animate-bounce">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-red-600 to-orange-600 rounded-2xl shadow-2xl flex items-center justify-center transform rotate-12 hover:rotate-0 transition-transform duration-500">
|
||||
<svg class="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-4xl lg:text-6xl font-light text-gray-900 mb-6 tracking-tight">
|
||||
Something Went Wrong
|
||||
</h2>
|
||||
<p class="text-xl lg:text-2xl text-gray-600 mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Our servers are experiencing some technical difficulties. Don't worry, our team has been notified and is working to fix this.
|
||||
</p>
|
||||
|
||||
<!-- Status Card -->
|
||||
<div class="bg-white/70 backdrop-blur-lg border border-red-200/50 rounded-2xl p-8 shadow-2xl max-w-lg mx-auto mb-8">
|
||||
<div class="flex items-center justify-center space-x-3 mb-4">
|
||||
<div class="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<span class="text-lg font-semibold text-gray-900">Server Status</span>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">
|
||||
We're working hard to restore full functionality. This is usually resolved within a few minutes.
|
||||
</p>
|
||||
<div class="text-sm text-gray-500">
|
||||
Error Code: <span class="font-mono bg-gray-100 px-2 py-1 rounded">TEMP_500</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center mb-12">
|
||||
<button
|
||||
onclick="window.location.reload()"
|
||||
class="group inline-flex items-center space-x-3 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white px-8 py-4 rounded-xl font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-6 h-6 group-hover:animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<span>Try Again</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
class="group inline-flex items-center space-x-3 bg-white border-2 border-gray-200 hover:border-gray-300 text-gray-700 hover:text-gray-900 px-8 py-4 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-6 h-6 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
<span>Go Home</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Support Contact -->
|
||||
<div class="max-w-lg mx-auto">
|
||||
<div class="bg-gradient-to-r from-gray-50 to-gray-100 border border-gray-200 rounded-2xl p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-3">Need Immediate Help?</h3>
|
||||
<p class="text-gray-600 mb-4 text-sm">
|
||||
If this error persists, please reach out to our support team.
|
||||
</p>
|
||||
<a
|
||||
href="/support"
|
||||
class="inline-flex items-center space-x-2 text-blue-600 hover:text-blue-700 font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
|
||||
</svg>
|
||||
<span>Contact Support</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Auto-retry functionality
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
// Show retry countdown if this is an automatic retry
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('retry')) {
|
||||
setTimeout(() => {
|
||||
if (retryCount < maxRetries) {
|
||||
window.location.reload();
|
||||
retryCount++;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Add interactive animations on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const animateElements = document.querySelectorAll('.animate-fade-in-up');
|
||||
animateElements.forEach((el, index) => {
|
||||
setTimeout(() => {
|
||||
el.style.animation = `fadeInUp 0.6s ease-out ${index * 0.1}s both`;
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
1637
src/pages/admin/dashboard.astro
Normal file
1637
src/pages/admin/dashboard.astro
Normal file
File diff suppressed because it is too large
Load Diff
640
src/pages/admin/index.astro
Normal file
640
src/pages/admin/index.astro
Normal file
@@ -0,0 +1,640 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Navigation from '../../components/Navigation.astro';
|
||||
---
|
||||
|
||||
<Layout title="Admin Dashboard - Black Canyon Tickets">
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||
<Navigation
|
||||
title="Admin Dashboard"
|
||||
showBackLink={true}
|
||||
backLinkUrl="/dashboard"
|
||||
backLinkText="← Dashboard"
|
||||
/>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||
<!-- Admin Header -->
|
||||
<div class="bg-gradient-to-r from-red-600 to-red-700 rounded-3xl shadow-2xl mb-8 overflow-hidden">
|
||||
<div class="px-8 py-12 text-white">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-4xl font-light mb-3 tracking-wide">Admin Dashboard</h1>
|
||||
<p class="text-red-100 text-lg">Platform management and oversight</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-semibold" id="total-platform-revenue">$0</div>
|
||||
<div class="text-sm text-red-100">Total Platform Revenue</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Navigation Tabs -->
|
||||
<div class="bg-white rounded-2xl shadow-lg border border-slate-200/50 mb-8">
|
||||
<div class="border-b border-slate-200">
|
||||
<nav class="flex space-x-8 px-8">
|
||||
<button
|
||||
onclick="showTab('tickets')"
|
||||
class="tab-btn py-4 px-2 border-b-2 border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300 font-medium transition-colors duration-200 active"
|
||||
>
|
||||
Ticket Management
|
||||
</button>
|
||||
<button
|
||||
onclick="showTab('subscriptions')"
|
||||
class="tab-btn py-4 px-2 border-b-2 border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300 font-medium transition-colors duration-200"
|
||||
>
|
||||
Subscriptions
|
||||
</button>
|
||||
<button
|
||||
onclick="showTab('organizations')"
|
||||
class="tab-btn py-4 px-2 border-b-2 border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300 font-medium transition-colors duration-200"
|
||||
>
|
||||
Organizations
|
||||
</button>
|
||||
<button
|
||||
onclick="showTab('analytics')"
|
||||
class="tab-btn py-4 px-2 border-b-2 border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300 font-medium transition-colors duration-200"
|
||||
>
|
||||
Platform Analytics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Ticket Management Tab -->
|
||||
<div id="content-tickets" class="tab-content">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-light text-slate-900 mb-2">Ticket Management</h2>
|
||||
<p class="text-slate-600">Manage all tickets across the platform</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<select id="ticket-filter-status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="none">Active</option>
|
||||
<option value="completed">Refunded</option>
|
||||
<option value="requested">Refund Requested</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="ticket-filter-email"
|
||||
placeholder="Filter by email..."
|
||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48"
|
||||
>
|
||||
<button
|
||||
onclick="loadTickets()"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tickets-content">
|
||||
<!-- Tickets will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Tab -->
|
||||
<div id="content-subscriptions" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-light text-slate-900 mb-2">Subscription Management</h2>
|
||||
<p class="text-slate-600">Manage organization subscriptions and billing</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<select id="subscription-filter-status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<button
|
||||
onclick="loadSubscriptions()"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="subscriptions-content">
|
||||
<!-- Subscriptions will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organizations Tab -->
|
||||
<div id="content-organizations" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-light text-slate-900 mb-2">Organizations</h2>
|
||||
<p class="text-slate-600">Manage all organizations on the platform</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="organizations-content">
|
||||
<!-- Organizations will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
<div id="content-analytics" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-light text-slate-900 mb-2">Platform Analytics</h2>
|
||||
<p class="text-slate-600">Platform-wide metrics and insights</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="analytics-content">
|
||||
<!-- Analytics will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
let currentPage = 1;
|
||||
const pageSize = 50;
|
||||
|
||||
async function checkAuth() {
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
if (error || !session) {
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('role', 'admin')
|
||||
.single();
|
||||
|
||||
if (!userRole) {
|
||||
alert('Admin access required');
|
||||
window.location.href = '/dashboard';
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove active class from all tab buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active', 'border-red-600', 'text-red-600');
|
||||
btn.classList.add('border-transparent', 'text-slate-600');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
const targetTab = document.getElementById(`content-${tabName}`);
|
||||
if (targetTab) {
|
||||
targetTab.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Mark button as active
|
||||
const activeBtn = event?.target || document.querySelector(`[onclick="showTab('${tabName}')"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('active', 'border-red-600', 'text-red-600');
|
||||
activeBtn.classList.remove('border-transparent', 'text-slate-600');
|
||||
}
|
||||
|
||||
// Load content for the tab
|
||||
switch (tabName) {
|
||||
case 'tickets':
|
||||
loadTickets();
|
||||
break;
|
||||
case 'subscriptions':
|
||||
loadSubscriptions();
|
||||
break;
|
||||
case 'organizations':
|
||||
loadOrganizations();
|
||||
break;
|
||||
case 'analytics':
|
||||
loadAnalytics();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
try {
|
||||
const statusFilter = document.getElementById('ticket-filter-status').value;
|
||||
const emailFilter = document.getElementById('ticket-filter-email').value;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
limit: pageSize.toString()
|
||||
});
|
||||
|
||||
if (statusFilter) params.append('refund_status', statusFilter);
|
||||
if (emailFilter) params.append('email', emailFilter);
|
||||
|
||||
const response = await fetch(`/api/admin/tickets?${params}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to load tickets');
|
||||
}
|
||||
|
||||
renderTickets(result.tickets, result.pagination);
|
||||
} catch (error) {
|
||||
console.error('Error loading tickets:', error);
|
||||
document.getElementById('tickets-content').innerHTML = `
|
||||
<div class="text-red-600 bg-red-50 p-4 rounded-lg">
|
||||
<p class="font-medium">Error loading tickets</p>
|
||||
<p class="text-sm">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTickets(tickets, pagination) {
|
||||
if (tickets.length === 0) {
|
||||
document.getElementById('tickets-content').innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<p class="text-slate-500 text-lg">No tickets found</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketsHtml = `
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Ticket ID</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Event</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Customer</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Organization</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Price</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Status</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
${tickets.map(ticket => `
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="py-3 px-4 font-mono text-sm">${ticket.uuid?.substring(0, 8) || 'N/A'}...</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium text-slate-900">${ticket.events?.title || 'Unknown Event'}</div>
|
||||
<div class="text-slate-500">${ticket.events?.venue || ''}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium text-slate-900">${ticket.purchaser_name || 'Not provided'}</div>
|
||||
<div class="text-slate-500">${ticket.purchaser_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-sm text-slate-700">${ticket.events?.organizations?.name || 'Unknown'}</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="text-sm font-medium text-slate-900">$${ticket.price}</span>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ticket.checked_in ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}">
|
||||
${ticket.checked_in ? 'Checked In' : 'Not Checked In'}
|
||||
</span>
|
||||
${ticket.refund_status !== 'none' ? `
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
${ticket.refund_status.replace('_', ' ')}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex space-x-2">
|
||||
${!ticket.checked_in ? `
|
||||
<button
|
||||
onclick="adminCheckInTicket('${ticket.id}')"
|
||||
class="text-green-600 hover:text-green-900 text-sm font-medium"
|
||||
>
|
||||
Check In
|
||||
</button>
|
||||
` : ''}
|
||||
${ticket.refund_status === 'none' ? `
|
||||
<button
|
||||
onclick="adminCancelTicket('${ticket.id}')"
|
||||
class="text-red-600 hover:text-red-900 text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-slate-200 bg-slate-50">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-slate-700">
|
||||
Showing ${((pagination.page - 1) * pagination.limit) + 1} to ${Math.min(pagination.page * pagination.limit, pagination.total)} of ${pagination.total} tickets
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
${pagination.page > 1 ? `
|
||||
<button onclick="changePage(${pagination.page - 1})" class="px-3 py-2 border border-slate-300 rounded-lg text-sm hover:bg-slate-50">
|
||||
Previous
|
||||
</button>
|
||||
` : ''}
|
||||
${pagination.page < pagination.pages ? `
|
||||
<button onclick="changePage(${pagination.page + 1})" class="px-3 py-2 border border-slate-300 rounded-lg text-sm hover:bg-slate-50">
|
||||
Next
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('tickets-content').innerHTML = ticketsHtml;
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const statusFilter = document.getElementById('subscription-filter-status').value;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
limit: pageSize.toString()
|
||||
});
|
||||
|
||||
if (statusFilter) params.append('status', statusFilter);
|
||||
|
||||
const response = await fetch(`/api/admin/subscriptions?${params}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to load subscriptions');
|
||||
}
|
||||
|
||||
renderSubscriptions(result.organizations, result.pagination);
|
||||
} catch (error) {
|
||||
console.error('Error loading subscriptions:', error);
|
||||
document.getElementById('subscriptions-content').innerHTML = `
|
||||
<div class="text-red-600 bg-red-50 p-4 rounded-lg">
|
||||
<p class="font-medium">Error loading subscriptions</p>
|
||||
<p class="text-sm">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubscriptions(organizations, pagination) {
|
||||
if (organizations.length === 0) {
|
||||
document.getElementById('subscriptions-content').innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<p class="text-slate-500 text-lg">No subscriptions found</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionsHtml = `
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Organization</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Users</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Stripe Account</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Status</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Created</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
${organizations.map(org => `
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="py-3 px-4">
|
||||
<div class="font-medium text-slate-900">${org.name}</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-sm text-slate-600">${org.users?.length || 0} users</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-sm">
|
||||
${org.subscription ? `
|
||||
<div class="font-mono text-xs text-slate-600">${org.subscription.stripe_account_id?.substring(0, 12) || 'N/A'}...</div>
|
||||
<div class="text-xs text-slate-500">${org.subscription.country || 'Unknown'}</div>
|
||||
` : 'Not connected'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
org.subscription?.account_status === 'active' ? 'bg-green-100 text-green-800' :
|
||||
org.subscription?.account_status === 'inactive' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}">
|
||||
${org.subscription?.account_status || 'inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-sm text-slate-600">${new Date(org.created_at).toLocaleDateString()}</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex space-x-2">
|
||||
${org.subscription?.account_status === 'active' ? `
|
||||
<button
|
||||
onclick="suspendAccount('${org.id}')"
|
||||
class="text-red-600 hover:text-red-900 text-sm font-medium"
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
` : `
|
||||
<button
|
||||
onclick="reactivateAccount('${org.id}')"
|
||||
class="text-green-600 hover:text-green-900 text-sm font-medium"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('subscriptions-content').innerHTML = subscriptionsHtml;
|
||||
}
|
||||
|
||||
async function loadOrganizations() {
|
||||
document.getElementById('organizations-content').innerHTML = '<p class="text-slate-500">Organizations management coming soon...</p>';
|
||||
}
|
||||
|
||||
async function loadAnalytics() {
|
||||
document.getElementById('analytics-content').innerHTML = '<p class="text-slate-500">Platform analytics coming soon...</p>';
|
||||
}
|
||||
|
||||
// Action functions
|
||||
async function adminCheckInTicket(ticketId) {
|
||||
try {
|
||||
const response = await fetch('/api/admin/tickets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'check_in',
|
||||
ticket_id: ticketId
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to check in ticket');
|
||||
}
|
||||
|
||||
alert('Ticket checked in successfully');
|
||||
loadTickets();
|
||||
} catch (error) {
|
||||
console.error('Error checking in ticket:', error);
|
||||
alert('Error checking in ticket: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function adminCancelTicket(ticketId) {
|
||||
if (!confirm('Cancel this ticket? This will mark it as refunded.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/tickets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'cancel_ticket',
|
||||
ticket_id: ticketId
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to cancel ticket');
|
||||
}
|
||||
|
||||
alert('Ticket cancelled successfully');
|
||||
loadTickets();
|
||||
} catch (error) {
|
||||
console.error('Error cancelling ticket:', error);
|
||||
alert('Error cancelling ticket: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function suspendAccount(organizationId) {
|
||||
if (!confirm('Suspend this organization account?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/subscriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'suspend_account',
|
||||
organization_id: organizationId
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to suspend account');
|
||||
}
|
||||
|
||||
alert('Account suspended successfully');
|
||||
loadSubscriptions();
|
||||
} catch (error) {
|
||||
console.error('Error suspending account:', error);
|
||||
alert('Error suspending account: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function reactivateAccount(organizationId) {
|
||||
try {
|
||||
const response = await fetch('/api/admin/subscriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'reactivate_account',
|
||||
organization_id: organizationId
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to reactivate account');
|
||||
}
|
||||
|
||||
alert('Account reactivated successfully');
|
||||
loadSubscriptions();
|
||||
} catch (error) {
|
||||
console.error('Error reactivating account:', error);
|
||||
alert('Error reactivating account: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadTickets();
|
||||
}
|
||||
|
||||
// Global functions
|
||||
window.showTab = showTab;
|
||||
window.loadTickets = loadTickets;
|
||||
window.loadSubscriptions = loadSubscriptions;
|
||||
window.adminCheckInTicket = adminCheckInTicket;
|
||||
window.adminCancelTicket = adminCancelTicket;
|
||||
window.suspendAccount = suspendAccount;
|
||||
window.reactivateAccount = reactivateAccount;
|
||||
window.changePage = changePage;
|
||||
|
||||
// Initialize
|
||||
checkAuth().then(session => {
|
||||
if (session) {
|
||||
showTab('tickets');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tab-btn.active {
|
||||
border-color: #dc2626 !important;
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
</style>
|
||||
120
src/pages/api/admin/events.ts
Normal file
120
src/pages/api/admin/events.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { logAPIRequest } from '../../../lib/logger';
|
||||
|
||||
// Handle missing environment variables gracefully
|
||||
const supabaseUrl = process.env.SUPABASE_URL || import.meta.env.SUPABASE_URL || 'https://zctjaivtfyfxokfaemek.supabase.co';
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || import.meta.env.SUPABASE_SERVICE_KEY || '';
|
||||
|
||||
let supabase: any = null;
|
||||
try {
|
||||
if (supabaseUrl && supabaseServiceKey) {
|
||||
supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle Supabase initialization errors
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
const startTime = Date.now();
|
||||
const clientIP = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
|
||||
try {
|
||||
if (!supabase) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Database not available'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get all events with organization info (admin view)
|
||||
const { data: events, error } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
venue,
|
||||
start_time,
|
||||
end_time,
|
||||
image_url,
|
||||
slug,
|
||||
category,
|
||||
is_featured,
|
||||
is_public,
|
||||
is_published,
|
||||
external_source,
|
||||
organization_id,
|
||||
created_at
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
logAPIRequest({
|
||||
method: 'GET',
|
||||
url: url.pathname + url.search,
|
||||
statusCode: 200,
|
||||
responseTime,
|
||||
ipAddress: clientIP,
|
||||
userAgent
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
events: events || [],
|
||||
total: events?.length || 0,
|
||||
summary: {
|
||||
total: events?.length || 0,
|
||||
featured: events?.filter(e => e.is_featured).length || 0,
|
||||
public: events?.filter(e => e.is_public).length || 0,
|
||||
firebase: events?.filter(e => e.external_source === 'firebase').length || 0,
|
||||
byOrganization: events?.reduce((acc: any, event) => {
|
||||
const orgId = event.organization_id || 'no-org';
|
||||
acc[orgId] = (acc[orgId] || 0) + 1;
|
||||
return acc;
|
||||
}, {}) || {}
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
logAPIRequest({
|
||||
method: 'GET',
|
||||
url: url.pathname + url.search,
|
||||
statusCode: 500,
|
||||
responseTime,
|
||||
ipAddress: clientIP,
|
||||
userAgent
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
160
src/pages/api/admin/scraper.ts
Normal file
160
src/pages/api/admin/scraper.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { runFirebaseEventScraper, initializeScraperOrganization } from '../../../lib/firebaseEventScraper';
|
||||
import { logAPIRequest, logSecurityEvent } from '../../../lib/logger';
|
||||
import { checkRateLimit } from '../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const startTime = Date.now();
|
||||
const clientIP = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
|
||||
try {
|
||||
// Rate limiting - only 50 requests per hour per IP (increased for testing)
|
||||
if (!checkRateLimit(clientIP, 50, 3600000)) {
|
||||
logSecurityEvent({
|
||||
type: 'rate_limit',
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
severity: 'medium',
|
||||
details: { endpoint: '/api/admin/scraper', limit: 5 }
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Rate limit exceeded. Please try again later.'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Parse request body for action
|
||||
const body = await request.json().catch(() => ({ action: 'run' }));
|
||||
const action = body.action || 'run';
|
||||
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case 'init':
|
||||
// Initialize scraper organization
|
||||
const initialized = await initializeScraperOrganization();
|
||||
result = {
|
||||
success: initialized,
|
||||
message: initialized ? 'Scraper organization initialized' : 'Failed to initialize scraper organization'
|
||||
};
|
||||
break;
|
||||
|
||||
case 'run':
|
||||
default:
|
||||
// Run the Firebase scraper
|
||||
result = await runFirebaseEventScraper();
|
||||
break;
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Log API request
|
||||
logAPIRequest({
|
||||
method: 'POST',
|
||||
url: '/api/admin/scraper',
|
||||
statusCode: 200,
|
||||
responseTime,
|
||||
ipAddress: clientIP,
|
||||
userAgent
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
logAPIRequest({
|
||||
method: 'POST',
|
||||
url: '/api/admin/scraper',
|
||||
statusCode: 500,
|
||||
responseTime,
|
||||
ipAddress: clientIP,
|
||||
userAgent
|
||||
});
|
||||
|
||||
logSecurityEvent({
|
||||
type: 'api_error',
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
severity: 'high',
|
||||
details: {
|
||||
endpoint: '/api/admin/scraper',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
const startTime = Date.now();
|
||||
const clientIP = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
|
||||
try {
|
||||
// Rate limiting - only 10 requests per hour per IP for status checks
|
||||
if (!checkRateLimit(clientIP, 10, 3600000)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Rate limit exceeded. Please try again later.'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Return scraper status
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
logAPIRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/scraper',
|
||||
statusCode: 200,
|
||||
responseTime,
|
||||
ipAddress: clientIP,
|
||||
userAgent
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Event scraper is operational',
|
||||
endpoints: {
|
||||
run: 'POST /api/admin/scraper with {"action": "run"}',
|
||||
init: 'POST /api/admin/scraper with {"action": "init"}',
|
||||
status: 'GET /api/admin/scraper'
|
||||
},
|
||||
rateLimit: '5 requests per hour for POST, 10 for GET'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
264
src/pages/api/admin/subscriptions.ts
Normal file
264
src/pages/api/admin/subscriptions.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2024-06-20',
|
||||
});
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
try {
|
||||
// Get current user
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||
if (userError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.eq('role', 'admin')
|
||||
.single();
|
||||
|
||||
if (!userRole) {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
const searchParams = url.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '25');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
// Get organizations with their subscription info
|
||||
let query = supabase
|
||||
.from('organizations')
|
||||
.select(`
|
||||
*,
|
||||
users (
|
||||
id,
|
||||
email,
|
||||
name
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Apply pagination
|
||||
const offset = (page - 1) * limit;
|
||||
query = query.range(offset, offset + limit - 1);
|
||||
|
||||
const { data: organizations, error: orgsError } = await query;
|
||||
|
||||
if (orgsError) {
|
||||
throw orgsError;
|
||||
}
|
||||
|
||||
// Get Stripe subscription info for each organization
|
||||
const organizationsWithSubscriptions = await Promise.all(
|
||||
organizations.map(async (org) => {
|
||||
let subscriptionInfo = null;
|
||||
|
||||
if (org.stripe_account_id) {
|
||||
try {
|
||||
// Get Stripe account info
|
||||
const account = await stripe.accounts.retrieve(org.stripe_account_id);
|
||||
|
||||
// Check if there are any subscriptions (this would be custom logic)
|
||||
// For now, we'll just return account status
|
||||
subscriptionInfo = {
|
||||
stripe_account_id: org.stripe_account_id,
|
||||
account_status: account.charges_enabled ? 'active' : 'inactive',
|
||||
details_submitted: account.details_submitted,
|
||||
payouts_enabled: account.payouts_enabled,
|
||||
country: account.country,
|
||||
created: account.created
|
||||
};
|
||||
} catch (stripeError) {
|
||||
console.error('Error fetching Stripe account:', stripeError);
|
||||
subscriptionInfo = {
|
||||
stripe_account_id: org.stripe_account_id,
|
||||
account_status: 'error',
|
||||
error: stripeError.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...org,
|
||||
subscription: subscriptionInfo
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Filter by status if provided
|
||||
const filteredOrgs = status
|
||||
? organizationsWithSubscriptions.filter(org =>
|
||||
org.subscription?.account_status === status
|
||||
)
|
||||
: organizationsWithSubscriptions;
|
||||
|
||||
// Get total count
|
||||
const { count, error: countError } = await supabase
|
||||
.from('organizations')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
if (countError) {
|
||||
throw countError;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
organizations: filteredOrgs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
pages: Math.ceil((count || 0) / limit)
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriptions:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to fetch subscriptions',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, organization_id, ...data } = body;
|
||||
|
||||
// Get current user
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||
if (userError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.eq('role', 'admin')
|
||||
.single();
|
||||
|
||||
if (!userRole) {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get organization
|
||||
const { data: organization, error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
.select('*')
|
||||
.eq('id', organization_id)
|
||||
.single();
|
||||
|
||||
if (orgError || !organization) {
|
||||
return new Response(JSON.stringify({ error: 'Organization not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case 'suspend_account':
|
||||
if (organization.stripe_account_id) {
|
||||
try {
|
||||
// In a real scenario, you'd implement custom suspension logic
|
||||
// For now, we'll just update our database
|
||||
result = await supabase
|
||||
.from('organizations')
|
||||
.update({
|
||||
status: 'suspended',
|
||||
suspended_at: new Date().toISOString(),
|
||||
suspended_by: user.id
|
||||
})
|
||||
.eq('id', organization_id)
|
||||
.select()
|
||||
.single();
|
||||
} catch (error) {
|
||||
throw new Error('Failed to suspend account');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reactivate_account':
|
||||
result = await supabase
|
||||
.from('organizations')
|
||||
.update({
|
||||
status: 'active',
|
||||
suspended_at: null,
|
||||
suspended_by: null
|
||||
})
|
||||
.eq('id', organization_id)
|
||||
.select()
|
||||
.single();
|
||||
break;
|
||||
|
||||
case 'update_billing':
|
||||
// This would typically involve updating Stripe subscription
|
||||
// For now, just update organization metadata
|
||||
result = await supabase
|
||||
.from('organizations')
|
||||
.update(data)
|
||||
.eq('id', organization_id)
|
||||
.select()
|
||||
.single();
|
||||
break;
|
||||
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: 'Invalid action' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (result && result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
organization: result?.data || { message: 'Action completed' }
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error managing subscription:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to manage subscription',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
241
src/pages/api/admin/tickets.ts
Normal file
241
src/pages/api/admin/tickets.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
try {
|
||||
// Get current user
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||
if (userError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.eq('role', 'admin')
|
||||
.single();
|
||||
|
||||
if (!userRole) {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
const searchParams = url.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
const status = searchParams.get('status');
|
||||
const eventId = searchParams.get('event_id');
|
||||
const email = searchParams.get('email');
|
||||
const refundStatus = searchParams.get('refund_status');
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
*,
|
||||
events (
|
||||
id,
|
||||
title,
|
||||
venue,
|
||||
start_time,
|
||||
organizations (
|
||||
id,
|
||||
name
|
||||
)
|
||||
),
|
||||
ticket_types (
|
||||
id,
|
||||
name,
|
||||
price
|
||||
),
|
||||
purchase_attempts (
|
||||
id,
|
||||
total_amount,
|
||||
purchaser_email,
|
||||
purchaser_name,
|
||||
status,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Apply filters
|
||||
if (status) {
|
||||
query = query.eq('checked_in', status === 'checked_in');
|
||||
}
|
||||
if (eventId) {
|
||||
query = query.eq('event_id', eventId);
|
||||
}
|
||||
if (email) {
|
||||
query = query.ilike('purchaser_email', `%${email}%`);
|
||||
}
|
||||
if (refundStatus) {
|
||||
query = query.eq('refund_status', refundStatus);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const offset = (page - 1) * limit;
|
||||
query = query.range(offset, offset + limit - 1);
|
||||
|
||||
const { data: tickets, error: ticketsError } = await query;
|
||||
|
||||
if (ticketsError) {
|
||||
throw ticketsError;
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = supabase
|
||||
.from('tickets')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
if (status) {
|
||||
countQuery = countQuery.eq('checked_in', status === 'checked_in');
|
||||
}
|
||||
if (eventId) {
|
||||
countQuery = countQuery.eq('event_id', eventId);
|
||||
}
|
||||
if (email) {
|
||||
countQuery = countQuery.ilike('purchaser_email', `%${email}%`);
|
||||
}
|
||||
if (refundStatus) {
|
||||
countQuery = countQuery.eq('refund_status', refundStatus);
|
||||
}
|
||||
|
||||
const { count, error: countError } = await countQuery;
|
||||
|
||||
if (countError) {
|
||||
throw countError;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
tickets,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
pages: Math.ceil((count || 0) / limit)
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching tickets:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to fetch tickets',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, ticket_id, ...data } = body;
|
||||
|
||||
// Get current user
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||
if (userError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.eq('role', 'admin')
|
||||
.single();
|
||||
|
||||
if (!userRole) {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case 'update_ticket':
|
||||
result = await supabase
|
||||
.from('tickets')
|
||||
.update(data)
|
||||
.eq('id', ticket_id)
|
||||
.select()
|
||||
.single();
|
||||
break;
|
||||
|
||||
case 'check_in':
|
||||
result = await supabase
|
||||
.from('tickets')
|
||||
.update({
|
||||
checked_in: true,
|
||||
scanned_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', ticket_id)
|
||||
.select()
|
||||
.single();
|
||||
break;
|
||||
|
||||
case 'cancel_ticket':
|
||||
result = await supabase
|
||||
.from('tickets')
|
||||
.update({
|
||||
refund_status: 'cancelled',
|
||||
refund_requested_at: new Date().toISOString(),
|
||||
refund_reason: 'Admin cancelled',
|
||||
refunded_by: user.id
|
||||
})
|
||||
.eq('id', ticket_id)
|
||||
.select()
|
||||
.single();
|
||||
break;
|
||||
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: 'Invalid action' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
ticket: result.data
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error managing ticket:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to manage ticket',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
110
src/pages/api/chat.ts
Normal file
110
src/pages/api/chat.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
|
||||
// Fallback responses when OpenAI is not available
|
||||
const getFallbackResponse = (message: string): string => {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (lowerMessage.includes('create') && lowerMessage.includes('event')) {
|
||||
return "To create your first event:\n\n1. Complete your account setup\n2. Connect your Stripe account\n3. Click 'Create Event' in your dashboard\n4. Fill in event details and ticket types\n5. Publish your event\n\nFor detailed steps, check our Getting Started guide at /docs/getting-started/first-event";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('stripe') || lowerMessage.includes('payment')) {
|
||||
return "To set up payments:\n\n1. Go to Settings → Payment Settings\n2. Click 'Connect Stripe Account'\n3. Complete the verification process\n4. Start accepting payments!\n\nOur platform fee is 2.5% + $1.50 per ticket. For detailed setup instructions, visit /docs/getting-started/stripe-connect";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('scan') || lowerMessage.includes('qr')) {
|
||||
return "QR code scanning is simple:\n\n1. Go to portal.blackcanyontickets.com/scan on any mobile device\n2. Log in with your organizer account\n3. Select your event\n4. Allow camera access\n5. Start scanning tickets!\n\nNo apps required - works in any browser. Check out our scanning guide at /docs/scanning/setup";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('fee') || lowerMessage.includes('cost')) {
|
||||
return "Our transparent pricing is 2.5% + $1.50 per ticket.\n\nThis includes:\n• Payment processing through Stripe\n• QR code generation and scanning\n• Event management tools\n• Customer support\n• Real-time analytics\n\nFees are automatically deducted before payouts.";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('payout') || lowerMessage.includes('paid')) {
|
||||
return "Payments are processed automatically through Stripe Connect:\n\n• Automatic processing after each sale\n• Platform fees deducted automatically\n• Typical payout time: 2-7 business days\n• Direct deposit to your bank account\n• Real-time tracking in your dashboard\n\nView detailed payout info in your Stripe dashboard.";
|
||||
}
|
||||
|
||||
return "I'm here to help with Black Canyon Tickets! You can ask me about:\n\n• Creating and managing events\n• Setting up Stripe payments\n• QR code scanning\n• Platform fees and payouts\n• Technical troubleshooting\n\nFor detailed documentation, visit /docs or email support@blackcanyontickets.com for personal assistance.";
|
||||
};
|
||||
|
||||
const SYSTEM_PROMPT = `You are a helpful customer support assistant for Black Canyon Tickets, a premium ticketing platform for upscale venues.
|
||||
|
||||
Key information about our platform:
|
||||
- We serve upscale venues and premium events
|
||||
- Features include QR code scanning, Stripe payment processing, event management
|
||||
- No mobile apps required - everything works in web browsers
|
||||
- Platform fee is 2.5% + $1.50 per ticket
|
||||
- Automatic payouts through Stripe Connect
|
||||
- Events are accessed at portal.blackcanyontickets.com/e/[event-slug]
|
||||
- QR scanning is available at /scan
|
||||
- Mobile-friendly design for all features
|
||||
|
||||
Common topics:
|
||||
- Account setup and verification
|
||||
- Creating events and ticket types
|
||||
- Payment processing and payouts
|
||||
- QR code ticket scanning
|
||||
- Embedding events on websites
|
||||
- Troubleshooting checkout issues
|
||||
|
||||
Be helpful, professional, and concise. If you don't know something specific, direct them to support@blackcanyontickets.com.
|
||||
Keep responses under 200 words unless asked for detailed explanations.`;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { message } = await request.json();
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
// Use fallback responses when OpenAI is not configured
|
||||
const fallbackResponse = getFallbackResponse(message);
|
||||
return new Response(JSON.stringify({
|
||||
message: fallbackResponse
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: message }
|
||||
],
|
||||
max_tokens: 300,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const assistantMessage = data.choices[0].message.content;
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
message: assistantMessage
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to process chat message'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
119
src/pages/api/checkin-barcode.ts
Normal file
119
src/pages/api/checkin-barcode.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { barcode_number, event_id, scanned_by } = await request.json();
|
||||
|
||||
if (!barcode_number || !event_id || !scanned_by) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing required parameters'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Log the scan attempt
|
||||
const logScanAttempt = async (result: string, errorMessage?: string) => {
|
||||
await supabase.from('scan_attempts').insert({
|
||||
barcode_number,
|
||||
event_id,
|
||||
scanned_by,
|
||||
result,
|
||||
error_message: errorMessage
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Lookup ticket by barcode
|
||||
const { data: ticket, error: ticketError } = await supabase
|
||||
.from('printed_tickets')
|
||||
.select(`
|
||||
*,
|
||||
ticket_types (
|
||||
name,
|
||||
price
|
||||
),
|
||||
events (
|
||||
title,
|
||||
organization_id
|
||||
)
|
||||
`)
|
||||
.eq('barcode_number', barcode_number)
|
||||
.single();
|
||||
|
||||
// 2. Check if barcode exists
|
||||
if (ticketError || !ticket) {
|
||||
await logScanAttempt('INVALID_BARCODE', 'Barcode not found');
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Invalid barcode'
|
||||
}), { status: 404 });
|
||||
}
|
||||
|
||||
// 3. Check if event matches
|
||||
if (ticket.event_id !== event_id) {
|
||||
await logScanAttempt('WRONG_EVENT', 'Barcode not valid for this event');
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Barcode not valid for this event'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// 4. Check if already used
|
||||
if (ticket.status === 'used') {
|
||||
await logScanAttempt('ALREADY_USED', `Ticket already used at ${ticket.checked_in_at}`);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: `Ticket already used at ${new Date(ticket.checked_in_at).toLocaleString()}`
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// 5. Check if status is valid
|
||||
if (ticket.status !== 'valid') {
|
||||
await logScanAttempt('NOT_VALID', 'Ticket is not valid');
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Ticket is not valid'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// 6. Mark as used
|
||||
const { error: updateError } = await supabase
|
||||
.from('printed_tickets')
|
||||
.update({
|
||||
status: 'used',
|
||||
checked_in_at: new Date().toISOString(),
|
||||
scanned_by: scanned_by
|
||||
})
|
||||
.eq('id', ticket.id);
|
||||
|
||||
if (updateError) {
|
||||
await logScanAttempt('ERROR', 'Failed to update ticket status');
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to update ticket status'
|
||||
}), { status: 500 });
|
||||
}
|
||||
|
||||
// 7. Log successful scan
|
||||
await logScanAttempt('SUCCESS', 'Check-in successful');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Check-in successful',
|
||||
ticket: {
|
||||
barcode_number: ticket.barcode_number,
|
||||
ticket_type: ticket.ticket_types?.name,
|
||||
price: ticket.ticket_types?.price,
|
||||
event: ticket.events?.title,
|
||||
checked_in_at: new Date().toISOString()
|
||||
}
|
||||
}), { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}), { status: 500 });
|
||||
}
|
||||
};
|
||||
411
src/pages/api/gdpr/user-data.ts
Normal file
411
src/pages/api/gdpr/user-data.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { requireAuth, getClientIP, checkRateLimit, createAuthResponse } from '../../../lib/auth';
|
||||
import { validateRequest } from '../../../lib/validation';
|
||||
import { logUserActivity, logSecurityEvent } from '../../../lib/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Validation schemas
|
||||
const userDataRequestSchema = z.object({
|
||||
request_type: z.enum(['export', 'delete', 'portability']),
|
||||
user_email: z.string().email().optional(),
|
||||
confirmation: z.boolean().optional()
|
||||
});
|
||||
|
||||
// User data export endpoint
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Rate limiting
|
||||
const clientIP = getClientIP(request);
|
||||
if (!checkRateLimit(`gdpr-export:${clientIP}`, 2, 300000)) { // 2 requests per 5 minutes
|
||||
return createAuthResponse({ error: 'Rate limit exceeded for data export requests' }, 429);
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
// Log data export request
|
||||
logUserActivity({
|
||||
action: 'gdpr_data_export_requested',
|
||||
userId: auth.user.id,
|
||||
ipAddress: clientIP,
|
||||
userAgent: request.headers.get('User-Agent') || undefined,
|
||||
details: { requestType: 'export' }
|
||||
});
|
||||
|
||||
// Collect all user data
|
||||
const userData = await collectUserData(auth.user.id);
|
||||
|
||||
// Log successful export
|
||||
logUserActivity({
|
||||
action: 'gdpr_data_export_completed',
|
||||
userId: auth.user.id,
|
||||
ipAddress: clientIP,
|
||||
details: { dataSize: JSON.stringify(userData).length }
|
||||
});
|
||||
|
||||
return createAuthResponse({
|
||||
success: true,
|
||||
data: userData,
|
||||
exported_at: new Date().toISOString(),
|
||||
user_id: auth.user.id,
|
||||
notice: 'This export contains all personal data we have stored about you. You have the right to correct, update, or delete this information.'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting user data:', error);
|
||||
return createAuthResponse({
|
||||
error: 'Failed to export user data'
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// User data deletion endpoint
|
||||
export const DELETE: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Rate limiting
|
||||
const clientIP = getClientIP(request);
|
||||
if (!checkRateLimit(`gdpr-delete:${clientIP}`, 1, 86400000)) { // 1 request per day
|
||||
return createAuthResponse({ error: 'Rate limit exceeded for data deletion requests' }, 429);
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
const body = await request.json();
|
||||
const validation = validateRequest(userDataRequestSchema, body);
|
||||
if (!validation.success) {
|
||||
return createAuthResponse({
|
||||
error: 'Invalid request',
|
||||
details: validation.error
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const { confirmation } = validation.data;
|
||||
|
||||
if (!confirmation) {
|
||||
return createAuthResponse({
|
||||
error: 'Deletion confirmation required',
|
||||
notice: 'You must explicitly confirm that you want to delete all your data. This action cannot be undone.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Log deletion request
|
||||
logUserActivity({
|
||||
action: 'gdpr_data_deletion_requested',
|
||||
userId: auth.user.id,
|
||||
ipAddress: clientIP,
|
||||
userAgent: request.headers.get('User-Agent') || undefined,
|
||||
details: { confirmation: true }
|
||||
});
|
||||
|
||||
// Check for active events or pending transactions
|
||||
const { data: activeEvents } = await supabase
|
||||
.from('events')
|
||||
.select('id, title, start_time')
|
||||
.eq('created_by', auth.user.id)
|
||||
.gt('start_time', new Date().toISOString());
|
||||
|
||||
const { data: pendingTickets } = await supabase
|
||||
.from('tickets')
|
||||
.select('id, event_id')
|
||||
.eq('purchaser_email', auth.user.email)
|
||||
.eq('status', 'valid')
|
||||
.neq('checked_in', true);
|
||||
|
||||
if (activeEvents && activeEvents.length > 0) {
|
||||
return createAuthResponse({
|
||||
error: 'Cannot delete account with active events',
|
||||
details: 'You have active events that are scheduled for the future. Please cancel or complete these events before deleting your account.',
|
||||
active_events: activeEvents
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (pendingTickets && pendingTickets.length > 0) {
|
||||
return createAuthResponse({
|
||||
error: 'Cannot delete account with valid tickets',
|
||||
details: 'You have valid tickets for upcoming events. Please use or transfer these tickets before deleting your account.',
|
||||
ticket_count: pendingTickets.length
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Perform data deletion
|
||||
await deleteUserData(auth.user.id, auth.user.email!);
|
||||
|
||||
// Log successful deletion
|
||||
logUserActivity({
|
||||
action: 'gdpr_data_deletion_completed',
|
||||
userId: auth.user.id,
|
||||
ipAddress: clientIP,
|
||||
details: { deletedAt: new Date().toISOString() }
|
||||
});
|
||||
|
||||
// Sign out the user
|
||||
await supabase.auth.signOut();
|
||||
|
||||
return createAuthResponse({
|
||||
success: true,
|
||||
message: 'Your account and all associated data have been permanently deleted.',
|
||||
deleted_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting user data:', error);
|
||||
return createAuthResponse({
|
||||
error: 'Failed to delete user data'
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Data portability endpoint (structured data for transfer)
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Rate limiting
|
||||
const clientIP = getClientIP(request);
|
||||
if (!checkRateLimit(`gdpr-portability:${clientIP}`, 3, 3600000)) { // 3 requests per hour
|
||||
return createAuthResponse({ error: 'Rate limit exceeded for data portability requests' }, 429);
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
const body = await request.json();
|
||||
const validation = validateRequest(userDataRequestSchema, body);
|
||||
if (!validation.success) {
|
||||
return createAuthResponse({
|
||||
error: 'Invalid request',
|
||||
details: validation.error
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Log portability request
|
||||
logUserActivity({
|
||||
action: 'gdpr_data_portability_requested',
|
||||
userId: auth.user.id,
|
||||
ipAddress: clientIP,
|
||||
userAgent: request.headers.get('User-Agent') || undefined
|
||||
});
|
||||
|
||||
// Collect structured data for portability
|
||||
const portableData = await collectPortableData(auth.user.id);
|
||||
|
||||
return createAuthResponse({
|
||||
success: true,
|
||||
data: portableData,
|
||||
format: 'json',
|
||||
exported_at: new Date().toISOString(),
|
||||
notice: 'This data is formatted for easy import into other systems. The format complies with GDPR portability requirements.'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating portable data:', error);
|
||||
return createAuthResponse({
|
||||
error: 'Failed to create portable data'
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to collect all user data
|
||||
async function collectUserData(userId: string) {
|
||||
const userData: any = {
|
||||
user_profile: null,
|
||||
organizations: [],
|
||||
events: [],
|
||||
tickets: [],
|
||||
purchase_attempts: [],
|
||||
audit_logs: [],
|
||||
collected_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
// Get user profile
|
||||
const { data: user } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
userData.user_profile = user;
|
||||
|
||||
// Get organizations
|
||||
const { data: organizations } = await supabase
|
||||
.from('organizations')
|
||||
.select('*')
|
||||
.eq('id', user?.organization_id);
|
||||
userData.organizations = organizations || [];
|
||||
|
||||
// Get events created by user
|
||||
const { data: events } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('created_by', userId);
|
||||
userData.events = events || [];
|
||||
|
||||
// Get tickets purchased by user
|
||||
const { data: tickets } = await supabase
|
||||
.from('tickets')
|
||||
.select('*')
|
||||
.eq('purchaser_email', user?.email);
|
||||
userData.tickets = tickets || [];
|
||||
|
||||
// Get purchase attempts
|
||||
const { data: purchases } = await supabase
|
||||
.from('purchase_attempts')
|
||||
.select('*')
|
||||
.eq('purchaser_email', user?.email);
|
||||
userData.purchase_attempts = purchases || [];
|
||||
|
||||
// Get audit logs (admin actions by this user)
|
||||
const { data: auditLogs } = await supabase
|
||||
.from('audit_logs')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100); // Limit to recent 100 entries
|
||||
userData.audit_logs = auditLogs || [];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error collecting user data:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return userData;
|
||||
}
|
||||
|
||||
// Helper function to collect portable data (structured for transfer)
|
||||
async function collectPortableData(userId: string) {
|
||||
const { data: user } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
const portableData = {
|
||||
profile: {
|
||||
name: user?.name,
|
||||
email: user?.email,
|
||||
created_at: user?.created_at,
|
||||
role: user?.role
|
||||
},
|
||||
events_created: [],
|
||||
tickets_purchased: [],
|
||||
purchase_history: []
|
||||
};
|
||||
|
||||
// Get events in portable format
|
||||
const { data: events } = await supabase
|
||||
.from('events')
|
||||
.select('title, description, venue, start_time, end_time, created_at')
|
||||
.eq('created_by', userId);
|
||||
|
||||
portableData.events_created = events?.map(event => ({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
venue: event.venue,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
created_at: event.created_at
|
||||
})) || [];
|
||||
|
||||
// Get tickets in portable format
|
||||
const { data: tickets } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
price,
|
||||
status,
|
||||
checked_in,
|
||||
created_at,
|
||||
events (title, venue, start_time)
|
||||
`)
|
||||
.eq('purchaser_email', user?.email);
|
||||
|
||||
portableData.tickets_purchased = tickets?.map(ticket => ({
|
||||
event_title: ticket.events?.title,
|
||||
event_venue: ticket.events?.venue,
|
||||
event_date: ticket.events?.start_time,
|
||||
price_paid: ticket.price,
|
||||
status: ticket.status,
|
||||
attended: ticket.checked_in,
|
||||
purchased_at: ticket.created_at
|
||||
})) || [];
|
||||
|
||||
return portableData;
|
||||
}
|
||||
|
||||
// Helper function to delete user data
|
||||
async function deleteUserData(userId: string, userEmail: string) {
|
||||
try {
|
||||
// Note: Be careful with deletions - some data may need to be retained for legal/accounting purposes
|
||||
|
||||
// Delete in reverse order of dependencies
|
||||
|
||||
// Delete audit logs
|
||||
await supabase
|
||||
.from('audit_logs')
|
||||
.delete()
|
||||
.eq('user_id', userId);
|
||||
|
||||
// Anonymize tickets instead of deleting (for event organizer records)
|
||||
await supabase
|
||||
.from('tickets')
|
||||
.update({
|
||||
purchaser_email: `deleted-user-${Date.now()}@anonymized.local`,
|
||||
purchaser_name: 'Deleted User'
|
||||
})
|
||||
.eq('purchaser_email', userEmail);
|
||||
|
||||
// Anonymize purchase attempts
|
||||
await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({
|
||||
purchaser_email: `deleted-user-${Date.now()}@anonymized.local`,
|
||||
purchaser_name: 'Deleted User'
|
||||
})
|
||||
.eq('purchaser_email', userEmail);
|
||||
|
||||
// Delete events created by user (only if no tickets sold)
|
||||
const { data: userEvents } = await supabase
|
||||
.from('events')
|
||||
.select('id')
|
||||
.eq('created_by', userId);
|
||||
|
||||
if (userEvents) {
|
||||
for (const event of userEvents) {
|
||||
const { data: eventTickets } = await supabase
|
||||
.from('tickets')
|
||||
.select('id')
|
||||
.eq('event_id', event.id)
|
||||
.limit(1);
|
||||
|
||||
if (!eventTickets || eventTickets.length === 0) {
|
||||
// Safe to delete event with no tickets
|
||||
await supabase
|
||||
.from('events')
|
||||
.delete()
|
||||
.eq('id', event.id);
|
||||
} else {
|
||||
// Anonymize event creator
|
||||
await supabase
|
||||
.from('events')
|
||||
.update({ created_by: null })
|
||||
.eq('id', event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user profile
|
||||
await supabase
|
||||
.from('users')
|
||||
.delete()
|
||||
.eq('id', userId);
|
||||
|
||||
// Delete from Supabase Auth
|
||||
// Note: This would typically be done through the admin API
|
||||
// For now, we'll just sign out the user
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
50
src/pages/api/inventory/availability/[ticketTypeId].ts
Normal file
50
src/pages/api/inventory/availability/[ticketTypeId].ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../../lib/supabase';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const ticketTypeId = params.ticketTypeId;
|
||||
|
||||
if (!ticketTypeId) {
|
||||
return new Response(JSON.stringify({ error: 'ticket_type_id is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get real-time availability using the database function
|
||||
const { data, error } = await supabase
|
||||
.rpc('get_ticket_availability', { p_ticket_type_id: ticketTypeId });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const availability = data[0];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
availability: {
|
||||
available: availability.available_quantity,
|
||||
total: availability.total_quantity,
|
||||
reserved: availability.reserved_quantity,
|
||||
sold: availability.sold_quantity,
|
||||
is_available: availability.available_quantity > 0
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting availability:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to get availability',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
150
src/pages/api/inventory/complete-purchase.ts
Normal file
150
src/pages/api/inventory/complete-purchase.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
purchase_attempt_id,
|
||||
payment_intent_id,
|
||||
session_id
|
||||
} = body;
|
||||
|
||||
if (!purchase_attempt_id || !payment_intent_id || !session_id) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'purchase_attempt_id, payment_intent_id, and session_id are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Start a transaction to complete the purchase
|
||||
const { data: purchaseAttempt, error: purchaseError } = await supabase
|
||||
.from('purchase_attempts')
|
||||
.select(`
|
||||
*,
|
||||
purchase_attempt_items (
|
||||
*,
|
||||
ticket_types (
|
||||
event_id,
|
||||
name,
|
||||
price
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('id', purchase_attempt_id)
|
||||
.eq('session_id', session_id)
|
||||
.eq('status', 'pending')
|
||||
.single();
|
||||
|
||||
if (purchaseError || !purchaseAttempt) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Purchase attempt not found or already processed'
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Update purchase attempt to completed
|
||||
const { error: updateError } = await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({
|
||||
status: 'completed',
|
||||
stripe_payment_intent_id: payment_intent_id,
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', purchase_attempt_id);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
// Create actual tickets for each purchase item
|
||||
const ticketsToCreate = [];
|
||||
for (const item of purchaseAttempt.purchase_attempt_items) {
|
||||
for (let i = 0; i < item.quantity; i++) {
|
||||
ticketsToCreate.push({
|
||||
event_id: item.ticket_types.event_id,
|
||||
ticket_type_id: item.ticket_type_id,
|
||||
seat_id: item.seat_id,
|
||||
purchaser_email: purchaseAttempt.purchaser_email,
|
||||
purchaser_name: purchaseAttempt.purchaser_name,
|
||||
price: item.unit_price * 100, // Convert back to cents
|
||||
purchase_session_id: session_id,
|
||||
purchase_attempt_id: purchase_attempt_id,
|
||||
uuid: crypto.randomUUID() // Generate QR code UUID
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { data: createdTickets, error: ticketsError } = await supabase
|
||||
.from('tickets')
|
||||
.insert(ticketsToCreate)
|
||||
.select();
|
||||
|
||||
if (ticketsError) {
|
||||
// Rollback purchase attempt
|
||||
await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({ status: 'failed', failure_reason: 'Failed to create tickets' })
|
||||
.eq('id', purchase_attempt_id);
|
||||
|
||||
throw ticketsError;
|
||||
}
|
||||
|
||||
// Mark reservations as converted
|
||||
const { error: reservationsError } = await supabase
|
||||
.from('ticket_reservations')
|
||||
.update({ status: 'converted' })
|
||||
.eq('reserved_for_purchase_id', purchase_attempt_id);
|
||||
|
||||
if (reservationsError) {
|
||||
console.error('Error updating reservations:', reservationsError);
|
||||
// Don't fail the entire purchase for this
|
||||
}
|
||||
|
||||
// Release any reserved seats that are now taken
|
||||
for (const item of purchaseAttempt.purchase_attempt_items) {
|
||||
if (item.seat_id) {
|
||||
await supabase
|
||||
.from('seats')
|
||||
.update({
|
||||
is_available: false,
|
||||
reserved_until: null,
|
||||
last_reserved_by: null
|
||||
})
|
||||
.eq('id', item.seat_id);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
purchase: {
|
||||
id: purchaseAttempt.id,
|
||||
total_amount: purchaseAttempt.total_amount,
|
||||
tickets_created: createdTickets.length,
|
||||
tickets: createdTickets.map(ticket => ({
|
||||
id: ticket.id,
|
||||
uuid: ticket.uuid,
|
||||
ticket_type_id: ticket.ticket_type_id
|
||||
}))
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error completing purchase:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to complete purchase',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
179
src/pages/api/inventory/purchase-attempt.ts
Normal file
179
src/pages/api/inventory/purchase-attempt.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { validateRequest, sanitizeString, sanitizeEmail } from '../../../lib/validation';
|
||||
import { getClientIP, checkRateLimit, createAuthResponse } from '../../../lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Validation schema for purchase attempt
|
||||
const purchaseAttemptSchema = z.object({
|
||||
session_id: z.string().min(1).max(200),
|
||||
event_id: z.string().uuid(),
|
||||
purchaser_email: z.string().email(),
|
||||
purchaser_name: z.string().min(1).max(100),
|
||||
items: z.array(z.object({
|
||||
ticket_type_id: z.string().uuid(),
|
||||
quantity: z.number().int().positive().max(10),
|
||||
unit_price: z.number().int().nonnegative(),
|
||||
seat_id: z.string().uuid().optional()
|
||||
})).min(1).max(20),
|
||||
platform_fee: z.number().int().nonnegative().optional(),
|
||||
hold_minutes: z.number().int().min(5).max(120).optional()
|
||||
});
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Rate limiting
|
||||
const clientIP = getClientIP(request);
|
||||
if (!checkRateLimit(`purchase-attempt:${clientIP}`, 5, 60000)) { // 5 requests per minute
|
||||
return createAuthResponse({ error: 'Rate limit exceeded' }, 429);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input
|
||||
const validation = validateRequest(purchaseAttemptSchema, body);
|
||||
if (!validation.success) {
|
||||
return createAuthResponse({
|
||||
error: 'Invalid request data',
|
||||
details: validation.error
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const {
|
||||
session_id,
|
||||
event_id,
|
||||
purchaser_email,
|
||||
purchaser_name,
|
||||
items,
|
||||
platform_fee,
|
||||
hold_minutes = 30
|
||||
} = validation.data;
|
||||
|
||||
// Sanitize inputs
|
||||
const sanitizedData = {
|
||||
session_id: sanitizeString(session_id),
|
||||
event_id,
|
||||
purchaser_email: sanitizeEmail(purchaser_email),
|
||||
purchaser_name: sanitizeString(purchaser_name),
|
||||
items,
|
||||
platform_fee: platform_fee || 0,
|
||||
hold_minutes
|
||||
};
|
||||
|
||||
// Calculate total amount
|
||||
const total_amount = sanitizedData.items.reduce((sum, item) => sum + (item.quantity * item.unit_price), 0);
|
||||
const expires_at = new Date(Date.now() + (sanitizedData.hold_minutes * 60 * 1000)).toISOString();
|
||||
|
||||
// Create purchase attempt
|
||||
const { data: purchaseAttempt, error: purchaseError } = await supabase
|
||||
.from('purchase_attempts')
|
||||
.insert({
|
||||
session_id: sanitizedData.session_id,
|
||||
event_id: sanitizedData.event_id,
|
||||
purchaser_email: sanitizedData.purchaser_email,
|
||||
purchaser_name: sanitizedData.purchaser_name,
|
||||
total_amount,
|
||||
platform_fee: sanitizedData.platform_fee,
|
||||
expires_at,
|
||||
status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (purchaseError) {
|
||||
throw purchaseError;
|
||||
}
|
||||
|
||||
// Reserve tickets for each item
|
||||
const reservations = [];
|
||||
const purchaseItems = [];
|
||||
|
||||
for (const item of sanitizedData.items) {
|
||||
try {
|
||||
// Reserve tickets
|
||||
const { data: reservationId, error: reserveError } = await supabase
|
||||
.rpc('reserve_tickets', {
|
||||
p_ticket_type_id: item.ticket_type_id,
|
||||
p_quantity: item.quantity,
|
||||
p_reserved_by: sanitizedData.session_id,
|
||||
p_hold_minutes: sanitizedData.hold_minutes,
|
||||
p_seat_ids: item.seat_id ? [item.seat_id] : null
|
||||
});
|
||||
|
||||
if (reserveError) {
|
||||
throw reserveError;
|
||||
}
|
||||
|
||||
reservations.push(reservationId);
|
||||
|
||||
// Create purchase attempt item
|
||||
const { data: purchaseItem, error: itemError } = await supabase
|
||||
.from('purchase_attempt_items')
|
||||
.insert({
|
||||
purchase_attempt_id: purchaseAttempt.id,
|
||||
ticket_type_id: item.ticket_type_id,
|
||||
seat_id: item.seat_id || null,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
total_price: item.quantity * item.unit_price
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (itemError) {
|
||||
throw itemError;
|
||||
}
|
||||
|
||||
purchaseItems.push(purchaseItem);
|
||||
|
||||
// Link reservation to purchase attempt
|
||||
await supabase
|
||||
.from('ticket_reservations')
|
||||
.update({ reserved_for_purchase_id: purchaseAttempt.id })
|
||||
.eq('id', reservationId);
|
||||
|
||||
} catch (itemError) {
|
||||
// If any item fails, clean up previous reservations
|
||||
for (const prevReservationId of reservations) {
|
||||
await supabase
|
||||
.from('ticket_reservations')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', prevReservationId);
|
||||
}
|
||||
|
||||
// Mark purchase attempt as failed
|
||||
await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({
|
||||
status: 'failed',
|
||||
failure_reason: `Failed to reserve tickets: ${itemError.message}`
|
||||
})
|
||||
.eq('id', purchaseAttempt.id);
|
||||
|
||||
throw itemError;
|
||||
}
|
||||
}
|
||||
|
||||
return createAuthResponse({
|
||||
success: true,
|
||||
purchase_attempt: {
|
||||
id: purchaseAttempt.id,
|
||||
session_id: purchaseAttempt.session_id,
|
||||
total_amount: purchaseAttempt.total_amount,
|
||||
platform_fee: purchaseAttempt.platform_fee,
|
||||
expires_at: purchaseAttempt.expires_at,
|
||||
status: purchaseAttempt.status,
|
||||
items: purchaseItems,
|
||||
reservations
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating purchase attempt:', error);
|
||||
return createAuthResponse({
|
||||
error: 'Failed to create purchase attempt'
|
||||
// Don't expose internal error details in production
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
85
src/pages/api/inventory/release.ts
Normal file
85
src/pages/api/inventory/release.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (jsonError) {
|
||||
console.error('JSON parsing error in release endpoint:', jsonError);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid JSON in request body',
|
||||
details: jsonError.message
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const { reservation_id, session_id } = body;
|
||||
|
||||
if (!reservation_id || !session_id) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'reservation_id and session_id are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel the reservation
|
||||
const { data, error } = await supabase
|
||||
.from('ticket_reservations')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', reservation_id)
|
||||
.eq('reserved_by', session_id)
|
||||
.eq('status', 'active')
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Reservation not found or not owned by this session'
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const reservation = data[0];
|
||||
|
||||
// Release any associated seats
|
||||
if (reservation.seat_id) {
|
||||
await supabase
|
||||
.from('seats')
|
||||
.update({
|
||||
is_available: true,
|
||||
reserved_until: null,
|
||||
last_reserved_by: null
|
||||
})
|
||||
.eq('id', reservation.seat_id);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Reservation cancelled and tickets released'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error releasing reservation:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to release reservation',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
102
src/pages/api/inventory/reserve.ts
Normal file
102
src/pages/api/inventory/reserve.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (jsonError) {
|
||||
console.error('JSON parsing error:', jsonError);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid JSON in request body',
|
||||
details: jsonError.message
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
ticket_type_id,
|
||||
quantity,
|
||||
session_id,
|
||||
hold_minutes = 15,
|
||||
seat_ids = null
|
||||
} = body;
|
||||
|
||||
if (!ticket_type_id || !quantity || !session_id) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'ticket_type_id, quantity, and session_id are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Reserve tickets using the database function
|
||||
const { data, error } = await supabase
|
||||
.rpc('reserve_tickets', {
|
||||
p_ticket_type_id: ticket_type_id,
|
||||
p_quantity: quantity,
|
||||
p_reserved_by: session_id,
|
||||
p_hold_minutes: hold_minutes,
|
||||
p_seat_ids: seat_ids
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reservationId = data;
|
||||
|
||||
// Get the reservation details
|
||||
const { data: reservation, error: reservationError } = await supabase
|
||||
.from('ticket_reservations')
|
||||
.select('*')
|
||||
.eq('id', reservationId)
|
||||
.single();
|
||||
|
||||
if (reservationError) {
|
||||
throw reservationError;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
reservation: {
|
||||
id: reservation.id,
|
||||
ticket_type_id: reservation.ticket_type_id,
|
||||
quantity: reservation.quantity,
|
||||
expires_at: reservation.expires_at,
|
||||
seat_id: reservation.seat_id,
|
||||
status: reservation.status
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reserving tickets:', error);
|
||||
|
||||
// Check if it's an availability error
|
||||
if (error.message && error.message.includes('Insufficient tickets available')) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Insufficient tickets available',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 409, // Conflict
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to reserve tickets',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
85
src/pages/api/presale/validate.ts
Normal file
85
src/pages/api/presale/validate.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { code, event_id, customer_email, customer_session } = body;
|
||||
|
||||
if (!code || !event_id) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Code and event_id are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate presale code using database function
|
||||
const { data, error } = await supabase
|
||||
.rpc('validate_presale_code', {
|
||||
p_code: code,
|
||||
p_event_id: event_id,
|
||||
p_customer_email: customer_email || null,
|
||||
p_customer_session: customer_session || null
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = data[0];
|
||||
|
||||
if (!result.is_valid) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: result.error_message
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get accessible ticket types for this presale code
|
||||
const { data: accessibleTicketTypes, error: ticketTypesError } = await supabase
|
||||
.from('presale_code_ticket_types')
|
||||
.select(`
|
||||
ticket_type_id,
|
||||
ticket_types (
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
presale_start_time,
|
||||
presale_end_time
|
||||
)
|
||||
`)
|
||||
.eq('presale_code_id', result.presale_code_id);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
presale_code: {
|
||||
id: result.presale_code_id,
|
||||
discount_type: result.discount_type,
|
||||
discount_value: result.discount_value,
|
||||
uses_remaining: result.uses_remaining,
|
||||
customer_uses_remaining: result.customer_uses_remaining
|
||||
},
|
||||
accessible_ticket_types: accessibleTicketTypes?.map(att => att.ticket_types) || []
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error validating presale code:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to validate presale code',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
155
src/pages/api/printed-tickets.ts
Normal file
155
src/pages/api/printed-tickets.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const eventId = url.searchParams.get('event_id');
|
||||
|
||||
if (!eventId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID is required'
|
||||
}), { 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) {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { barcodes, event_id, ticket_type_id, batch_number, notes, issued_by } = await request.json();
|
||||
|
||||
if (!barcodes || !Array.isArray(barcodes) || barcodes.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Barcodes array is required'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
if (!event_id || !ticket_type_id) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Event ID and ticket type ID are required'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Prepare tickets for insertion
|
||||
const ticketsToInsert = barcodes.map(barcode => ({
|
||||
barcode_number: barcode.trim(),
|
||||
event_id,
|
||||
ticket_type_id,
|
||||
batch_number: batch_number || null,
|
||||
notes: notes || null,
|
||||
issued_by: issued_by || null,
|
||||
status: 'valid'
|
||||
}));
|
||||
|
||||
// Insert tickets
|
||||
const { data: insertedTickets, error: insertError } = await supabase
|
||||
.from('printed_tickets')
|
||||
.insert(ticketsToInsert)
|
||||
.select();
|
||||
|
||||
if (insertError) {
|
||||
// Handle duplicate barcode error
|
||||
if (insertError.code === '23505') {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'One or more barcodes already exist'
|
||||
}), { status: 409 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to insert printed tickets'
|
||||
}), { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully added ${insertedTickets.length} printed tickets`,
|
||||
tickets: insertedTickets
|
||||
}), { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Add printed tickets error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { id, status, notes } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Ticket ID is required'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (status) updateData.status = status;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('printed_tickets')
|
||||
.update(updateData)
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to update printed ticket'
|
||||
}), { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Printed ticket updated successfully'
|
||||
}), { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Update printed ticket error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}), { status: 500 });
|
||||
}
|
||||
};
|
||||
245
src/pages/api/public/events.ts
Normal file
245
src/pages/api/public/events.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { logAPIRequest, logSecurityEvent } from '../../../lib/logger';
|
||||
import { checkRateLimit } from '../../../lib/auth';
|
||||
|
||||
// Handle missing environment variables gracefully
|
||||
const supabaseUrl = process.env.SUPABASE_URL || import.meta.env.SUPABASE_URL || 'https://zctjaivtfyfxokfaemek.supabase.co';
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || import.meta.env.SUPABASE_SERVICE_KEY || '';
|
||||
|
||||
// Create supabase client with fallback handling
|
||||
let supabase: any = null;
|
||||
try {
|
||||
if (supabaseUrl && supabaseServiceKey) {
|
||||
supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle Supabase initialization errors
|
||||
}
|
||||
|
||||
interface PublicEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
venue: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
image_url?: string;
|
||||
slug: string;
|
||||
ticket_url: string;
|
||||
organizer_name: string;
|
||||
category?: string;
|
||||
price_range?: string;
|
||||
is_featured: boolean;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
const startTime = Date.now();
|
||||
const clientIP = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
|
||||
try {
|
||||
// Check if Supabase is available
|
||||
if (!supabase) {
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
events: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
message: 'Service temporarily unavailable'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
});
|
||||
}
|
||||
// Rate limiting - 100 requests per hour per IP
|
||||
if (!checkRateLimit(clientIP, 100, 3600000)) {
|
||||
logSecurityEvent({
|
||||
type: 'rate_limit',
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
severity: 'medium',
|
||||
details: { endpoint: '/api/public/events', limit: 100 }
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Rate limit exceeded. Please try again later.'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const searchParams = url.searchParams;
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100); // Max 100 events
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const category = searchParams.get('category');
|
||||
const search = searchParams.get('search');
|
||||
const featured = searchParams.get('featured') === 'true';
|
||||
const upcoming = searchParams.get('upcoming') !== 'false'; // Default to upcoming only
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
venue,
|
||||
start_time,
|
||||
end_time,
|
||||
image_url,
|
||||
slug,
|
||||
category,
|
||||
is_featured,
|
||||
organizations!inner(name)
|
||||
`)
|
||||
.eq('is_published', true)
|
||||
.eq('is_public', true) // Only show public events
|
||||
.order('start_time', { ascending: true });
|
||||
|
||||
// Filter upcoming events
|
||||
if (upcoming) {
|
||||
query = query.gte('start_time', new Date().toISOString());
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (category) {
|
||||
query = query.eq('category', category);
|
||||
}
|
||||
|
||||
// Filter featured events
|
||||
if (featured) {
|
||||
query = query.eq('is_featured', true);
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
if (search && search.trim()) {
|
||||
const searchTerm = search.trim();
|
||||
query = query.or(`title.ilike.%${searchTerm}%,description.ilike.%${searchTerm}%,venue.ilike.%${searchTerm}%`);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
query = query.range(offset, offset + limit - 1);
|
||||
|
||||
const { data: events, error } = await query;
|
||||
|
||||
if (error) {
|
||||
// Silently handle database errors
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
events: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
message: 'Unable to load events at this time'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Transform data for public consumption
|
||||
const publicEvents: PublicEvent[] = events.map(event => {
|
||||
// Calculate price range from tickets (this would need a separate query in production)
|
||||
const priceRange = 'Free - $50'; // Placeholder - implement based on ticket prices
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description?.substring(0, 200) + (event.description?.length > 200 ? '...' : ''), // Truncate for security
|
||||
venue: event.venue,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
image_url: event.image_url,
|
||||
slug: event.slug,
|
||||
ticket_url: `${process.env.PUBLIC_APP_URL || import.meta.env.PUBLIC_APP_URL || 'http://localhost:4321'}/e/${event.slug}`,
|
||||
organizer_name: event.organizations?.name || 'Event Organizer',
|
||||
category: event.category,
|
||||
price_range: priceRange,
|
||||
is_featured: event.is_featured || false
|
||||
};
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Log API request
|
||||
logAPIRequest({
|
||||
method: 'GET',
|
||||
url: url.pathname + url.search,
|
||||
statusCode: 200,
|
||||
responseTime,
|
||||
ipAddress: clientIP,
|
||||
userAgent
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
events: publicEvents,
|
||||
total: publicEvents.length,
|
||||
hasMore: publicEvents.length === limit,
|
||||
filters: {
|
||||
category,
|
||||
search,
|
||||
featured,
|
||||
upcoming
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Silently handle API errors
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
logAPIRequest({
|
||||
method: 'GET',
|
||||
url: url.pathname + url.search,
|
||||
statusCode: 200,
|
||||
responseTime,
|
||||
ipAddress: clientIP,
|
||||
userAgent
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
events: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
message: 'Service temporarily unavailable'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// OPTIONS handler for CORS
|
||||
export const OPTIONS: APIRoute = async () => {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
});
|
||||
};
|
||||
221
src/pages/api/refunds/process.ts
Normal file
221
src/pages/api/refunds/process.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { requireAuth, getClientIP, checkRateLimit, createAuthResponse } from '../../../lib/auth';
|
||||
import { validateRequest } from '../../../lib/validation';
|
||||
import { stripe } from '../../../lib/stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Validation schema for refund requests
|
||||
const refundSchema = z.object({
|
||||
ticket_id: z.string().uuid(),
|
||||
refund_amount: z.number().positive().max(10000), // Max $100 refund
|
||||
reason: z.string().min(5).max(500) // Reasonable reason length
|
||||
});
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Rate limiting for refund requests
|
||||
const clientIP = getClientIP(request);
|
||||
if (!checkRateLimit(`refund:${clientIP}`, 3, 300000)) { // 3 requests per 5 minutes
|
||||
return createAuthResponse({ error: 'Rate limit exceeded for refund requests' }, 429);
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input
|
||||
const validation = validateRequest(refundSchema, body);
|
||||
if (!validation.success) {
|
||||
return createAuthResponse({
|
||||
error: 'Invalid refund request',
|
||||
details: validation.error
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const { ticket_id, refund_amount, reason } = validation.data;
|
||||
|
||||
// Get ticket with purchase attempt info
|
||||
const { data: ticket, error: ticketError } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
*,
|
||||
purchase_attempts (
|
||||
id,
|
||||
stripe_payment_intent_id,
|
||||
total_amount,
|
||||
purchaser_email,
|
||||
purchaser_name
|
||||
)
|
||||
`)
|
||||
.eq('id', ticket_id)
|
||||
.single();
|
||||
|
||||
if (ticketError || !ticket) {
|
||||
return createAuthResponse({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
// Check if ticket is already refunded
|
||||
if (ticket.refund_status !== 'none') {
|
||||
return createAuthResponse({
|
||||
error: 'Ticket already has a refund request'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Validate refund amount
|
||||
const ticketPrice = parseFloat(ticket.price);
|
||||
if (refund_amount > ticketPrice) {
|
||||
return createAuthResponse({
|
||||
error: 'Refund amount cannot exceed ticket price'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Create refund record
|
||||
const { data: refundRecord, error: refundError } = await supabase
|
||||
.from('refunds')
|
||||
.insert({
|
||||
purchase_attempt_id: ticket.purchase_attempt_id,
|
||||
ticket_id: ticket_id,
|
||||
amount: refund_amount,
|
||||
reason: reason,
|
||||
status: 'pending',
|
||||
processed_by: auth.user.id
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (refundError) {
|
||||
throw refundError;
|
||||
}
|
||||
|
||||
// Update ticket status
|
||||
const { error: ticketUpdateError } = await supabase
|
||||
.from('tickets')
|
||||
.update({
|
||||
refund_status: 'requested',
|
||||
refund_amount: refund_amount,
|
||||
refund_requested_at: new Date().toISOString(),
|
||||
refund_reason: reason,
|
||||
refunded_by: auth.user.id
|
||||
})
|
||||
.eq('id', ticket_id);
|
||||
|
||||
if (ticketUpdateError) {
|
||||
throw ticketUpdateError;
|
||||
}
|
||||
|
||||
// Process Stripe refund if payment intent exists
|
||||
let stripeRefund = null;
|
||||
if (ticket.purchase_attempts?.stripe_payment_intent_id) {
|
||||
try {
|
||||
// Update refund status to processing
|
||||
await supabase
|
||||
.from('refunds')
|
||||
.update({ status: 'processing' })
|
||||
.eq('id', refundRecord.id);
|
||||
|
||||
await supabase
|
||||
.from('tickets')
|
||||
.update({ refund_status: 'processing' })
|
||||
.eq('id', ticket_id);
|
||||
|
||||
// Create Stripe refund
|
||||
stripeRefund = await stripe!.refunds.create({
|
||||
payment_intent: ticket.purchase_attempts.stripe_payment_intent_id,
|
||||
amount: Math.round(refund_amount * 100), // Convert to cents
|
||||
reason: 'requested_by_customer',
|
||||
metadata: {
|
||||
ticket_id: ticket_id,
|
||||
refund_record_id: refundRecord.id,
|
||||
reason: reason
|
||||
}
|
||||
});
|
||||
|
||||
// Update refund with Stripe ID
|
||||
await supabase
|
||||
.from('refunds')
|
||||
.update({
|
||||
stripe_refund_id: stripeRefund.id,
|
||||
status: 'completed',
|
||||
processed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', refundRecord.id);
|
||||
|
||||
// Update ticket status to completed
|
||||
await supabase
|
||||
.from('tickets')
|
||||
.update({
|
||||
refund_status: 'completed',
|
||||
refund_completed_at: new Date().toISOString(),
|
||||
stripe_refund_id: stripeRefund.id
|
||||
})
|
||||
.eq('id', ticket_id);
|
||||
|
||||
// Check if all tickets for this purchase are refunded
|
||||
const { data: allTickets } = await supabase
|
||||
.from('tickets')
|
||||
.select('refund_status')
|
||||
.eq('purchase_attempt_id', ticket.purchase_attempt_id);
|
||||
|
||||
if (allTickets && allTickets.every(t => t.refund_status === 'completed')) {
|
||||
// Mark entire purchase as fully refunded
|
||||
await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({
|
||||
refund_status: 'full',
|
||||
refund_completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', ticket.purchase_attempt_id);
|
||||
} else if (allTickets && allTickets.some(t => t.refund_status === 'completed')) {
|
||||
// Mark purchase as partially refunded
|
||||
await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({
|
||||
refund_status: 'partial',
|
||||
refund_requested_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', ticket.purchase_attempt_id);
|
||||
}
|
||||
|
||||
} catch (stripeError) {
|
||||
console.error('Stripe refund error:', stripeError);
|
||||
|
||||
// Update refund status to failed
|
||||
await supabase
|
||||
.from('refunds')
|
||||
.update({ status: 'failed' })
|
||||
.eq('id', refundRecord.id);
|
||||
|
||||
await supabase
|
||||
.from('tickets')
|
||||
.update({ refund_status: 'failed' })
|
||||
.eq('id', ticket_id);
|
||||
|
||||
return createAuthResponse({
|
||||
error: 'Failed to process refund with Stripe'
|
||||
// Don't expose internal error details
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return createAuthResponse({
|
||||
success: true,
|
||||
refund: {
|
||||
id: refundRecord.id,
|
||||
amount: refund_amount,
|
||||
status: stripeRefund ? 'completed' : 'pending',
|
||||
stripe_refund_id: stripeRefund?.id
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing refund:', error);
|
||||
return createAuthResponse({
|
||||
error: 'Failed to process refund'
|
||||
// Don't expose internal error details in production
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
91
src/pages/api/scanner-lock/disable.ts
Normal file
91
src/pages/api/scanner-lock/disable.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { eventId } = await request.json();
|
||||
|
||||
// Verify user authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(authHeader.replace('Bearer ', ''));
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
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({ error: 'User not found or not in organization' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify event belongs to user's organization
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select('id, organization_id, scanner_lock_enabled')
|
||||
.eq('id', eventId)
|
||||
.eq('organization_id', userData.organization_id)
|
||||
.single();
|
||||
|
||||
if (eventError || !event) {
|
||||
return new Response(JSON.stringify({ error: 'Event not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if scanner lock is enabled
|
||||
if (!event.scanner_lock_enabled) {
|
||||
return new Response(JSON.stringify({ error: 'Scanner lock is not enabled for this event' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Disable scanner lock using database function
|
||||
const { data: disableResult, error: disableError } = await supabase
|
||||
.rpc('disable_scanner_lock', {
|
||||
p_event_id: eventId
|
||||
});
|
||||
|
||||
if (disableError || !disableResult) {
|
||||
console.error('Scanner lock disable error:', disableError);
|
||||
return new Response(JSON.stringify({ error: 'Failed to disable scanner lock' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Scanner lock disabled successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Scanner lock disable error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
112
src/pages/api/scanner-lock/setup.ts
Normal file
112
src/pages/api/scanner-lock/setup.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { hashPin, generateRandomPin, validatePin, type ScannerLockData } from '../../../lib/scanner-lock';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { eventId, pin, organizerEmail } = await request.json();
|
||||
|
||||
// Validate PIN format
|
||||
if (!pin || !validatePin(pin)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'PIN must be exactly 4 digits'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(authHeader.replace('Bearer ', ''));
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
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({ error: 'User not found or not in organization' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify event belongs to user's organization
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select('id, title, start_time, organization_id, scanner_lock_enabled')
|
||||
.eq('id', eventId)
|
||||
.eq('organization_id', userData.organization_id)
|
||||
.single();
|
||||
|
||||
if (eventError || !event) {
|
||||
return new Response(JSON.stringify({ error: 'Event not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if scanner lock is already enabled
|
||||
if (event.scanner_lock_enabled) {
|
||||
return new Response(JSON.stringify({ error: 'Scanner lock is already enabled for this event' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Hash the PIN
|
||||
const pinHash = await hashPin(pin);
|
||||
|
||||
// Setup scanner lock using database function
|
||||
const { data: setupResult, error: setupError } = await supabase
|
||||
.rpc('setup_scanner_lock', {
|
||||
p_event_id: eventId,
|
||||
p_pin_hash: pinHash
|
||||
});
|
||||
|
||||
if (setupError || !setupResult) {
|
||||
console.error('Scanner lock setup error:', setupError);
|
||||
return new Response(JSON.stringify({ error: 'Failed to setup scanner lock' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Return success response with event details for email
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
event: {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start_time: event.start_time
|
||||
},
|
||||
pin // Return the PIN for email purposes - this will be sent securely
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Scanner lock setup error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
112
src/pages/api/scanner-lock/verify.ts
Normal file
112
src/pages/api/scanner-lock/verify.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { verifyPin, getDeviceInfo, type UnlockAttemptData } from '../../../lib/scanner-lock';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { eventId, pin } = await request.json();
|
||||
|
||||
// Get IP address and user agent for logging
|
||||
const ipAddress = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('cf-connecting-ip') ||
|
||||
'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
const deviceInfo = getDeviceInfo(userAgent);
|
||||
|
||||
// Verify user authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(authHeader.replace('Bearer ', ''));
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
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({ error: 'User not found or not in organization' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get event with scanner lock info
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select('id, title, organization_id, scanner_lock_enabled, scanner_pin_hash')
|
||||
.eq('id', eventId)
|
||||
.eq('organization_id', userData.organization_id)
|
||||
.single();
|
||||
|
||||
if (eventError || !event) {
|
||||
return new Response(JSON.stringify({ error: 'Event not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if scanner lock is enabled
|
||||
if (!event.scanner_lock_enabled || !event.scanner_pin_hash) {
|
||||
return new Response(JSON.stringify({ error: 'Scanner lock is not enabled for this event' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify PIN
|
||||
const isValidPin = await verifyPin(pin, event.scanner_pin_hash);
|
||||
|
||||
// Log the unlock attempt
|
||||
const attemptResult = isValidPin ? 'SUCCESS' : 'INVALID_PIN';
|
||||
|
||||
await supabase
|
||||
.from('scanner_unlock_attempts')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
attempted_by: user.id,
|
||||
attempt_result: attemptResult,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
device_info: deviceInfo
|
||||
});
|
||||
|
||||
if (isValidPin) {
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'PIN verified successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Invalid PIN'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Scanner lock verification error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
176
src/pages/api/send-pin-email.ts
Normal file
176
src/pages/api/send-pin-email.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { event, pin, email, type = 'immediate' } = await request.json();
|
||||
|
||||
if (!event || !pin || !email) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate PIN format
|
||||
if (!/^\d{4}$/.test(pin)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid PIN format' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const eventTime = new Date(event.start_time).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
let subject: string;
|
||||
let htmlContent: string;
|
||||
|
||||
if (type === 'immediate') {
|
||||
subject = `Scanner Lock PIN for ${event.title}`;
|
||||
htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Scanner Lock PIN</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="margin: 0; font-size: 28px;">🔒 Scanner Lock PIN</h1>
|
||||
<p style="margin: 10px 0 0 0; font-size: 16px; opacity: 0.9;">Black Canyon Tickets</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||
<h2 style="color: #667eea; margin: 0 0 15px 0;">Your Scanner Access PIN</h2>
|
||||
<p style="margin: 0 0 15px 0;">Your scanner has been locked for the event:</p>
|
||||
<p style="font-weight: bold; font-size: 18px; margin: 0 0 15px 0; color: #333;">${event.title}</p>
|
||||
<p style="margin: 0 0 15px 0;">Date: ${eventDate} at ${eventTime}</p>
|
||||
|
||||
<div style="background: white; border: 2px solid #667eea; border-radius: 8px; padding: 20px; text-align: center; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 16px;">Your PIN is:</p>
|
||||
<div style="font-size: 32px; font-weight: bold; font-family: monospace; color: #667eea; letter-spacing: 8px;">${pin}</div>
|
||||
</div>
|
||||
|
||||
<p style="margin: 15px 0 0 0; font-size: 14px; color: #666;">
|
||||
Use this PIN to unlock your scanner if you need to exit scan-only mode.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 8px; padding: 20px; margin-bottom: 25px;">
|
||||
<h3 style="color: #856404; margin: 0 0 10px 0;">Important Security Information</h3>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #856404;">
|
||||
<li>Keep this PIN secure and do not share it with unauthorized personnel</li>
|
||||
<li>The scanner is now locked to scan-only mode for security</li>
|
||||
<li>You will receive a reminder email when your event starts</li>
|
||||
<li>The PIN will be required to unlock and return to normal portal access</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||
Sent by Black Canyon Tickets Scanner Lock System<br>
|
||||
<a href="https://portal.blackcanyontickets.com" style="color: #667eea;">portal.blackcanyontickets.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
} else {
|
||||
// Reminder email
|
||||
subject = `Reminder: Scanner Lock PIN for ${event.title}`;
|
||||
htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Scanner Lock PIN Reminder</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="margin: 0; font-size: 28px;">🔔 Scanner PIN Reminder</h1>
|
||||
<p style="margin: 10px 0 0 0; font-size: 16px; opacity: 0.9;">Your Event is Starting Soon</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||
<h2 style="color: #667eea; margin: 0 0 15px 0;">Scanner Lock PIN Reminder</h2>
|
||||
<p style="margin: 0 0 15px 0;">Your event is starting! Here's your scanner PIN in case you need to unlock your device:</p>
|
||||
<p style="font-weight: bold; font-size: 18px; margin: 0 0 15px 0; color: #333;">${event.title}</p>
|
||||
<p style="margin: 0 0 15px 0;">Date: ${eventDate} at ${eventTime}</p>
|
||||
|
||||
<div style="background: white; border: 2px solid #667eea; border-radius: 8px; padding: 20px; text-align: center; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 16px;">Your PIN is:</p>
|
||||
<div style="font-size: 32px; font-weight: bold; font-family: monospace; color: #667eea; letter-spacing: 8px;">${pin}</div>
|
||||
</div>
|
||||
|
||||
<p style="margin: 15px 0 0 0; font-size: 14px; color: #666;">
|
||||
Use this PIN to unlock your scanner if you need to exit scan-only mode during the event.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #e8f5e8; border: 1px solid #c3e6c3; border-radius: 8px; padding: 20px; margin-bottom: 25px;">
|
||||
<h3 style="color: #2d5a2d; margin: 0 0 10px 0;">Event Day Reminders</h3>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #2d5a2d;">
|
||||
<li>Your scanner is locked and ready for secure ticket scanning</li>
|
||||
<li>Staff can only scan tickets - no other portal access</li>
|
||||
<li>Use the PIN above to unlock if you need administrative access</li>
|
||||
<li>Keep the PIN secure throughout the event</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||
Sent by Black Canyon Tickets Scanner Lock System<br>
|
||||
<a href="https://portal.blackcanyontickets.com" style="color: #667eea;">portal.blackcanyontickets.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// Send email
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: 'Scanner Lock <scanner@blackcanyontickets.com>',
|
||||
to: [email],
|
||||
subject,
|
||||
html: htmlContent
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Email sending error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Failed to send email' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
emailId: data?.id,
|
||||
message: 'Email sent successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Send PIN email error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
107
src/pages/api/send-reminder-emails.ts
Normal file
107
src/pages/api/send-reminder-emails.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// This endpoint should be called by a cron job or scheduled task
|
||||
// It finds events that are starting soon and sends reminder emails
|
||||
|
||||
const now = new Date();
|
||||
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
|
||||
|
||||
// Find events starting within the next hour that have scanner lock enabled
|
||||
const { data: events, error } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
start_time,
|
||||
scanner_lock_enabled,
|
||||
scanner_pin_hash,
|
||||
scanner_lock_created_by,
|
||||
users!scanner_lock_created_by (
|
||||
email,
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('scanner_lock_enabled', true)
|
||||
.gte('start_time', now.toISOString())
|
||||
.lte('start_time', oneHourFromNow.toISOString());
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch events' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'No events found that need reminder emails'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const emailPromises = events.map(async (event) => {
|
||||
if (!event.users || !event.users.email) {
|
||||
console.warn(`No email found for event ${event.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// For security, we can't retrieve the original PIN from the hash
|
||||
// So we'll send a reminder without the PIN, asking them to use the original email
|
||||
const response = await fetch('/api/send-pin-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event: {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start_time: event.start_time
|
||||
},
|
||||
pin: '****', // Hide the PIN in reminder
|
||||
email: event.users.email,
|
||||
type: 'reminder'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to send reminder email for event ${event.id}:`, result.error);
|
||||
return { eventId: event.id, success: false, error: result.error };
|
||||
}
|
||||
|
||||
return { eventId: event.id, success: true, emailId: result.emailId };
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(emailPromises);
|
||||
|
||||
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.success).length;
|
||||
const failureCount = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value?.success)).length;
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: `Processed ${events.length} events`,
|
||||
results: {
|
||||
total: events.length,
|
||||
successful: successCount,
|
||||
failed: failureCount
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Send reminder emails error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
328
src/pages/api/webhooks/stripe.ts
Normal file
328
src/pages/api/webhooks/stripe.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import Stripe from 'stripe';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { sendTicketConfirmationEmail, sendOrderConfirmationEmail, sendOrganizerNotificationEmail } from '../../../lib/email';
|
||||
import { logPaymentEvent } from '../../../lib/logger';
|
||||
|
||||
// Initialize Stripe with the secret key
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2024-06-20'
|
||||
});
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
if (!endpointSecret) {
|
||||
throw new Error('Missing STRIPE_WEBHOOK_SECRET environment variable');
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.text();
|
||||
const signature = request.headers.get('stripe-signature');
|
||||
|
||||
if (!signature) {
|
||||
console.error('Missing Stripe signature header');
|
||||
return new Response('Missing signature', { status: 400 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
// Verify the webhook signature
|
||||
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err);
|
||||
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
|
||||
break;
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
await handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
|
||||
break;
|
||||
|
||||
case 'charge.dispute.created':
|
||||
await handleChargeDispute(event.data.object as Stripe.Dispute);
|
||||
break;
|
||||
|
||||
case 'account.updated':
|
||||
await handleAccountUpdated(event.data.object as Stripe.Account);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Webhook handler error:', error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
|
||||
console.log('Payment succeeded:', paymentIntent.id);
|
||||
|
||||
try {
|
||||
// Log payment event
|
||||
logPaymentEvent({
|
||||
type: 'payment_completed',
|
||||
amount: paymentIntent.amount,
|
||||
currency: paymentIntent.currency,
|
||||
paymentIntentId: paymentIntent.id
|
||||
});
|
||||
|
||||
// Find the purchase attempt by payment intent ID
|
||||
const { data: purchaseAttempt, error: findError } = await supabase
|
||||
.from('purchase_attempts')
|
||||
.select(`
|
||||
*,
|
||||
events (
|
||||
title,
|
||||
venue,
|
||||
start_time,
|
||||
description,
|
||||
created_by,
|
||||
users (name, email)
|
||||
)
|
||||
`)
|
||||
.eq('stripe_payment_intent_id', paymentIntent.id)
|
||||
.single();
|
||||
|
||||
if (findError || !purchaseAttempt) {
|
||||
console.error('Purchase attempt not found for payment intent:', paymentIntent.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update purchase attempt status
|
||||
const { error: updateError } = await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({
|
||||
status: 'completed',
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', purchaseAttempt.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating purchase attempt:', updateError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create tickets for each item in the purchase
|
||||
const { data: purchaseItems, error: itemsError } = await supabase
|
||||
.from('purchase_attempt_items')
|
||||
.select(`
|
||||
*,
|
||||
ticket_types (name, description),
|
||||
seats (row, number)
|
||||
`)
|
||||
.eq('purchase_attempt_id', purchaseAttempt.id);
|
||||
|
||||
if (itemsError || !purchaseItems) {
|
||||
console.error('Error fetching purchase items:', itemsError);
|
||||
return;
|
||||
}
|
||||
|
||||
const tickets = [];
|
||||
const orderTickets = [];
|
||||
|
||||
for (const item of purchaseItems) {
|
||||
for (let i = 0; i < item.quantity; i++) {
|
||||
const { data: ticket, error: ticketError } = await supabase
|
||||
.from('tickets')
|
||||
.insert({
|
||||
event_id: purchaseAttempt.event_id,
|
||||
ticket_type_id: item.ticket_type_id,
|
||||
seat_id: item.seat_id,
|
||||
price: item.unit_price,
|
||||
purchaser_email: purchaseAttempt.purchaser_email,
|
||||
purchaser_name: purchaseAttempt.purchaser_name,
|
||||
purchase_attempt_id: purchaseAttempt.id,
|
||||
stripe_payment_intent_id: paymentIntent.id,
|
||||
status: 'valid'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (ticketError) {
|
||||
console.error('Error creating ticket:', ticketError);
|
||||
continue;
|
||||
}
|
||||
|
||||
tickets.push(ticket);
|
||||
|
||||
// Send individual ticket confirmation email
|
||||
try {
|
||||
await sendTicketConfirmationEmail({
|
||||
ticketId: ticket.id,
|
||||
ticketUuid: ticket.uuid,
|
||||
eventTitle: purchaseAttempt.events.title,
|
||||
eventVenue: purchaseAttempt.events.venue,
|
||||
eventDate: new Date(purchaseAttempt.events.start_time).toLocaleDateString(),
|
||||
eventTime: new Date(purchaseAttempt.events.start_time).toLocaleTimeString(),
|
||||
ticketType: item.ticket_types.name,
|
||||
seatInfo: item.seats ? `Row ${item.seats.row}, Seat ${item.seats.number}` : undefined,
|
||||
price: item.unit_price,
|
||||
purchaserName: purchaseAttempt.purchaser_name,
|
||||
purchaserEmail: purchaseAttempt.purchaser_email,
|
||||
organizerName: purchaseAttempt.events.users.name,
|
||||
organizerEmail: purchaseAttempt.events.users.email,
|
||||
qrCodeUrl: '', // Will be generated in email function
|
||||
orderNumber: purchaseAttempt.id,
|
||||
totalAmount: purchaseAttempt.total_amount,
|
||||
platformFee: purchaseAttempt.platform_fee,
|
||||
eventDescription: purchaseAttempt.events.description,
|
||||
additionalInfo: 'Please arrive 15 minutes early for entry.'
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Error sending ticket confirmation email:', emailError);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to order summary
|
||||
orderTickets.push({
|
||||
type: item.ticket_types.name,
|
||||
quantity: item.quantity,
|
||||
price: item.unit_price,
|
||||
seatInfo: item.seats ? `Row ${item.seats.row}, Seat ${item.seats.number}` : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// Send order confirmation email
|
||||
try {
|
||||
await sendOrderConfirmationEmail({
|
||||
orderNumber: purchaseAttempt.id,
|
||||
purchaserName: purchaseAttempt.purchaser_name,
|
||||
purchaserEmail: purchaseAttempt.purchaser_email,
|
||||
eventTitle: purchaseAttempt.events.title,
|
||||
eventVenue: purchaseAttempt.events.venue,
|
||||
eventDate: new Date(purchaseAttempt.events.start_time).toLocaleDateString(),
|
||||
totalAmount: purchaseAttempt.total_amount,
|
||||
platformFee: purchaseAttempt.platform_fee,
|
||||
tickets: orderTickets,
|
||||
organizerName: purchaseAttempt.events.users.name,
|
||||
refundPolicy: 'Refunds available up to 24 hours before the event.'
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Error sending order confirmation email:', emailError);
|
||||
}
|
||||
|
||||
// Send organizer notification
|
||||
try {
|
||||
await sendOrganizerNotificationEmail({
|
||||
organizerEmail: purchaseAttempt.events.users.email,
|
||||
organizerName: purchaseAttempt.events.users.name,
|
||||
eventTitle: purchaseAttempt.events.title,
|
||||
purchaserName: purchaseAttempt.purchaser_name,
|
||||
purchaserEmail: purchaseAttempt.purchaser_email,
|
||||
ticketType: orderTickets.map(t => `${t.quantity}x ${t.type}`).join(', '),
|
||||
amount: purchaseAttempt.total_amount - purchaseAttempt.platform_fee,
|
||||
orderNumber: purchaseAttempt.id
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Error sending organizer notification email:', emailError);
|
||||
}
|
||||
|
||||
console.log(`Created ${tickets.length} tickets and sent confirmation emails for payment ${paymentIntent.id}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing successful payment:', error);
|
||||
|
||||
// Log payment error
|
||||
logPaymentEvent({
|
||||
type: 'payment_failed',
|
||||
amount: paymentIntent.amount,
|
||||
currency: paymentIntent.currency,
|
||||
paymentIntentId: paymentIntent.id,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
|
||||
console.log('Payment failed:', paymentIntent.id);
|
||||
|
||||
try {
|
||||
// Update purchase attempt status
|
||||
const { error } = await supabase
|
||||
.from('purchase_attempts')
|
||||
.update({
|
||||
status: 'failed',
|
||||
failure_reason: 'Payment failed'
|
||||
})
|
||||
.eq('stripe_payment_intent_id', paymentIntent.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating failed purchase attempt:', error);
|
||||
}
|
||||
|
||||
// Release any reserved tickets
|
||||
const { error: releaseError } = await supabase
|
||||
.rpc('release_reservations_by_payment_intent', {
|
||||
p_payment_intent_id: paymentIntent.id
|
||||
});
|
||||
|
||||
if (releaseError) {
|
||||
console.error('Error releasing reservations:', releaseError);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing failed payment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChargeDispute(dispute: Stripe.Dispute) {
|
||||
console.log('Charge dispute created:', dispute.id);
|
||||
|
||||
try {
|
||||
// Log the dispute for manual review
|
||||
await supabase
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
action: 'dispute_created',
|
||||
resource_type: 'charge',
|
||||
resource_id: dispute.charge as string,
|
||||
old_values: null,
|
||||
new_values: {
|
||||
dispute_id: dispute.id,
|
||||
amount: dispute.amount,
|
||||
reason: dispute.reason,
|
||||
status: dispute.status
|
||||
},
|
||||
ip_address: null,
|
||||
user_agent: 'stripe-webhook'
|
||||
});
|
||||
|
||||
// TODO: Send alert to admin team
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing dispute:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccountUpdated(account: Stripe.Account) {
|
||||
console.log('Stripe Connect account updated:', account.id);
|
||||
|
||||
try {
|
||||
// Update organization with latest account status
|
||||
const { error } = await supabase
|
||||
.from('organizations')
|
||||
.update({
|
||||
stripe_account_status: account.charges_enabled ? 'active' : 'pending'
|
||||
})
|
||||
.eq('stripe_account_id', account.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating organization account status:', error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing account update:', error);
|
||||
}
|
||||
}
|
||||
1142
src/pages/calendar.astro
Normal file
1142
src/pages/calendar.astro
Normal file
File diff suppressed because it is too large
Load Diff
610
src/pages/dashboard.astro
Normal file
610
src/pages/dashboard.astro
Normal file
@@ -0,0 +1,610 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
---
|
||||
|
||||
<Layout title="Dashboard - Black Canyon Tickets">
|
||||
<style>
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slideIn {
|
||||
animation: slideIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.hover-float:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<!-- Animated background elements -->
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-purple-600/20 to-pink-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-blue-600/20 to-cyan-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-gradient-to-br from-indigo-600/10 to-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
<Navigation title="Dashboard" />
|
||||
|
||||
<main class="relative max-w-7xl mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<div class="px-4 sm:px-0">
|
||||
<!-- Dashboard Header -->
|
||||
<div class="mb-12 animate-slideIn">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
|
||||
<div>
|
||||
<h1 class="text-4xl md:text-5xl font-light text-white tracking-wide">Dashboard</h1>
|
||||
<p class="text-white/80 mt-2 text-lg font-light">Manage your events and track performance</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<button
|
||||
id="toggle-view-btn"
|
||||
class="bg-white/10 backdrop-blur-lg border border-white/20 hover:bg-white/20 text-white px-6 py-3 rounded-xl text-sm font-medium transition-all duration-200 shadow-lg shadow-black/10 flex items-center gap-2 hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span id="view-text">Calendar View</span>
|
||||
</button>
|
||||
<a
|
||||
href="/settings/fees"
|
||||
class="bg-white/10 backdrop-blur-lg border border-white/20 hover:bg-white/20 text-white px-6 py-3 rounded-xl text-sm font-medium transition-all duration-200 shadow-lg shadow-black/10 flex items-center gap-2 hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Fee Settings
|
||||
</a>
|
||||
<a
|
||||
href="/events/new"
|
||||
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 text-sm font-medium transition-all duration-200 shadow-lg shadow-blue-500/30 flex items-center gap-2 hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Event
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats Cards -->
|
||||
<div id="stats-cards" class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<!-- Stats will be populated here -->
|
||||
</div>
|
||||
|
||||
<!-- Calendar View -->
|
||||
<div id="calendar-view" class="hidden mb-8">
|
||||
<div id="calendar-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div id="list-view">
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg overflow-hidden">
|
||||
<div class="px-8 py-6 border-b border-white/20 bg-gradient-to-r from-white/10 to-white/5">
|
||||
<h3 class="text-xl font-light text-white tracking-wide">Your Events</h3>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<div id="events-container" class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Events will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="text-center py-16">
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-12 max-w-md mx-auto">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-2 border-blue-400 border-t-transparent mx-auto mb-6"></div>
|
||||
<p class="text-white/80 font-light text-lg">Loading your events...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="no-events" class="text-center py-16 hidden">
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-16 max-w-lg mx-auto">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="h-10 w-10 text-white/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-light text-white mb-3 tracking-wide">No events yet</h3>
|
||||
<p class="text-white/80 mb-8 text-lg font-light leading-relaxed">Get started by creating your first event and start selling tickets.</p>
|
||||
<a
|
||||
href="/events/new"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-medium rounded-xl transition-all duration-200 shadow-lg shadow-blue-500/30 hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Your First Event
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const eventsContainer = document.getElementById('events-container');
|
||||
const loading = document.getElementById('loading');
|
||||
const noEvents = document.getElementById('no-events');
|
||||
const toggleViewBtn = document.getElementById('toggle-view-btn');
|
||||
const viewText = document.getElementById('view-text');
|
||||
const calendarView = document.getElementById('calendar-view');
|
||||
const listView = document.getElementById('list-view');
|
||||
const calendarContainer = document.getElementById('calendar-container');
|
||||
const statsCards = document.getElementById('stats-cards');
|
||||
|
||||
let currentView = 'list';
|
||||
let allEvents = [];
|
||||
|
||||
// Check authentication (simplified since Navigation component handles user display)
|
||||
async function checkAuth() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
// Load events
|
||||
async function loadEvents() {
|
||||
try {
|
||||
// Check if user has organization_id or is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const { data: userProfile, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id, role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('Error loading user profile:', userError);
|
||||
console.error('User ID:', user?.id);
|
||||
loading.innerHTML = `
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-6 max-w-md mx-auto">
|
||||
<p class="text-red-600 font-medium">Error loading user profile</p>
|
||||
<p class="text-red-500 text-sm mt-2">${userError.message || userError}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('User profile loaded:', userProfile);
|
||||
|
||||
// Check if user is admin or has organization_id
|
||||
const isAdmin = userProfile?.role === 'admin';
|
||||
if (!isAdmin && !userProfile?.organization_id) {
|
||||
console.log('User has no organization_id and is not admin, showing no events');
|
||||
loading.classList.add('hidden');
|
||||
noEvents.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load events based on user permissions
|
||||
let query = supabase.from('events').select('*');
|
||||
|
||||
if (!isAdmin) {
|
||||
// Regular users see only their organization's events
|
||||
query = query.eq('organization_id', userProfile.organization_id);
|
||||
}
|
||||
// Admins see all events (no filter)
|
||||
|
||||
const { data: events, error } = await query.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading events from database:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('Successfully loaded events:', events?.length || 0, 'events');
|
||||
allEvents = events || [];
|
||||
loading.classList.add('hidden');
|
||||
|
||||
if (allEvents.length === 0) {
|
||||
noEvents.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
renderStatsCards();
|
||||
renderListView();
|
||||
if (currentView === 'calendar') {
|
||||
renderCalendarView();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading events:', error);
|
||||
loading.innerHTML = `
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-6 max-w-md mx-auto">
|
||||
<p class="text-red-600 font-medium">Error loading events</p>
|
||||
<p class="text-red-500 text-sm mt-2">${error.message || error}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render stats cards
|
||||
function renderStatsCards() {
|
||||
const totalEvents = allEvents.length;
|
||||
const upcomingEvents = allEvents.filter(event => new Date(event.start_time) > new Date()).length;
|
||||
const pastEvents = totalEvents - upcomingEvents;
|
||||
|
||||
statsCards.innerHTML = `
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-8 hover:shadow-xl hover:-translate-y-1 hover:scale-105 transition-all duration-200 ease-out cursor-pointer group">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wider">Total Events</p>
|
||||
<p class="text-3xl font-light text-white mt-2 group-hover:text-blue-400 transition-colors">${totalEvents}</p>
|
||||
</div>
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-2xl flex items-center justify-center shadow-lg group-hover:shadow-lg transition-all duration-200 ease-out">
|
||||
<svg class="w-7 h-7 text-blue-400 group-hover:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-8 hover:shadow-xl hover:-translate-y-1 hover:scale-105 transition-all duration-200 ease-out cursor-pointer group">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wider">Upcoming Events</p>
|
||||
<p class="text-3xl font-light text-white mt-2 group-hover:text-emerald-400 transition-colors">${upcomingEvents}</p>
|
||||
</div>
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-emerald-500/20 to-teal-500/20 rounded-2xl flex items-center justify-center shadow-lg group-hover:shadow-lg transition-all duration-200 ease-out">
|
||||
<svg class="w-7 h-7 text-emerald-400 group-hover:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg p-8 hover:shadow-xl hover:-translate-y-1 hover:scale-105 transition-all duration-200 ease-out cursor-pointer group">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white/80 uppercase tracking-wider">Past Events</p>
|
||||
<p class="text-3xl font-light text-white mt-2 group-hover:text-slate-400 transition-colors">${pastEvents}</p>
|
||||
</div>
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-slate-500/20 to-gray-500/20 rounded-2xl flex items-center justify-center shadow-lg group-hover:shadow-lg transition-all duration-200 ease-out">
|
||||
<svg class="w-7 h-7 text-slate-400 group-hover:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render list view
|
||||
function renderListView() {
|
||||
eventsContainer.innerHTML = allEvents.map((event, index) => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
const isUpcoming = eventDate > new Date();
|
||||
const formattedDate = eventDate.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const formattedTime = eventDate.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const animationDelay = index * 0.1;
|
||||
return `
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg hover:shadow-xl hover:-translate-y-1 hover:scale-105 transition-all duration-200 ease-out overflow-hidden group">
|
||||
<div class="p-8">
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-light text-white mb-3 tracking-wide group-hover:text-white/90 transition-colors duration-200">${event.title}</h3>
|
||||
<div class="flex items-center text-sm text-white/80 mb-3 group-hover:text-white/90 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" 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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
${event.venue}
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-white/80 group-hover:text-white/90 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
${formattedDate} at ${formattedTime}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="px-3 py-1 text-xs font-semibold rounded-full ${isUpcoming ? 'bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-400' : 'bg-gradient-to-r from-slate-500/20 to-gray-500/20 text-slate-400'} transition-all duration-200 ease-out">
|
||||
${isUpcoming ? 'Upcoming' : 'Past'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${event.description ? `<p class="text-sm text-white/70 mb-6 leading-relaxed group-hover:text-white/80 transition-colors">${event.description}</p>` : ''}
|
||||
|
||||
<div class="flex items-center justify-between pt-6 border-t border-white/20">
|
||||
<div class="flex space-x-3">
|
||||
<a
|
||||
href="/events/${event.id}/manage"
|
||||
class="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 shadow-lg shadow-blue-500/25 hover:shadow-lg hover:-translate-y-0.5 hover:scale-105"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Manage
|
||||
</a>
|
||||
<a
|
||||
href="/e/${event.slug}"
|
||||
class="inline-flex items-center gap-2 border border-white/20 hover:bg-white/10 text-white px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 shadow-lg shadow-black/10 hover:shadow-lg hover:-translate-y-0.5 hover:scale-105"
|
||||
target="_blank"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Preview
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-xs text-white/60 font-medium">
|
||||
Created ${new Date(event.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Render calendar view
|
||||
function renderCalendarView() {
|
||||
// Initialize calendar with current date or previously selected date
|
||||
calendarContainer.innerHTML = createSimpleCalendar(allEvents, currentCalendarYear, currentCalendarMonth);
|
||||
}
|
||||
|
||||
// Enhanced calendar HTML generator with mobile responsiveness
|
||||
function createSimpleCalendar(events, year, month) {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
|
||||
let html = `
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-lg overflow-hidden">
|
||||
<!-- Calendar Header -->
|
||||
<div class="px-8 py-6 bg-gradient-to-r from-white/10 to-white/5 border-b border-white/20 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-2xl font-light text-white tracking-wide">${monthNames[month]} ${year}</h3>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="navigateMonth(-1)" class="p-3 hover:bg-white/10 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl hover:scale-105">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="navigateMonth(1)" class="p-3 hover:bg-white/10 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl hover:scale-105">
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Body -->
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Day Headers -->
|
||||
<div class="grid grid-cols-7 gap-1 mb-4">
|
||||
<div class="text-center text-xs sm:text-sm font-semibold text-white/80 py-2">Sun</div>
|
||||
<div class="text-center text-xs sm:text-sm font-semibold text-white/80 py-2">Mon</div>
|
||||
<div class="text-center text-xs sm:text-sm font-semibold text-white/80 py-2">Tue</div>
|
||||
<div class="text-center text-xs sm:text-sm font-semibold text-white/80 py-2">Wed</div>
|
||||
<div class="text-center text-xs sm:text-sm font-semibold text-white/80 py-2">Thu</div>
|
||||
<div class="text-center text-xs sm:text-sm font-semibold text-white/80 py-2">Fri</div>
|
||||
<div class="text-center text-xs sm:text-sm font-semibold text-white/80 py-2">Sat</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="grid grid-cols-7 gap-1 sm:gap-2">
|
||||
`;
|
||||
|
||||
// Empty cells for days before month starts
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
html += '<div class="aspect-square"></div>';
|
||||
}
|
||||
|
||||
// Days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayEvents = events.filter(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return eventDate.getDate() === day &&
|
||||
eventDate.getMonth() === month &&
|
||||
eventDate.getFullYear() === year;
|
||||
});
|
||||
|
||||
const isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
|
||||
const hasEvents = dayEvents.length > 0;
|
||||
|
||||
html += `
|
||||
<div class="aspect-square border rounded-lg p-1 sm:p-2 hover:bg-white/10 transition-colors cursor-pointer ${
|
||||
isToday ? 'bg-blue-500/20 border-blue-400 ring-2 ring-blue-400/20' :
|
||||
hasEvents ? 'border-white/30 bg-white/5' : 'border-white/20'
|
||||
}" onclick="showDayEvents(${year}, ${month}, ${day})">
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="text-xs sm:text-sm font-semibold mb-1 ${
|
||||
isToday ? 'text-blue-400' : 'text-white'
|
||||
}">${day}</div>
|
||||
|
||||
<!-- Mobile: Show event dots -->
|
||||
<div class="flex-1 sm:hidden">
|
||||
${dayEvents.length > 0 ? `
|
||||
<div class="flex flex-wrap gap-0.5">
|
||||
${dayEvents.slice(0, 3).map(() => `
|
||||
<div class="w-1.5 h-1.5 bg-blue-400 rounded-full"></div>
|
||||
`).join('')}
|
||||
${dayEvents.length > 3 ? '<div class="text-xs text-white/60">+</div>' : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Show event titles -->
|
||||
<div class="hidden sm:block flex-1 space-y-1 overflow-hidden">
|
||||
${dayEvents.slice(0, 2).map(event => `
|
||||
<div class="text-xs bg-blue-500/20 text-blue-400 rounded px-1.5 py-0.5 truncate font-medium"
|
||||
title="${event.title} at ${event.venue}">
|
||||
${event.title}
|
||||
</div>
|
||||
`).join('')}
|
||||
${dayEvents.length > 2 ? `<div class="text-xs text-white/60 font-medium">+${dayEvents.length - 2} more</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Event List (shows when day is clicked) -->
|
||||
<div id="mobile-day-events" class="hidden sm:hidden mt-4 bg-white/10 backdrop-blur-xl border border-white/20 rounded-xl shadow-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 id="selected-day-title" class="font-semibold text-white"></h4>
|
||||
<button onclick="hideMobileDayEvents()" class="p-1 hover:bg-white/10 rounded">
|
||||
<svg class="w-4 h-4 text-white/80" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="selected-day-events" class="space-y-2">
|
||||
<!-- Events will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Calendar navigation
|
||||
let currentCalendarYear = new Date().getFullYear();
|
||||
let currentCalendarMonth = new Date().getMonth();
|
||||
|
||||
window.navigateMonth = function(direction) {
|
||||
currentCalendarMonth += direction;
|
||||
if (currentCalendarMonth < 0) {
|
||||
currentCalendarMonth = 11;
|
||||
currentCalendarYear--;
|
||||
} else if (currentCalendarMonth > 11) {
|
||||
currentCalendarMonth = 0;
|
||||
currentCalendarYear++;
|
||||
}
|
||||
calendarContainer.innerHTML = createSimpleCalendar(allEvents, currentCalendarYear, currentCalendarMonth);
|
||||
};
|
||||
|
||||
// Show day events on mobile
|
||||
window.showDayEvents = function(year, month, day) {
|
||||
const dayEvents = allEvents.filter(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return eventDate.getDate() === day &&
|
||||
eventDate.getMonth() === month &&
|
||||
eventDate.getFullYear() === year;
|
||||
});
|
||||
|
||||
if (dayEvents.length === 0) return;
|
||||
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const mobileDayEvents = document.getElementById('mobile-day-events');
|
||||
const selectedDayTitle = document.getElementById('selected-day-title');
|
||||
const selectedDayEventsContainer = document.getElementById('selected-day-events');
|
||||
|
||||
selectedDayTitle.textContent = `${monthNames[month]} ${day}, ${year}`;
|
||||
selectedDayEventsContainer.innerHTML = dayEvents.map(event => `
|
||||
<div class="flex items-start gap-3 p-3 bg-white/10 backdrop-blur-lg rounded-lg">
|
||||
<div class="w-2 h-2 bg-blue-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h5 class="font-medium text-white text-sm">${event.title}</h5>
|
||||
<p class="text-xs text-white/80 mt-1">${event.venue}</p>
|
||||
<p class="text-xs text-white/60 mt-1">${new Date(event.start_time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
mobileDayEvents.classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.hideMobileDayEvents = function() {
|
||||
document.getElementById('mobile-day-events').classList.add('hidden');
|
||||
};
|
||||
|
||||
// Toggle between views
|
||||
function toggleView() {
|
||||
if (currentView === 'list') {
|
||||
currentView = 'calendar';
|
||||
viewText.textContent = 'List View';
|
||||
calendarView.classList.remove('hidden');
|
||||
listView.classList.add('hidden');
|
||||
renderCalendarView();
|
||||
} else {
|
||||
currentView = 'list';
|
||||
viewText.textContent = 'Calendar View';
|
||||
calendarView.classList.add('hidden');
|
||||
listView.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
toggleViewBtn.addEventListener('click', toggleView);
|
||||
|
||||
// Initialize
|
||||
checkAuth().then(session => {
|
||||
if (session) {
|
||||
loadEvents();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
29
src/pages/docs/[...slug].astro
Normal file
29
src/pages/docs/[...slug].astro
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
// This will redirect to the Starlight docs when they're running
|
||||
// For now, let's create a placeholder docs page
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { slug: undefined } },
|
||||
{ params: { slug: 'getting-started' } },
|
||||
{ params: { slug: 'events' } },
|
||||
{ params: { slug: 'scanning' } },
|
||||
{ params: { slug: 'payments' } },
|
||||
{ params: { slug: 'api' } },
|
||||
{ params: { slug: 'troubleshooting' } },
|
||||
];
|
||||
}
|
||||
|
||||
const { slug } = Astro.params;
|
||||
---
|
||||
|
||||
<script>
|
||||
// Redirect to the Starlight docs when they're running on port 4322
|
||||
// For development, redirect to localhost:4322
|
||||
// For production, serve the built docs
|
||||
window.location.href = `http://localhost:4322${window.location.pathname.replace('/docs', '')}` || '/support';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p>Redirecting to documentation...</p>
|
||||
<p>If you're not redirected, <a href="/support">return to support</a></p>
|
||||
</div>
|
||||
270
src/pages/docs/getting-started/account-setup.astro
Normal file
270
src/pages/docs/getting-started/account-setup.astro
Normal file
@@ -0,0 +1,270 @@
|
||||
---
|
||||
import Layout from '../../../layouts/Layout.astro';
|
||||
import SimpleHeader from '../../../components/SimpleHeader.astro';
|
||||
---
|
||||
|
||||
<Layout title="Account Setup - Black Canyon Tickets Documentation">
|
||||
<SimpleHeader />
|
||||
<main class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-8">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<li><a href="/docs" class="hover:text-blue-600">Documentation</a></li>
|
||||
<li>•</li>
|
||||
<li><a href="/docs/getting-started" class="hover:text-blue-600">Getting Started</a></li>
|
||||
<li>•</li>
|
||||
<li class="text-gray-900">Account Setup</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Article Header -->
|
||||
<div class="mb-12">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
Account Setup
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 leading-relaxed">
|
||||
Setting up your Black Canyon Tickets organizer account is the first step to selling tickets for your events. This guide will walk you through the complete setup process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-lg max-w-none">
|
||||
|
||||
<h2>Creating Your Account</h2>
|
||||
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-6 my-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
<strong>Before you start:</strong> Have your business information, bank details, and identification ready for the quickest setup experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Step 1: Visit the Platform</h3>
|
||||
<ol>
|
||||
<li>Go to <a href="https://portal.blackcanyontickets.com" class="text-blue-600 hover:text-blue-800">portal.blackcanyontickets.com</a></li>
|
||||
<li>Click the <strong>"Sign Up"</strong> button in the top right corner</li>
|
||||
</ol>
|
||||
|
||||
<div class="bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg p-8 text-center my-6">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Screenshot: Homepage with Sign Up button highlighted</p>
|
||||
</div>
|
||||
|
||||
<h3>Step 2: Registration Details</h3>
|
||||
<ol>
|
||||
<li>Enter your <strong>email address</strong> (this will be your login)</li>
|
||||
<li>Create a <strong>secure password</strong> (minimum 8 characters)</li>
|
||||
<li>Confirm your password</li>
|
||||
<li>Click <strong>"Create Account"</strong></li>
|
||||
</ol>
|
||||
|
||||
<div class="bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg p-8 text-center my-6">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Screenshot: Registration form with email and password fields</p>
|
||||
</div>
|
||||
|
||||
<h3>Step 3: Email Verification</h3>
|
||||
<ol>
|
||||
<li>Check your email inbox for a verification message</li>
|
||||
<li>Click the verification link in the email</li>
|
||||
<li>Return to the platform and log in with your new credentials</li>
|
||||
</ol>
|
||||
|
||||
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
<strong>Check your spam folder</strong> if you don't see the verification email within 5 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Completing Your Organizer Profile</h2>
|
||||
|
||||
<h3>Organization Information</h3>
|
||||
<p>Your organization information helps customers identify your events and builds trust:</p>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6 my-6">
|
||||
<h4 class="font-semibold text-gray-900 mb-4">Required Fields:</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><strong>Organization Name</strong>: The name that will appear on tickets and event pages</li>
|
||||
<li><strong>Display Name</strong>: How you want to be identified publicly</li>
|
||||
<li><strong>Contact Email</strong>: Primary email for customer inquiries</li>
|
||||
<li><strong>Phone Number</strong>: Optional, but recommended for customer service</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg p-8 text-center my-6">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Screenshot: Organization setup form with all required fields</p>
|
||||
</div>
|
||||
|
||||
<h3>Venue Details</h3>
|
||||
<p>If you have a regular venue, providing these details helps with event creation:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Venue Name</strong>: Primary location for your events</li>
|
||||
<li><strong>Address</strong>: Full street address including city, state, and ZIP</li>
|
||||
<li><strong>Capacity</strong>: Typical maximum attendance</li>
|
||||
<li><strong>Accessibility</strong>: Any accessibility features or accommodations</li>
|
||||
</ul>
|
||||
|
||||
<h3>Branding (Optional)</h3>
|
||||
<p>Customize your presence to match your brand:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Logo</strong>: Upload your organization or venue logo (recommended: 300x100px PNG)</li>
|
||||
<li><strong>Brand Colors</strong>: Choose colors that match your brand</li>
|
||||
<li><strong>Description</strong>: Brief description of your organization or venue</li>
|
||||
</ul>
|
||||
|
||||
<h2>Account Verification</h2>
|
||||
|
||||
<h3>Email Verification</h3>
|
||||
<ul>
|
||||
<li>Check your email for a verification link</li>
|
||||
<li>Click the link to confirm your email address</li>
|
||||
<li>This enables all account features</li>
|
||||
</ul>
|
||||
|
||||
<h3>Identity Verification</h3>
|
||||
<p>For payment processing, you'll need to verify your identity:</p>
|
||||
<ul>
|
||||
<li>This happens during Stripe Connect setup</li>
|
||||
<li>Required for receiving payments from ticket sales</li>
|
||||
<li>Typically takes 1-2 business days</li>
|
||||
</ul>
|
||||
|
||||
<div class="bg-green-50 border-l-4 border-green-400 p-6 my-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-green-700">
|
||||
<strong>Next Step:</strong> Once your account is set up, proceed to <a href="/docs/getting-started/stripe-connect" class="text-green-600 hover:text-green-800 underline">Stripe Connect setup</a> to enable payment processing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Security Best Practices</h2>
|
||||
|
||||
<h3>Password Security</h3>
|
||||
<ul>
|
||||
<li>Use a strong, unique password</li>
|
||||
<li>Enable two-factor authentication if available</li>
|
||||
<li>Never share your login credentials</li>
|
||||
</ul>
|
||||
|
||||
<h3>Account Safety</h3>
|
||||
<ul>
|
||||
<li>Log out when using shared computers</li>
|
||||
<li>Monitor your account for unusual activity</li>
|
||||
<li>Keep your contact information up to date</li>
|
||||
</ul>
|
||||
|
||||
<h2>Troubleshooting</h2>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden my-6">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Common Issues & Solutions</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Can't Access Your Account?</h4>
|
||||
<ul class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<li>• Use the "Forgot Password" link to reset your password</li>
|
||||
<li>• Check your spam folder for verification emails</li>
|
||||
<li>• Contact support if you continue having issues</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Email Not Verified?</h4>
|
||||
<ul class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<li>• Check your spam or junk folder</li>
|
||||
<li>• Request a new verification email from your account settings</li>
|
||||
<li>• Ensure your email address is correctly entered</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Support</h2>
|
||||
|
||||
<p>Need help with account setup?</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Email</strong>: <a href="mailto:support@blackcanyontickets.com">support@blackcanyontickets.com</a></li>
|
||||
<li><strong>Response Time</strong>: Typically within 24 hours</li>
|
||||
<li><strong>Include</strong>: Your registered email address and description of the issue</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="mt-16 pt-8 border-t border-gray-200">
|
||||
<div class="flex justify-between">
|
||||
<a href="/docs/getting-started/introduction" class="inline-flex items-center text-blue-600 hover:text-blue-800">
|
||||
← Introduction
|
||||
</a>
|
||||
<a href="/docs/getting-started/stripe-connect" class="inline-flex items-center text-blue-600 hover:text-blue-800">
|
||||
Stripe Connect Setup →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.prose h2 {
|
||||
@apply text-2xl font-bold text-gray-900 mt-8 mb-4;
|
||||
}
|
||||
.prose h3 {
|
||||
@apply text-xl font-semibold text-gray-900 mt-6 mb-3;
|
||||
}
|
||||
.prose h4 {
|
||||
@apply text-lg font-medium text-gray-900 mt-4 mb-2;
|
||||
}
|
||||
.prose p {
|
||||
@apply text-gray-700 mb-4 leading-relaxed;
|
||||
}
|
||||
.prose ul {
|
||||
@apply list-disc list-inside text-gray-700 mb-4 space-y-2;
|
||||
}
|
||||
.prose ol {
|
||||
@apply list-decimal list-inside text-gray-700 mb-4 space-y-2;
|
||||
}
|
||||
.prose li {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
.prose a {
|
||||
@apply text-blue-600 hover:text-blue-800;
|
||||
}
|
||||
</style>
|
||||
151
src/pages/docs/getting-started/introduction.astro
Normal file
151
src/pages/docs/getting-started/introduction.astro
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
import Layout from '../../../layouts/Layout.astro';
|
||||
import SimpleHeader from '../../../components/SimpleHeader.astro';
|
||||
---
|
||||
|
||||
<Layout title="Introduction - Black Canyon Tickets Documentation">
|
||||
<SimpleHeader />
|
||||
<main class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-8">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<li><a href="/docs" class="hover:text-blue-600">Documentation</a></li>
|
||||
<li>•</li>
|
||||
<li><a href="/docs/getting-started" class="hover:text-blue-600">Getting Started</a></li>
|
||||
<li>•</li>
|
||||
<li class="text-gray-900">Introduction</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Article Header -->
|
||||
<div class="mb-12">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
Welcome to Black Canyon Tickets
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 leading-relaxed">
|
||||
Black Canyon Tickets is a sophisticated, self-service ticketing platform built for upscale venues everywhere. Whether you're hosting intimate dance performances, elegant weddings, or exclusive galas, our platform provides the tools you need to sell tickets professionally and efficiently.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-lg max-w-none">
|
||||
|
||||
<h2>What Makes Us Different</h2>
|
||||
|
||||
<h3>Premium Experience</h3>
|
||||
<ul>
|
||||
<li><strong>Elegant Design</strong>: Every aspect of our platform is crafted with sophistication in mind</li>
|
||||
<li><strong>White-Label Solution</strong>: Seamlessly integrate with your venue's brand</li>
|
||||
<li><strong>Mobile-First</strong>: Beautiful, responsive design that works perfectly on all devices</li>
|
||||
</ul>
|
||||
|
||||
<h3>Built for Premium Events</h3>
|
||||
<ul>
|
||||
<li><strong>Upscale Focus</strong>: Understanding the unique needs of high-end venues</li>
|
||||
<li><strong>Sophisticated Events</strong>: Designed for discerning event organizers and their audiences</li>
|
||||
<li><strong>Flexible Scheduling</strong>: Handle both recurring and one-time premium events</li>
|
||||
</ul>
|
||||
|
||||
<h3>Technical Excellence</h3>
|
||||
<ul>
|
||||
<li><strong>No Apps Required</strong>: Everything works through web browsers</li>
|
||||
<li><strong>Instant Setup</strong>: Get started in minutes, not days</li>
|
||||
<li><strong>Reliable Infrastructure</strong>: Built on enterprise-grade cloud services</li>
|
||||
</ul>
|
||||
|
||||
<h2>Key Features</h2>
|
||||
|
||||
<h3>Event Management</h3>
|
||||
<ul>
|
||||
<li>Create and customize events with rich descriptions and media</li>
|
||||
<li>Set up multiple ticket types with different pricing tiers</li>
|
||||
<li>Manage seating charts and seat assignments</li>
|
||||
<li>Real-time inventory tracking</li>
|
||||
</ul>
|
||||
|
||||
<h3>Payment Processing</h3>
|
||||
<ul>
|
||||
<li>Integrated Stripe payments with Connect for automatic payouts</li>
|
||||
<li>Transparent fee structure (2.5% + $1.50 per transaction)</li>
|
||||
<li>PCI compliant and secure</li>
|
||||
<li>Automatic tax calculation and reporting</li>
|
||||
</ul>
|
||||
|
||||
<h3>QR Code Ticketing</h3>
|
||||
<ul>
|
||||
<li>Secure, UUID-based QR codes prevent fraud</li>
|
||||
<li>Mobile-friendly scanning interface</li>
|
||||
<li>Real-time check-in tracking</li>
|
||||
<li>Offline capability for poor connectivity areas</li>
|
||||
</ul>
|
||||
|
||||
<h3>Analytics & Reporting</h3>
|
||||
<ul>
|
||||
<li>Real-time sales dashboards</li>
|
||||
<li>Comprehensive attendee lists</li>
|
||||
<li>Financial reporting and reconciliation</li>
|
||||
<li>Export capabilities for external systems</li>
|
||||
</ul>
|
||||
|
||||
<h2>Getting Started</h2>
|
||||
|
||||
<p>Ready to transform your ticketing experience? Follow these steps:</p>
|
||||
|
||||
<ol>
|
||||
<li><a href="/docs/getting-started/account-setup" class="text-blue-600 hover:text-blue-800">Set up your account</a> - Create your organizer profile</li>
|
||||
<li><a href="/docs/getting-started/stripe-connect" class="text-blue-600 hover:text-blue-800">Connect Stripe</a> - Enable payment processing</li>
|
||||
<li><a href="/docs/getting-started/first-event" class="text-blue-600 hover:text-blue-800">Create your first event</a> - Build your event page</li>
|
||||
<li><a href="/docs/events/publishing-events" class="text-blue-600 hover:text-blue-800">Start selling</a> - Go live and share your event</li>
|
||||
</ol>
|
||||
|
||||
<h2>Support</h2>
|
||||
|
||||
<p>Our support team is here to help you succeed:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Email</strong>: <a href="mailto:support@blackcanyontickets.com">support@blackcanyontickets.com</a></li>
|
||||
<li><strong>Response Time</strong>: Typically within 24 hours</li>
|
||||
<li><strong>Documentation</strong>: This comprehensive guide covers all features</li>
|
||||
<li><strong>Training</strong>: We offer personalized onboarding for larger venues</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="mt-16 pt-8 border-t border-gray-200">
|
||||
<div class="flex justify-between">
|
||||
<a href="/docs" class="inline-flex items-center text-blue-600 hover:text-blue-800">
|
||||
← Back to Documentation
|
||||
</a>
|
||||
<a href="/docs/getting-started/account-setup" class="inline-flex items-center text-blue-600 hover:text-blue-800">
|
||||
Account Setup →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.prose h2 {
|
||||
@apply text-2xl font-bold text-gray-900 mt-8 mb-4;
|
||||
}
|
||||
.prose h3 {
|
||||
@apply text-xl font-semibold text-gray-900 mt-6 mb-3;
|
||||
}
|
||||
.prose p {
|
||||
@apply text-gray-700 mb-4 leading-relaxed;
|
||||
}
|
||||
.prose ul {
|
||||
@apply list-disc list-inside text-gray-700 mb-4 space-y-2;
|
||||
}
|
||||
.prose ol {
|
||||
@apply list-decimal list-inside text-gray-700 mb-4 space-y-2;
|
||||
}
|
||||
.prose li {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
</style>
|
||||
291
src/pages/docs/index.astro
Normal file
291
src/pages/docs/index.astro
Normal file
@@ -0,0 +1,291 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import SimpleHeader from '../../components/SimpleHeader.astro';
|
||||
---
|
||||
|
||||
<Layout title="Documentation - Black Canyon Tickets">
|
||||
<SimpleHeader />
|
||||
<main class="min-h-screen bg-gray-50">
|
||||
<!-- Hero Section -->
|
||||
<div class="bg-gradient-to-br from-blue-600 via-purple-600 to-indigo-800 text-white">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
|
||||
<h1 class="text-5xl font-bold mb-6">
|
||||
Documentation
|
||||
</h1>
|
||||
<p class="text-xl text-blue-100 mb-8">
|
||||
Complete guides to master every feature of Black Canyon Tickets
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentation Grid -->
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 -mt-10 relative z-10 pb-20">
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
|
||||
<!-- Getting Started -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden hover:shadow-2xl transition-all duration-300">
|
||||
<div class="bg-gradient-to-r from-green-500 to-green-600 p-6">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">Getting Started</h3>
|
||||
<p class="text-green-100 mt-2">Set up your account and create your first event</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/getting-started/account-setup" class="font-medium text-gray-900 hover:text-green-600">Account Setup</a>
|
||||
<p class="text-sm text-gray-600">Create and verify your organizer account</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/getting-started/stripe-connect" class="font-medium text-gray-900 hover:text-green-600">Stripe Connect</a>
|
||||
<p class="text-sm text-gray-600">Enable payment processing</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/getting-started/first-event" class="font-medium text-gray-900 hover:text-green-600">First Event</a>
|
||||
<p class="text-sm text-gray-600">Step-by-step event creation guide</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/getting-started/introduction" class="inline-flex items-center text-green-600 hover:text-green-800 font-semibold">
|
||||
Start Here →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Management -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden hover:shadow-2xl transition-all duration-300">
|
||||
<div class="bg-gradient-to-r from-blue-500 to-blue-600 p-6">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">Event Management</h3>
|
||||
<p class="text-blue-100 mt-2">Create, customize, and manage your events</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-blue-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/events/creating-events" class="font-medium text-gray-900 hover:text-blue-600">Creating Events</a>
|
||||
<p class="text-sm text-gray-600">Comprehensive event creation guide</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-blue-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/events/ticket-types" class="font-medium text-gray-900 hover:text-blue-600">Ticket Types</a>
|
||||
<p class="text-sm text-gray-600">Configure pricing and ticket options</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-blue-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/events/seating" class="font-medium text-gray-900 hover:text-blue-600">Seating Management</a>
|
||||
<p class="text-sm text-gray-600">Set up seating charts and assignments</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/events/creating-events" class="inline-flex items-center text-blue-600 hover:text-blue-800 font-semibold">
|
||||
Learn More →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Scanning -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden hover:shadow-2xl transition-all duration-300">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-purple-600 p-6">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2V5h1v1H5zM3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm2 2v-1h1v1H5zM13 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm2 2V5h1v1h-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">QR Code Scanning</h3>
|
||||
<p class="text-purple-100 mt-2">Mobile ticket scanning and check-in</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-purple-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/scanning/setup" class="font-medium text-gray-900 hover:text-purple-600">Scanner Setup</a>
|
||||
<p class="text-sm text-gray-600">Configure mobile scanning for your events</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-purple-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/scanning/training" class="font-medium text-gray-900 hover:text-purple-600">Staff Training</a>
|
||||
<p class="text-sm text-gray-600">Train your door staff quickly</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-purple-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/scanning/troubleshooting" class="font-medium text-gray-900 hover:text-purple-600">Troubleshooting</a>
|
||||
<p class="text-sm text-gray-600">Fix common scanning issues</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/scanning/setup" class="inline-flex items-center text-purple-600 hover:text-purple-800 font-semibold">
|
||||
Get Started →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden hover:shadow-2xl transition-all duration-300">
|
||||
<div class="bg-gradient-to-r from-yellow-500 to-orange-500 p-6">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z"></path>
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">Payments & Payouts</h3>
|
||||
<p class="text-yellow-100 mt-2">Stripe integration and financial management</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-orange-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/payments/stripe-setup" class="font-medium text-gray-900 hover:text-orange-600">Stripe Setup</a>
|
||||
<p class="text-sm text-gray-600">Connect your Stripe account</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-orange-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/payments/fees" class="font-medium text-gray-900 hover:text-orange-600">Platform Fees</a>
|
||||
<p class="text-sm text-gray-600">Understand our pricing structure</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-orange-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/payments/payouts" class="font-medium text-gray-900 hover:text-orange-600">Payouts</a>
|
||||
<p class="text-sm text-gray-600">When and how you get paid</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/payments/stripe-setup" class="inline-flex items-center text-orange-600 hover:text-orange-800 font-semibold">
|
||||
Learn More →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Documentation -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden hover:shadow-2xl transition-all duration-300">
|
||||
<div class="bg-gradient-to-r from-indigo-500 to-indigo-600 p-6">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">API Documentation</h3>
|
||||
<p class="text-indigo-100 mt-2">Integrate with your existing systems</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-indigo-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/api/overview" class="font-medium text-gray-900 hover:text-indigo-600">API Overview</a>
|
||||
<p class="text-sm text-gray-600">Getting started with our API</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-indigo-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/api/authentication" class="font-medium text-gray-900 hover:text-indigo-600">Authentication</a>
|
||||
<p class="text-sm text-gray-600">API keys and security</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-indigo-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/api/webhooks" class="font-medium text-gray-900 hover:text-indigo-600">Webhooks</a>
|
||||
<p class="text-sm text-gray-600">Real-time event notifications</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/api/overview" class="inline-flex items-center text-indigo-600 hover:text-indigo-800 font-semibold">
|
||||
API Reference →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Troubleshooting -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden hover:shadow-2xl transition-all duration-300">
|
||||
<div class="bg-gradient-to-r from-red-500 to-red-600 p-6">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">Troubleshooting</h3>
|
||||
<p class="text-red-100 mt-2">Fix common issues and problems</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/troubleshooting/common-issues" class="font-medium text-gray-900 hover:text-red-600">Common Issues</a>
|
||||
<p class="text-sm text-gray-600">Most frequently encountered problems</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/troubleshooting/payment-issues" class="font-medium text-gray-900 hover:text-red-600">Payment Issues</a>
|
||||
<p class="text-sm text-gray-600">Stripe and checkout problems</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-3 text-lg">•</span>
|
||||
<div>
|
||||
<a href="/docs/troubleshooting/scanning-issues" class="font-medium text-gray-900 hover:text-red-600">Scanning Issues</a>
|
||||
<p class="text-sm text-gray-600">QR code and check-in problems</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/troubleshooting/common-issues" class="inline-flex items-center text-red-600 hover:text-red-800 font-semibold">
|
||||
Get Help →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="mt-16 text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">Quick Links</h2>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a href="/support" class="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
← Back to Support
|
||||
</a>
|
||||
<a href="mailto:support@blackcanyontickets.com" class="inline-flex items-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium">
|
||||
Email Support
|
||||
</a>
|
||||
<a href="/" class="inline-flex items-center px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
155
src/pages/e/[slug].astro
Normal file
155
src/pages/e/[slug].astro
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import TicketCheckout from '../../components/TicketCheckout.tsx';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const { slug } = Astro.params;
|
||||
|
||||
// Fetch event data with ticket types
|
||||
const { data: event, error } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
*,
|
||||
availability_display_mode,
|
||||
availability_threshold,
|
||||
show_sold_out,
|
||||
low_stock_threshold,
|
||||
availability_messages,
|
||||
organizations (
|
||||
name,
|
||||
logo,
|
||||
platform_fee_type,
|
||||
platform_fee_percentage,
|
||||
platform_fee_fixed
|
||||
),
|
||||
ticket_types (
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
quantity_available,
|
||||
quantity_sold,
|
||||
is_active,
|
||||
sale_start_time,
|
||||
sale_end_time,
|
||||
sort_order
|
||||
)
|
||||
`)
|
||||
.eq('slug', slug)
|
||||
.single();
|
||||
|
||||
if (error || !event) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const eventDate = new Date(event.start_time);
|
||||
const formattedDate = eventDate.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
const formattedTime = eventDate.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
---
|
||||
|
||||
<Layout title={`${event.title} - Black Canyon Tickets`}>
|
||||
<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">
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
{event.organizations.logo && (
|
||||
<img
|
||||
src={event.organizations.logo}
|
||||
alt={event.organizations.name}
|
||||
class="h-12 w-12 rounded-xl mr-4 shadow-lg border-2 border-white/20"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 class="text-2xl font-light mb-1 tracking-wide">{event.title}</h1>
|
||||
<p class="text-slate-200 text-sm font-medium">Presented by {event.organizations.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-3 border border-white/20">
|
||||
<p class="text-xs text-slate-300 uppercase tracking-wide font-medium">Event Date</p>
|
||||
<p class="text-lg font-semibold text-white">{formattedDate}</p>
|
||||
<p class="text-slate-200 text-sm">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="bg-gradient-to-br from-slate-50 to-white rounded-xl p-5 border border-slate-200 shadow-lg">
|
||||
<h2 class="text-lg font-semibold text-slate-900 mb-4 flex items-center">
|
||||
<div class="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full mr-2"></div>
|
||||
Event Details
|
||||
</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-emerald-400 to-green-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">Venue</p>
|
||||
<p class="text-slate-600 text-sm">{event.venue}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">Date & Time</p>
|
||||
<p class="text-slate-600 text-sm">{formattedDate} at {formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<div class="mt-4 p-4 bg-white rounded-lg border border-slate-200">
|
||||
<h3 class="text-sm font-semibold text-slate-900 mb-2 flex items-center">
|
||||
<div class="w-1 h-1 bg-gradient-to-r from-amber-400 to-orange-500 rounded-full mr-2"></div>
|
||||
About This Event
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm whitespace-pre-line leading-relaxed">{event.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="bg-gradient-to-br from-slate-50 to-white rounded-xl p-5 border border-slate-200 shadow-lg sticky top-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900 mb-4 flex items-center">
|
||||
<div class="w-2 h-2 bg-gradient-to-r from-emerald-500 to-green-500 rounded-full mr-2"></div>
|
||||
Get Your Tickets
|
||||
</h2>
|
||||
<TicketCheckout event={event} client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
634
src/pages/embed-code/[slug].astro
Normal file
634
src/pages/embed-code/[slug].astro
Normal file
@@ -0,0 +1,634 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const { slug } = Astro.params;
|
||||
|
||||
// Fetch event data
|
||||
const { data: event, error } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
*,
|
||||
organizations (
|
||||
name,
|
||||
logo
|
||||
)
|
||||
`)
|
||||
.eq('slug', slug)
|
||||
.single();
|
||||
|
||||
if (error || !event) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
// Get the current domain for the embed URL
|
||||
const protocol = Astro.url.protocol;
|
||||
const host = Astro.url.host;
|
||||
const baseEmbedUrl = `${protocol}//${host}/embed/${slug}`;
|
||||
|
||||
// Default theme values
|
||||
const defaultTheme = {
|
||||
primaryColor: '#1e293b',
|
||||
accentColor: '#3b82f6',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#1f2937',
|
||||
borderColor: '#e2e8f0',
|
||||
borderRadius: '8',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
hidePoweredBy: false,
|
||||
hideHeader: false
|
||||
};
|
||||
|
||||
// Function to generate embed URL with theme parameters
|
||||
function generateEmbedUrl(theme) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(theme).forEach(([key, value]) => {
|
||||
if (value !== defaultTheme[key]) {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
return params.toString() ? `${baseEmbedUrl}?${params.toString()}` : baseEmbedUrl;
|
||||
}
|
||||
|
||||
// Generate embed code with theme
|
||||
function generateEmbedCode(theme, responsive = false) {
|
||||
const embedUrl = generateEmbedUrl(theme);
|
||||
const borderStyle = `border: 1px solid ${theme.borderColor}; border-radius: ${theme.borderRadius}px; overflow: hidden;`;
|
||||
|
||||
if (responsive) {
|
||||
return `<div style="position: relative; width: 100%; max-width: 600px; margin: 0 auto;">
|
||||
<iframe
|
||||
src="${embedUrl}"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
title="${event.title} - Ticket Widget"
|
||||
style="${borderStyle} display: block;"
|
||||
onload="this.style.height = this.contentWindow.document.body.scrollHeight + 'px'">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-resize iframe based on content
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data.type === 'resize') {
|
||||
const iframe = document.querySelector('iframe[src*="${baseEmbedUrl}"]');
|
||||
if (iframe) {
|
||||
iframe.style.height = event.data.height + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
} else {
|
||||
return `<iframe
|
||||
src="${embedUrl}"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
title="${event.title} - Ticket Widget"
|
||||
style="${borderStyle}"
|
||||
onload="this.style.height = this.contentWindow.document.body.scrollHeight + 'px'">
|
||||
</iframe>`;
|
||||
|
||||
<script>
|
||||
// Auto-resize iframe based on content
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data.type === 'resize') {
|
||||
const iframe = document.querySelector('iframe[src*="${baseEmbedUrl}"]');
|
||||
if (iframe) {
|
||||
iframe.style.height = event.data.height + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title={`Embed Code - ${event.title}`}>
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||
<main class="max-w-4xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow-xl rounded-2xl overflow-hidden border border-slate-200">
|
||||
<div class="px-6 py-6">
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-slate-800 to-slate-900 rounded-xl p-6 mb-6 text-white">
|
||||
<div class="flex items-center">
|
||||
{event.organizations.logo && (
|
||||
<img
|
||||
src={event.organizations.logo}
|
||||
alt={event.organizations.name}
|
||||
class="h-12 w-12 rounded-xl mr-4 shadow-lg border-2 border-white/20"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 class="text-2xl font-light mb-1 tracking-wide">Embed Code</h1>
|
||||
<p class="text-slate-200 text-sm font-medium">{event.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Customization -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Theme Controls -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900 mb-4">Customize Theme</h2>
|
||||
<div class="space-y-4">
|
||||
<!-- Primary Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Primary Color (Header)</label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="color"
|
||||
id="primaryColor"
|
||||
value="#1e293b"
|
||||
class="w-10 h-10 rounded border border-slate-300"
|
||||
onchange="updateTheme()"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="primaryColorText"
|
||||
value="#1e293b"
|
||||
class="flex-1 px-3 py-2 border border-slate-300 rounded-md text-sm font-mono"
|
||||
onchange="document.getElementById('primaryColor').value = this.value; updateTheme()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accent Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Accent Color (Buttons)</label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="color"
|
||||
id="accentColor"
|
||||
value="#3b82f6"
|
||||
class="w-10 h-10 rounded border border-slate-300"
|
||||
onchange="updateTheme()"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="accentColorText"
|
||||
value="#3b82f6"
|
||||
class="flex-1 px-3 py-2 border border-slate-300 rounded-md text-sm font-mono"
|
||||
onchange="document.getElementById('accentColor').value = this.value; updateTheme()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Background Color</label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="color"
|
||||
id="backgroundColor"
|
||||
value="#ffffff"
|
||||
class="w-10 h-10 rounded border border-slate-300"
|
||||
onchange="updateTheme()"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="backgroundColorText"
|
||||
value="#ffffff"
|
||||
class="flex-1 px-3 py-2 border border-slate-300 rounded-md text-sm font-mono"
|
||||
onchange="document.getElementById('backgroundColor').value = this.value; updateTheme()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Text Color</label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="color"
|
||||
id="textColor"
|
||||
value="#1f2937"
|
||||
class="w-10 h-10 rounded border border-slate-300"
|
||||
onchange="updateTheme()"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="textColorText"
|
||||
value="#1f2937"
|
||||
class="flex-1 px-3 py-2 border border-slate-300 rounded-md text-sm font-mono"
|
||||
onchange="document.getElementById('textColor').value = this.value; updateTheme()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Border Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Border Color</label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="color"
|
||||
id="borderColor"
|
||||
value="#e2e8f0"
|
||||
class="w-10 h-10 rounded border border-slate-300"
|
||||
onchange="updateTheme()"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="borderColorText"
|
||||
value="#e2e8f0"
|
||||
class="flex-1 px-3 py-2 border border-slate-300 rounded-md text-sm font-mono"
|
||||
onchange="document.getElementById('borderColor').value = this.value; updateTheme()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Border Radius -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Border Radius (px)</label>
|
||||
<input
|
||||
type="range"
|
||||
id="borderRadius"
|
||||
min="0"
|
||||
max="20"
|
||||
value="8"
|
||||
class="w-full"
|
||||
onchange="document.getElementById('borderRadiusText').value = this.value; updateTheme()"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
id="borderRadiusText"
|
||||
value="8"
|
||||
min="0"
|
||||
max="20"
|
||||
class="mt-2 w-20 px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
onchange="document.getElementById('borderRadius').value = this.value; updateTheme()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Font Family -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Font Family</label>
|
||||
<select
|
||||
id="fontFamily"
|
||||
class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
onchange="updateTheme()"
|
||||
>
|
||||
<option value="system-ui, -apple-system, sans-serif">System (Default)</option>
|
||||
<option value="'Helvetica Neue', Arial, sans-serif">Helvetica</option>
|
||||
<option value="'Times New Roman', serif">Times New Roman</option>
|
||||
<option value="'Georgia', serif">Georgia</option>
|
||||
<option value="'Roboto', sans-serif">Roboto</option>
|
||||
<option value="'Open Sans', sans-serif">Open Sans</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hideHeader"
|
||||
class="mr-2"
|
||||
onchange="updateTheme()"
|
||||
/>
|
||||
<span class="text-sm text-slate-700">Hide Header</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hidePoweredBy"
|
||||
class="mr-2"
|
||||
onchange="updateTheme()"
|
||||
/>
|
||||
<span class="text-sm text-slate-700">Hide "Powered by" Footer</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Preset Themes -->
|
||||
<div class="pt-4 border-t border-slate-200">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-3">Preset Themes</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onclick="applyPreset('default')"
|
||||
class="p-2 border border-slate-300 rounded-md text-sm hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onclick="applyPreset('minimal')"
|
||||
class="p-2 border border-slate-300 rounded-md text-sm hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Minimal
|
||||
</button>
|
||||
<button
|
||||
onclick="applyPreset('dark')"
|
||||
class="p-2 border border-slate-300 rounded-md text-sm hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onclick="applyPreset('elegant')"
|
||||
class="p-2 border border-slate-300 rounded-md text-sm hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Elegant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900 mb-4">Preview</h2>
|
||||
<div class="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||
<iframe
|
||||
id="previewFrame"
|
||||
src={baseEmbedUrl}
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
title={`${event.title} - Ticket Widget`}
|
||||
style="border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;"
|
||||
onload="this.style.height = this.contentWindow.document.body.scrollHeight + 'px'">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generated Embed Code -->
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Embed -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900 mb-3">Basic Embed Code</h3>
|
||||
<p class="text-slate-600 text-sm mb-3">Copy and paste this code into your website HTML:</p>
|
||||
<div class="bg-slate-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre class="text-green-400 text-sm font-mono whitespace-pre-wrap"><code id="basicEmbedCode"></code></pre>
|
||||
</div>
|
||||
<button
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('basicEmbedCode').textContent)"
|
||||
class="mt-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Copy Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Embed -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900 mb-3">Responsive Embed Code</h3>
|
||||
<p class="text-slate-600 text-sm mb-3">This version includes responsive styling and centers the widget:</p>
|
||||
<div class="bg-slate-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre class="text-green-400 text-sm font-mono whitespace-pre-wrap"><code id="responsiveEmbedCode"></code></pre>
|
||||
</div>
|
||||
<button
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('responsiveEmbedCode').textContent)"
|
||||
class="mt-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Copy Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Direct Link -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900 mb-3">Direct Link</h3>
|
||||
<p class="text-slate-600 text-sm mb-3">You can also link directly to the embed page:</p>
|
||||
<div class="bg-slate-100 rounded-lg p-4">
|
||||
<code class="text-slate-800 text-sm font-mono break-all" id="directLink"></code>
|
||||
</div>
|
||||
<button
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('directLink').textContent)"
|
||||
class="mt-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="mt-8 bg-blue-50 rounded-lg p-6 border border-blue-200">
|
||||
<h3 class="text-lg font-semibold text-blue-900 mb-3">How to Use</h3>
|
||||
<ul class="text-blue-800 text-sm space-y-2">
|
||||
<li>• Copy the embed code above</li>
|
||||
<li>• Paste it into your website's HTML where you want the ticket widget to appear</li>
|
||||
<li>• The widget will automatically resize based on its content</li>
|
||||
<li>• Customers can purchase tickets directly from your website</li>
|
||||
<li>• All transactions are processed securely through our platform</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="mt-6 bg-green-50 rounded-lg p-6 border border-green-200">
|
||||
<h3 class="text-lg font-semibold text-green-900 mb-3">Features</h3>
|
||||
<ul class="text-green-800 text-sm space-y-2">
|
||||
<li>• ✅ Mobile-responsive design</li>
|
||||
<li>• ✅ Automatic height adjustment</li>
|
||||
<li>• ✅ Secure payment processing</li>
|
||||
<li>• ✅ Real-time ticket availability</li>
|
||||
<li>• ✅ Branded with your organization</li>
|
||||
<li>• ✅ No additional fees for embedding</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const baseEmbedUrl = `${window.location.protocol}//${window.location.host}/embed/${slug}`;
|
||||
|
||||
// Theme presets
|
||||
const presets = {
|
||||
default: {
|
||||
primaryColor: '#1e293b',
|
||||
accentColor: '#3b82f6',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#1f2937',
|
||||
borderColor: '#e2e8f0',
|
||||
borderRadius: '8',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
hideHeader: false,
|
||||
hidePoweredBy: false
|
||||
},
|
||||
minimal: {
|
||||
primaryColor: '#ffffff',
|
||||
accentColor: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#000000',
|
||||
borderColor: '#e5e7eb',
|
||||
borderRadius: '0',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
hideHeader: true,
|
||||
hidePoweredBy: true
|
||||
},
|
||||
dark: {
|
||||
primaryColor: '#111827',
|
||||
accentColor: '#3b82f6',
|
||||
backgroundColor: '#1f2937',
|
||||
textColor: '#ffffff',
|
||||
borderColor: '#374151',
|
||||
borderRadius: '8',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
hideHeader: false,
|
||||
hidePoweredBy: false
|
||||
},
|
||||
elegant: {
|
||||
primaryColor: '#7c3aed',
|
||||
accentColor: '#a855f7',
|
||||
backgroundColor: '#fefefe',
|
||||
textColor: '#1f2937',
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: '12',
|
||||
fontFamily: "'Georgia', serif",
|
||||
hideHeader: false,
|
||||
hidePoweredBy: false
|
||||
}
|
||||
};
|
||||
|
||||
// Generate embed URL with theme parameters
|
||||
function generateEmbedUrl(theme) {
|
||||
const params = new URLSearchParams();
|
||||
const defaultTheme = presets.default;
|
||||
|
||||
Object.entries(theme).forEach(([key, value]) => {
|
||||
if (value !== defaultTheme[key]) {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return params.toString() ? `${baseEmbedUrl}?${params.toString()}` : baseEmbedUrl;
|
||||
}
|
||||
|
||||
// Generate embed code with theme
|
||||
function generateEmbedCode(theme, responsive = false) {
|
||||
const embedUrl = generateEmbedUrl(theme);
|
||||
const borderStyle = `border: 1px solid ${theme.borderColor}; border-radius: ${theme.borderRadius}px; overflow: hidden;`;
|
||||
|
||||
if (responsive) {
|
||||
return `<div style="position: relative; width: 100%; max-width: 600px; margin: 0 auto;">
|
||||
<iframe
|
||||
src="${embedUrl}"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
title="${event.title} - Ticket Widget"
|
||||
style="${borderStyle} display: block;"
|
||||
onload="this.style.height = this.contentWindow.document.body.scrollHeight + 'px'">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-resize iframe based on content
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data.type === 'resize') {
|
||||
const iframe = document.querySelector('iframe[src*="${baseEmbedUrl}"]');
|
||||
if (iframe) {
|
||||
iframe.style.height = event.data.height + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
} else {
|
||||
return `<iframe
|
||||
src="${embedUrl}"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
title="${event.title} - Ticket Widget"
|
||||
style="${borderStyle}"
|
||||
onload="this.style.height = this.contentWindow.document.body.scrollHeight + 'px'">
|
||||
</iframe>`;
|
||||
|
||||
<script>
|
||||
// Auto-resize iframe based on content
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data.type === 'resize') {
|
||||
const iframe = document.querySelector('iframe[src*="${baseEmbedUrl}"]');
|
||||
if (iframe) {
|
||||
iframe.style.height = event.data.height + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current theme from form
|
||||
function getCurrentTheme() {
|
||||
return {
|
||||
primaryColor: document.getElementById('primaryColor').value,
|
||||
accentColor: document.getElementById('accentColor').value,
|
||||
backgroundColor: document.getElementById('backgroundColor').value,
|
||||
textColor: document.getElementById('textColor').value,
|
||||
borderColor: document.getElementById('borderColor').value,
|
||||
borderRadius: document.getElementById('borderRadius').value,
|
||||
fontFamily: document.getElementById('fontFamily').value,
|
||||
hideHeader: document.getElementById('hideHeader').checked,
|
||||
hidePoweredBy: document.getElementById('hidePoweredBy').checked
|
||||
};
|
||||
}
|
||||
|
||||
// Apply theme to form
|
||||
function applyThemeToForm(theme) {
|
||||
document.getElementById('primaryColor').value = theme.primaryColor;
|
||||
document.getElementById('primaryColorText').value = theme.primaryColor;
|
||||
document.getElementById('accentColor').value = theme.accentColor;
|
||||
document.getElementById('accentColorText').value = theme.accentColor;
|
||||
document.getElementById('backgroundColor').value = theme.backgroundColor;
|
||||
document.getElementById('backgroundColorText').value = theme.backgroundColor;
|
||||
document.getElementById('textColor').value = theme.textColor;
|
||||
document.getElementById('textColorText').value = theme.textColor;
|
||||
document.getElementById('borderColor').value = theme.borderColor;
|
||||
document.getElementById('borderColorText').value = theme.borderColor;
|
||||
document.getElementById('borderRadius').value = theme.borderRadius;
|
||||
document.getElementById('borderRadiusText').value = theme.borderRadius;
|
||||
document.getElementById('fontFamily').value = theme.fontFamily;
|
||||
document.getElementById('hideHeader').checked = theme.hideHeader;
|
||||
document.getElementById('hidePoweredBy').checked = theme.hidePoweredBy;
|
||||
}
|
||||
|
||||
// Update theme (called when form changes)
|
||||
function updateTheme() {
|
||||
const theme = getCurrentTheme();
|
||||
|
||||
// Update preview iframe
|
||||
const previewFrame = document.getElementById('previewFrame');
|
||||
previewFrame.src = generateEmbedUrl(theme);
|
||||
|
||||
// Update embed codes
|
||||
document.getElementById('basicEmbedCode').textContent = generateEmbedCode(theme, false);
|
||||
document.getElementById('responsiveEmbedCode').textContent = generateEmbedCode(theme, true);
|
||||
document.getElementById('directLink').textContent = generateEmbedUrl(theme);
|
||||
|
||||
// Sync color inputs
|
||||
document.getElementById('primaryColorText').value = theme.primaryColor;
|
||||
document.getElementById('accentColorText').value = theme.accentColor;
|
||||
document.getElementById('backgroundColorText').value = theme.backgroundColor;
|
||||
document.getElementById('textColorText').value = theme.textColor;
|
||||
document.getElementById('borderColorText').value = theme.borderColor;
|
||||
}
|
||||
|
||||
// Apply preset theme
|
||||
function applyPreset(presetName) {
|
||||
const theme = presets[presetName];
|
||||
applyThemeToForm(theme);
|
||||
updateTheme();
|
||||
}
|
||||
|
||||
// Auto-resize iframe based on content
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data.type === 'resize') {
|
||||
const iframe = document.querySelector('iframe[src*="embed"]');
|
||||
if (iframe) {
|
||||
iframe.style.height = event.data.height + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize with default theme
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateTheme();
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
377
src/pages/embed.astro
Normal file
377
src/pages/embed.astro
Normal file
@@ -0,0 +1,377 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Embed Script - Black Canyon Tickets">
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-gray-100">
|
||||
<!-- Sticky Navigation -->
|
||||
<nav class="sticky top-0 z-50 bg-white/90 backdrop-blur-lg shadow-xl border-b border-slate-200/50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<div class="flex items-center">
|
||||
<a href="/dashboard" class="text-xl font-medium text-gray-900">
|
||||
Black Canyon Tickets
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<a href="/dashboard" class="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 font-medium transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-5xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Widget Generator</h1>
|
||||
<p class="text-gray-600">Create embeddable ticket widgets for your website</p>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Card -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden mb-8">
|
||||
<div class="px-8 py-6 bg-gray-50 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Configuration
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Event Selection -->
|
||||
<div class="mb-8">
|
||||
<label for="event-select" class="block text-sm font-semibold text-gray-700 mb-3">
|
||||
Select Event
|
||||
</label>
|
||||
<select
|
||||
id="event-select"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
>
|
||||
<option value="">Choose an event...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Widget Options -->
|
||||
<div id="widget-options" class="hidden">
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<!-- Size Options -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-4">Widget Size</label>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="radio" name="size" value="small" class="text-indigo-600 focus:ring-indigo-500" />
|
||||
<div class="ml-3">
|
||||
<div class="font-medium text-gray-900">Small</div>
|
||||
<div class="text-sm text-gray-500">300 × 400px</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="radio" name="size" value="medium" class="text-indigo-600 focus:ring-indigo-500" checked />
|
||||
<div class="ml-3">
|
||||
<div class="font-medium text-gray-900">Medium</div>
|
||||
<div class="text-sm text-gray-500">400 × 500px</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="radio" name="size" value="large" class="text-indigo-600 focus:ring-indigo-500" />
|
||||
<div class="ml-3">
|
||||
<div class="font-medium text-gray-900">Large</div>
|
||||
<div class="text-sm text-gray-500">500 × 600px</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme & Branding -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-4">Theme</label>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="radio" name="theme" value="light" class="text-indigo-600 focus:ring-indigo-500" checked />
|
||||
<div class="ml-3">
|
||||
<div class="font-medium text-gray-900">Light</div>
|
||||
<div class="text-sm text-gray-500">Clean, bright appearance</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="radio" name="theme" value="dark" class="text-indigo-600 focus:ring-indigo-500" />
|
||||
<div class="ml-3">
|
||||
<div class="font-medium text-gray-900">Dark</div>
|
||||
<div class="text-sm text-gray-500">Modern, dark styling</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="checkbox" id="show-branding" class="text-indigo-600 focus:ring-indigo-500" checked />
|
||||
<div class="ml-3">
|
||||
<div class="font-medium text-gray-900">Show Branding</div>
|
||||
<div class="text-sm text-gray-500">Display "Powered by Black Canyon Tickets"</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code & Preview Section -->
|
||||
<div id="embed-code-section" class="hidden grid lg:grid-cols-2 gap-8">
|
||||
<!-- Embed Code -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
Embed Code
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Copy and paste this code into your website:
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-900 rounded-xl p-4 mb-4 overflow-hidden">
|
||||
<pre id="embed-code" class="text-sm text-green-400 whitespace-pre-wrap overflow-x-auto font-mono"></pre>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
id="copy-code-btn"
|
||||
class="inline-flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy Code
|
||||
</button>
|
||||
<button
|
||||
id="test-widget-btn"
|
||||
class="inline-flex items-center gap-2 border border-gray-300 hover:bg-gray-50 text-gray-700 px-4 py-2 rounded-xl text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Test Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Preview -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Live Preview
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Preview of your widget:
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div id="widget-preview" class="inline-block border rounded-xl p-4 bg-gray-50">
|
||||
<!-- Widget preview will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const eventSelect = document.getElementById('event-select') as HTMLSelectElement;
|
||||
const widgetOptions = document.getElementById('widget-options');
|
||||
const embedCodeSection = document.getElementById('embed-code-section');
|
||||
const embedCode = document.getElementById('embed-code');
|
||||
const copyCodeBtn = document.getElementById('copy-code-btn');
|
||||
const testWidgetBtn = document.getElementById('test-widget-btn');
|
||||
const widgetPreview = document.getElementById('widget-preview');
|
||||
|
||||
let selectedEvent: any = null;
|
||||
|
||||
// Check authentication
|
||||
async function checkAuth() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
// Load user's events
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const { data: events, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
events.forEach(event => {
|
||||
const option = document.createElement('option');
|
||||
option.value = event.id;
|
||||
option.textContent = event.title;
|
||||
option.setAttribute('data-event', JSON.stringify(event));
|
||||
eventSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embed code
|
||||
function generateEmbedCode() {
|
||||
if (!selectedEvent) return;
|
||||
|
||||
const sizeInputs = document.querySelectorAll('input[name="size"]') as NodeListOf<HTMLInputElement>;
|
||||
const themeInputs = document.querySelectorAll('input[name="theme"]') as NodeListOf<HTMLInputElement>;
|
||||
const showBranding = document.getElementById('show-branding') as HTMLInputElement;
|
||||
|
||||
const selectedSize = Array.from(sizeInputs).find(input => input.checked)?.value || 'medium';
|
||||
const selectedTheme = Array.from(themeInputs).find(input => input.checked)?.value || 'light';
|
||||
|
||||
const dimensions = {
|
||||
small: { width: 300, height: 400 },
|
||||
medium: { width: 400, height: 500 },
|
||||
large: { width: 500, height: 600 }
|
||||
}[selectedSize];
|
||||
|
||||
const embedScript = `<!-- Black Canyon Tickets Widget -->
|
||||
<div id="bct-widget-${selectedEvent.id}" style="width: ${dimensions.width}px; height: ${dimensions.height}px;"></div>
|
||||
<sc` + `ript>
|
||||
(function() {
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.src = 'https://portal.blackcanyontickets.com/widget/${selectedEvent.slug}?theme=${selectedTheme}&branding=${showBranding.checked}';
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.border = 'none';
|
||||
iframe.style.borderRadius = '8px';
|
||||
iframe.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
|
||||
|
||||
var container = document.getElementById('bct-widget-${selectedEvent.id}');
|
||||
if (container) {
|
||||
container.appendChild(iframe);
|
||||
}
|
||||
})();
|
||||
</sc` + `ript>
|
||||
<!-- End Black Canyon Tickets Widget -->`;
|
||||
|
||||
embedCode.textContent = embedScript;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Update preview
|
||||
function updatePreview() {
|
||||
if (!selectedEvent) return;
|
||||
|
||||
const sizeInputs = document.querySelectorAll('input[name="size"]') as NodeListOf<HTMLInputElement>;
|
||||
const themeInputs = document.querySelectorAll('input[name="theme"]') as NodeListOf<HTMLInputElement>;
|
||||
const showBranding = document.getElementById('show-branding') as HTMLInputElement;
|
||||
|
||||
const selectedSize = Array.from(sizeInputs).find(input => input.checked)?.value || 'medium';
|
||||
const selectedTheme = Array.from(themeInputs).find(input => input.checked)?.value || 'light';
|
||||
|
||||
const dimensions = {
|
||||
small: { width: 300, height: 400 },
|
||||
medium: { width: 400, height: 500 },
|
||||
large: { width: 500, height: 600 }
|
||||
}[selectedSize];
|
||||
|
||||
// Create a simplified preview
|
||||
const previewHTML = `
|
||||
<div style="width: ${dimensions.width}px; height: ${dimensions.height}px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; background: ${selectedTheme === 'dark' ? '#1f2937' : '#ffffff'}; color: ${selectedTheme === 'dark' ? '#ffffff' : '#1f2937'};">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 18px; font-weight: 600;">${selectedEvent.title}</h3>
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; opacity: 0.7;">📍 ${selectedEvent.venue}</p>
|
||||
<p style="margin: 0 0 16px 0; font-size: 14px; opacity: 0.7;">📅 ${new Date(selectedEvent.start_time).toLocaleDateString()}</p>
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 4px; padding: 12px; margin-bottom: 16px;">
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Get Your Tickets</p>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||||
<select style="flex: 1; padding: 8px; border: 1px solid #e5e7eb; border-radius: 4px; background: ${selectedTheme === 'dark' ? '#374151' : '#ffffff'}; color: ${selectedTheme === 'dark' ? '#ffffff' : '#1f2937'};">
|
||||
<option>1 Ticket</option>
|
||||
</select>
|
||||
</div>
|
||||
<button style="width: 100%; padding: 12px; background: #4f46e5; color: white; border: none; border-radius: 4px; font-weight: 500; cursor: pointer;">
|
||||
Purchase Tickets
|
||||
</button>
|
||||
</div>
|
||||
${showBranding.checked ? `<p style="margin: 0; font-size: 12px; opacity: 0.5; text-align: center;">Powered by Black Canyon Tickets</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
widgetPreview.innerHTML = previewHTML;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
eventSelect.addEventListener('change', (e) => {
|
||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
||||
if (selectedOption.value) {
|
||||
selectedEvent = JSON.parse(selectedOption.getAttribute('data-event'));
|
||||
widgetOptions.classList.remove('hidden');
|
||||
embedCodeSection.classList.remove('hidden');
|
||||
generateEmbedCode();
|
||||
} else {
|
||||
selectedEvent = null;
|
||||
widgetOptions.classList.add('hidden');
|
||||
embedCodeSection.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for option changes
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.matches('input[name="size"], input[name="theme"], #show-branding')) {
|
||||
generateEmbedCode();
|
||||
}
|
||||
});
|
||||
|
||||
copyCodeBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(embedCode.textContent).then(() => {
|
||||
const originalText = copyCodeBtn.textContent;
|
||||
copyCodeBtn.textContent = 'Copied!';
|
||||
copyCodeBtn.classList.add('bg-green-600');
|
||||
setTimeout(() => {
|
||||
copyCodeBtn.textContent = originalText;
|
||||
copyCodeBtn.classList.remove('bg-green-600');
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgetBtn.addEventListener('click', () => {
|
||||
if (selectedEvent) {
|
||||
window.open(`/e/${selectedEvent.slug}`, '_blank');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
checkAuth().then(session => {
|
||||
if (session) {
|
||||
loadEvents();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
238
src/pages/embed/[slug].astro
Normal file
238
src/pages/embed/[slug].astro
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import TicketCheckout from '../../components/TicketCheckout.tsx';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const url = Astro.url;
|
||||
|
||||
// Extract theme parameters from URL
|
||||
const primaryColor = url.searchParams.get('primaryColor') || '#1e293b';
|
||||
const accentColor = url.searchParams.get('accentColor') || '#3b82f6';
|
||||
const backgroundColor = url.searchParams.get('backgroundColor') || '#ffffff';
|
||||
const textColor = url.searchParams.get('textColor') || '#1f2937';
|
||||
const borderColor = url.searchParams.get('borderColor') || '#e2e8f0';
|
||||
const borderRadius = url.searchParams.get('borderRadius') || '8';
|
||||
const fontFamily = url.searchParams.get('fontFamily') || 'system-ui, -apple-system, sans-serif';
|
||||
const hidePoweredBy = url.searchParams.get('hidePoweredBy') === 'true';
|
||||
const hideHeader = url.searchParams.get('hideHeader') === 'true';
|
||||
|
||||
// Fetch event data with ticket types
|
||||
const { data: event, error } = await supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
*,
|
||||
availability_display_mode,
|
||||
availability_threshold,
|
||||
show_sold_out,
|
||||
low_stock_threshold,
|
||||
availability_messages,
|
||||
organizations (
|
||||
name,
|
||||
logo,
|
||||
platform_fee_type,
|
||||
platform_fee_percentage,
|
||||
platform_fee_fixed
|
||||
),
|
||||
ticket_types (
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
quantity_available,
|
||||
quantity_sold,
|
||||
is_active,
|
||||
sale_start_time,
|
||||
sale_end_time,
|
||||
sort_order
|
||||
)
|
||||
`)
|
||||
.eq('slug', slug)
|
||||
.single();
|
||||
|
||||
if (error || !event) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const eventDate = new Date(event.start_time);
|
||||
const formattedDate = eventDate.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
const formattedTime = eventDate.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{event.title} - Tickets</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: {fontFamily};
|
||||
color: {textColor};
|
||||
}
|
||||
|
||||
/* Make the widget responsive */
|
||||
.widget-container {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
background: {backgroundColor};
|
||||
border-radius: {borderRadius}px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Compact styling for embed */
|
||||
.embed-header {
|
||||
background: {primaryColor};
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: {borderRadius}px {borderRadius}px 0 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.embed-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
color: {accentColor};
|
||||
}
|
||||
|
||||
.accent-bg {
|
||||
background-color: {accentColor};
|
||||
}
|
||||
|
||||
.border-custom {
|
||||
border-color: {borderColor};
|
||||
}
|
||||
|
||||
.rounded-custom {
|
||||
border-radius: {borderRadius}px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: {accentColor};
|
||||
border-color: {accentColor};
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: {accentColor}dd;
|
||||
border-color: {accentColor}dd;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.embed-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.embed-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="widget-container">
|
||||
<div class="embed-content">
|
||||
<!-- Compact Header -->
|
||||
{!hideHeader && (
|
||||
<div class="embed-header">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="flex items-center min-w-0">
|
||||
{event.organizations.logo && (
|
||||
<img
|
||||
src={event.organizations.logo}
|
||||
alt={event.organizations.name}
|
||||
class="h-8 w-8 rounded-custom mr-3 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold mb-0 truncate">{event.title}</h1>
|
||||
<p class="text-sm opacity-80 truncate">{event.organizations.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<p class="text-xs opacity-80 uppercase tracking-wide">Event Date</p>
|
||||
<p class="text-sm font-semibold">{formattedDate}</p>
|
||||
<p class="text-xs opacity-80">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Event Details (Compact) -->
|
||||
<div class="rounded-custom p-3 mb-4" style={`background-color: ${backgroundColor === '#ffffff' ? '#f8fafc' : backgroundColor + '20'};`}>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm">
|
||||
<svg class="h-4 w-4 mr-2 flex-shrink-0 accent-color" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="font-medium mr-2" style={`color: ${textColor}`}>Venue:</span>
|
||||
<span style={`color: ${textColor}aa`}>{event.venue}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<svg class="h-4 w-4 mr-2 flex-shrink-0 accent-color" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="font-medium mr-2" style={`color: ${textColor}`}>When:</span>
|
||||
<span style={`color: ${textColor}aa`}>{formattedDate} at {formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Checkout -->
|
||||
<div class="border rounded-custom p-4" style={`background-color: ${backgroundColor}; border-color: ${borderColor};`}>
|
||||
<h2 class="text-lg font-semibold mb-3" style={`color: ${textColor}`}>Get Your Tickets</h2>
|
||||
<TicketCheckout event={event} client:load />
|
||||
</div>
|
||||
|
||||
<!-- Powered by footer -->
|
||||
{!hidePoweredBy && (
|
||||
<div class="text-center mt-4 pt-3" style={`border-top: 1px solid ${borderColor};`}>
|
||||
<p class="text-xs" style={`color: ${textColor}aa`}>
|
||||
Powered by <a href="https://portal.blackcanyontickets.com" target="_blank" class="accent-color hover:underline">Black Canyon Tickets</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-resize iframe to fit content
|
||||
function resizeIframe() {
|
||||
const height = document.body.scrollHeight;
|
||||
window.parent.postMessage({
|
||||
type: 'resize',
|
||||
height: height
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// Initial resize
|
||||
setTimeout(resizeIframe, 100);
|
||||
|
||||
// Resize on window resize
|
||||
window.addEventListener('resize', resizeIframe);
|
||||
|
||||
// Resize when content changes (like when ticket selection changes)
|
||||
const observer = new MutationObserver(resizeIframe);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
5954
src/pages/events/[id]/_manage.astro.backup
Normal file
5954
src/pages/events/[id]/_manage.astro.backup
Normal file
File diff suppressed because it is too large
Load Diff
6743
src/pages/events/[id]/manage.astro
Normal file
6743
src/pages/events/[id]/manage.astro
Normal file
File diff suppressed because it is too large
Load Diff
523
src/pages/events/new.astro
Normal file
523
src/pages/events/new.astro
Normal file
@@ -0,0 +1,523 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Navigation from '../../components/Navigation.astro';
|
||||
---
|
||||
|
||||
<Layout title="Create Event - Black Canyon Tickets">
|
||||
<style>
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
</style>
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<!-- Animated background elements -->
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-purple-600/20 to-pink-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-blue-600/20 to-cyan-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-gradient-to-br from-indigo-600/10 to-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
<Navigation
|
||||
title="Create Event"
|
||||
showBackLink={true}
|
||||
backLinkUrl="/dashboard"
|
||||
backLinkText="← All Events"
|
||||
/>
|
||||
|
||||
<main class="relative max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header Section -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl md:text-5xl font-light text-white mb-4 tracking-wide">
|
||||
Create Your Event
|
||||
</h1>
|
||||
<p class="text-xl text-white/80 max-w-2xl mx-auto leading-relaxed">
|
||||
Set up the foundation for your distinguished event. You'll add tickets, seating, and other details in the next step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl overflow-hidden">
|
||||
<form id="event-form" class="p-8">
|
||||
<div class="space-y-8">
|
||||
<!-- Basic Event Information -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-light text-white mb-6">Event Details</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-semibold text-white/90 mb-2">Event Title</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
required
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
placeholder="Summer Gala at The Little Nell"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-white/90 mb-2">Event Date & Time</label>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="event_date" class="block text-xs font-medium text-white/90 mb-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
name="event_date"
|
||||
id="event_date"
|
||||
required
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="event_time" class="block text-xs font-medium text-white/90 mb-1">Time</label>
|
||||
<select
|
||||
name="event_time"
|
||||
id="event_time"
|
||||
required
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
>
|
||||
<option value="">Select time</option>
|
||||
<option value="09:00">9:00 AM</option>
|
||||
<option value="09:30">9:30 AM</option>
|
||||
<option value="10:00">10:00 AM</option>
|
||||
<option value="10:30">10:30 AM</option>
|
||||
<option value="11:00">11:00 AM</option>
|
||||
<option value="11:30">11:30 AM</option>
|
||||
<option value="12:00">12:00 PM</option>
|
||||
<option value="12:30">12:30 PM</option>
|
||||
<option value="13:00">1:00 PM</option>
|
||||
<option value="13:30">1:30 PM</option>
|
||||
<option value="14:00">2:00 PM</option>
|
||||
<option value="14:30">2:30 PM</option>
|
||||
<option value="15:00">3:00 PM</option>
|
||||
<option value="15:30">3:30 PM</option>
|
||||
<option value="16:00">4:00 PM</option>
|
||||
<option value="16:30">4:30 PM</option>
|
||||
<option value="17:00">5:00 PM</option>
|
||||
<option value="17:30">5:30 PM</option>
|
||||
<option value="18:00">6:00 PM</option>
|
||||
<option value="18:30">6:30 PM</option>
|
||||
<option value="19:00">7:00 PM</option>
|
||||
<option value="19:30">7:30 PM</option>
|
||||
<option value="20:00">8:00 PM</option>
|
||||
<option value="20:30">8:30 PM</option>
|
||||
<option value="21:00">9:00 PM</option>
|
||||
<option value="21:30">9:30 PM</option>
|
||||
<option value="22:00">10:00 PM</option>
|
||||
<option value="22:30">10:30 PM</option>
|
||||
<option value="23:00">11:00 PM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="timezone" class="block text-xs font-medium text-white/90 mb-1">Timezone</label>
|
||||
<select
|
||||
name="timezone"
|
||||
id="timezone"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
>
|
||||
<option value="America/Denver" selected>Mountain Time (MT)</option>
|
||||
<option value="America/New_York">Eastern Time (ET)</option>
|
||||
<option value="America/Chicago">Central Time (CT)</option>
|
||||
<option value="America/Los_Angeles">Pacific Time (PT)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="seating_type" class="block text-sm font-semibold text-white/90 mb-2">Seating Type</label>
|
||||
<select
|
||||
name="seating_type"
|
||||
id="seating_type"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
>
|
||||
<option value="general_admission">General Admission</option>
|
||||
<option value="assigned_seating">Assigned Seating</option>
|
||||
<option value="mixed">Mixed (GA + Assigned)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-semibold text-white/90 mb-2">Event Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
id="description"
|
||||
rows="4"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
placeholder="Join us for an elegant evening of exceptional dining, entertainment, and community..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium Add-ons Section (Coming Soon) -->
|
||||
<div class="border-t border-white/20 pt-8 opacity-50">
|
||||
<div class="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
id="addons-toggle"
|
||||
class="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-2xl font-light text-white/40 mb-2">Premium Add-ons</h2>
|
||||
<p class="text-white/40">Professional features and services (coming soon)</p>
|
||||
</div>
|
||||
<svg id="addons-chevron" class="w-6 h-6 text-white/40 transform transition-transform duration-200" 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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="addons-section" class="space-y-4 pointer-events-none hidden">
|
||||
<div class="bg-white/5 border-2 border-dashed border-white/20 rounded-2xl p-8 text-center">
|
||||
<div class="text-white/40 mb-4">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-white/40 mb-2">Premium Features Coming Soon</h3>
|
||||
<p class="text-white/40 text-sm max-w-md mx-auto">
|
||||
Advanced features like seating maps, AI descriptions, premium analytics, and professional scanning tools will be available in future updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Venue Selection -->
|
||||
<div class="border-t border-white/20 pt-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-light text-white">Venue</h2>
|
||||
<a
|
||||
href="/venues"
|
||||
target="_blank"
|
||||
class="text-white/70 hover:text-white text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
Manage Venues →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-white/90 mb-3">Choose Venue</label>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center space-x-3 p-4 border border-white/20 rounded-xl hover:bg-white/5 transition-colors duration-200 cursor-pointer">
|
||||
<input type="radio" name="venue_option" value="existing" class="text-blue-400 focus:ring-blue-400" checked>
|
||||
<div>
|
||||
<span class="font-medium text-white">Select from existing venues</span>
|
||||
<p class="text-sm text-white/70">Choose from your previously created venues</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center space-x-3 p-4 border border-white/20 rounded-xl hover:bg-white/5 transition-colors duration-200 cursor-pointer">
|
||||
<input type="radio" name="venue_option" value="custom" class="text-blue-400 focus:ring-blue-400">
|
||||
<div>
|
||||
<span class="font-medium text-white">Enter custom venue</span>
|
||||
<p class="text-sm text-white/70">Specify venue details for this event only</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Venue Selection -->
|
||||
<div id="existing-venue-section" class="space-y-4">
|
||||
<div>
|
||||
<label for="venue_id" class="block text-sm font-semibold text-white/90 mb-2">Select Venue</label>
|
||||
<select
|
||||
name="venue_id"
|
||||
id="venue_id"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
>
|
||||
<option value="">Loading venues...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Venue Section -->
|
||||
<div id="custom-venue-section" class="space-y-4 hidden">
|
||||
<div>
|
||||
<label for="custom_venue" class="block text-sm font-semibold text-white/90 mb-2">Venue Name & Address</label>
|
||||
<textarea
|
||||
name="custom_venue"
|
||||
id="custom_venue"
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 text-white placeholder-white/50"
|
||||
placeholder="The Little Nell 675 E Durant Ave Aspen, CO 81611"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-between items-center pt-8 border-t border-white/20">
|
||||
<div class="text-sm text-white/70">
|
||||
<p>Next: Add tickets, configure seating, and customize your event</p>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="px-6 py-3 border border-white/20 text-white/80 rounded-xl hover:bg-white/5 font-medium transition-colors duration-200"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-8 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Create Event & Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="mt-6 text-sm text-red-400 hidden bg-red-500/10 border border-red-400/20 rounded-xl p-4"></div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const eventForm = document.getElementById('event-form') as HTMLFormElement;
|
||||
const errorMessage = document.getElementById('error-message') as HTMLDivElement;
|
||||
const venueSelect = document.getElementById('venue_id') as HTMLSelectElement;
|
||||
const existingVenueSection = document.getElementById('existing-venue-section');
|
||||
const customVenueSection = document.getElementById('custom-venue-section');
|
||||
|
||||
let currentOrganizationId = null;
|
||||
let selectedAddons = [];
|
||||
|
||||
// Check authentication
|
||||
async function checkAuth() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get user details
|
||||
const { data: user } = await supabase
|
||||
.from('users')
|
||||
.select('name, email, organization_id, role')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (user) {
|
||||
currentOrganizationId = user.organization_id;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// Generate slug from title
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// Premium add-ons are disabled for now
|
||||
// This functionality will be enabled in future updates
|
||||
|
||||
// Load available venues
|
||||
async function loadVenues() {
|
||||
try {
|
||||
const { data: venues, error } = await supabase
|
||||
.from('venues')
|
||||
.select('id, name, address')
|
||||
.eq('organization_id', currentOrganizationId)
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
venueSelect.innerHTML = '<option value="">Select a venue...</option>';
|
||||
|
||||
if (venues.length === 0) {
|
||||
venueSelect.innerHTML = '<option value="">No venues created yet</option>';
|
||||
} else {
|
||||
venues.forEach(venue => {
|
||||
const option = document.createElement('option');
|
||||
option.value = venue.id;
|
||||
option.textContent = venue.name + (venue.address ? ` - ${venue.address}` : '');
|
||||
venueSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle venue loading errors gracefully
|
||||
venueSelect.innerHTML = '<option value="">Unable to load venues</option>';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle venue option change
|
||||
function handleVenueOptionChange() {
|
||||
const venueOption = document.querySelector('input[name="venue_option"]:checked')?.value;
|
||||
|
||||
if (venueOption === 'existing') {
|
||||
existingVenueSection.classList.remove('hidden');
|
||||
customVenueSection.classList.add('hidden');
|
||||
} else {
|
||||
existingVenueSection.classList.add('hidden');
|
||||
customVenueSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Event form submission
|
||||
eventForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
errorMessage.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(eventForm);
|
||||
const title = formData.get('title') as string;
|
||||
const eventDate = formData.get('event_date') as string;
|
||||
const eventTime = formData.get('event_time') as string;
|
||||
const timezone = formData.get('timezone') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const seatingType = formData.get('seating_type') as string;
|
||||
const venueOption = formData.get('venue_option') as string;
|
||||
|
||||
// Combine date and time
|
||||
if (!eventDate || !eventTime) {
|
||||
throw new Error('Please select both date and time for your event');
|
||||
}
|
||||
|
||||
const startTime = `${eventDate}T${eventTime}:00`;
|
||||
|
||||
const slug = generateSlug(title);
|
||||
|
||||
// Get current user and organization
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
let organizationId = currentOrganizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
// Create a default organization for the user
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
.insert([
|
||||
{ name: `${user.user_metadata?.name || user.email}'s Organization` }
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orgError) {
|
||||
throw new Error('Unable to create organization. Please contact support.');
|
||||
}
|
||||
|
||||
organizationId = org.id;
|
||||
|
||||
// Update user with organization_id
|
||||
const { error: updateError } = await supabase
|
||||
.from('users')
|
||||
.update({ organization_id: organizationId })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
// Silently handle user update errors
|
||||
}
|
||||
|
||||
// Update current state
|
||||
currentOrganizationId = organizationId;
|
||||
}
|
||||
|
||||
// Determine venue information
|
||||
let venue, venueId = null;
|
||||
|
||||
if (venueOption === 'existing') {
|
||||
venueId = formData.get('venue_id') as string;
|
||||
if (!venueId) {
|
||||
throw new Error('Please select a venue or choose custom venue option');
|
||||
}
|
||||
|
||||
// Get venue name for display
|
||||
const { data: venueData } = await supabase
|
||||
.from('venues')
|
||||
.select('name')
|
||||
.eq('id', venueId)
|
||||
.single();
|
||||
venue = venueData?.name || 'Selected Venue';
|
||||
} else {
|
||||
venue = formData.get('custom_venue') as string;
|
||||
if (!venue) {
|
||||
throw new Error('Please enter venue information');
|
||||
}
|
||||
}
|
||||
|
||||
// Create the event
|
||||
const { data: event, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.insert([
|
||||
{
|
||||
title,
|
||||
slug,
|
||||
venue,
|
||||
venue_id: venueId,
|
||||
start_time: startTime,
|
||||
description,
|
||||
created_by: user.id,
|
||||
organization_id: organizationId,
|
||||
seating_type: seatingType
|
||||
}
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (eventError) throw eventError;
|
||||
|
||||
// Premium add-ons will be handled in future updates
|
||||
|
||||
// Redirect to the event dashboard
|
||||
window.location.href = `/events/${event.id}/manage`;
|
||||
|
||||
} catch (error) {
|
||||
// Handle errors gracefully without exposing details
|
||||
errorMessage.textContent = 'An error occurred creating the event. Please try again.';
|
||||
errorMessage.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle premium addons section
|
||||
const addonsToggle = document.getElementById('addons-toggle');
|
||||
const addonsSection = document.getElementById('addons-section');
|
||||
const addonsChevron = document.getElementById('addons-chevron');
|
||||
|
||||
addonsToggle?.addEventListener('click', () => {
|
||||
const isHidden = addonsSection.classList.contains('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
addonsSection.classList.remove('hidden');
|
||||
addonsChevron.classList.add('rotate-180');
|
||||
} else {
|
||||
addonsSection.classList.add('hidden');
|
||||
addonsChevron.classList.remove('rotate-180');
|
||||
}
|
||||
});
|
||||
|
||||
// Event listeners
|
||||
document.querySelectorAll('input[name="venue_option"]').forEach(radio => {
|
||||
radio.addEventListener('change', handleVenueOptionChange);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
checkAuth().then(session => {
|
||||
if (session && currentOrganizationId) {
|
||||
loadVenues();
|
||||
}
|
||||
handleVenueOptionChange(); // Set initial state
|
||||
});
|
||||
</script>
|
||||
294
src/pages/index.astro
Normal file
294
src/pages/index.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>
|
||||
293
src/pages/inventory-pools.astro
Normal file
293
src/pages/inventory-pools.astro
Normal file
@@ -0,0 +1,293 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Check authentication
|
||||
const { data: { session } } = await Astro.request.headers.get('cookie')
|
||||
? await supabase.auth.getSession()
|
||||
: { data: { session: null } };
|
||||
|
||||
if (!session) {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
// Get user profile to check organization
|
||||
const { data: userProfile } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id, role')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (!userProfile?.organization_id && userProfile?.role !== 'admin') {
|
||||
return Astro.redirect('/dashboard');
|
||||
}
|
||||
|
||||
// Load inventory pools for the organization
|
||||
const { data: inventoryPools } = await supabase
|
||||
.from('inventory_pools')
|
||||
.select(`
|
||||
*,
|
||||
ticket_type_pool_allocations (
|
||||
allocated_quantity,
|
||||
ticket_types (
|
||||
name,
|
||||
event_id,
|
||||
events (
|
||||
title
|
||||
)
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('organization_id', userProfile.organization_id);
|
||||
---
|
||||
|
||||
<Layout title="Inventory Pools - Black Canyon Tickets">
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-gray-100">
|
||||
<!-- Sticky Navigation -->
|
||||
<nav class="sticky top-0 z-50 bg-white/90 backdrop-blur-lg shadow-xl border-b border-slate-200/50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<div class="flex items-center">
|
||||
<a href="/dashboard" class="text-xl font-medium text-gray-900">
|
||||
Black Canyon Tickets
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard" class="text-slate-600 hover:text-slate-900 font-medium transition-colors">Dashboard</a>
|
||||
<span class="text-sm text-slate-700 font-medium">{session.user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<div class="px-4 sm:px-0">
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Inventory Pools</h1>
|
||||
<p class="text-gray-600 mt-1">Manage ticket inventory across events</p>
|
||||
</div>
|
||||
<button
|
||||
id="create-pool-btn"
|
||||
class="inline-flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-xl font-semibold transition-colors shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Pool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="grid gap-6">
|
||||
{inventoryPools?.length === 0 ? (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No inventory pools</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating your first inventory pool.</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
id="create-first-pool-btn"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Create Inventory Pool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
inventoryPools?.map(pool => (
|
||||
<div class="border border-gray-200 rounded-lg p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">{pool.name}</h3>
|
||||
{pool.description && (
|
||||
<p class="text-gray-600 mt-1">{pool.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
pool.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{pool.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-500">Total Capacity</div>
|
||||
<div class="text-2xl font-semibold text-gray-900">{pool.total_capacity}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-500">Allocated</div>
|
||||
<div class="text-2xl font-semibold text-gray-900">{pool.allocated_capacity}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-500">Available</div>
|
||||
<div class="text-2xl font-semibold text-green-600">{pool.total_capacity - pool.allocated_capacity}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pool.ticket_type_pool_allocations?.length > 0 && (
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Allocations</h4>
|
||||
<div class="space-y-2">
|
||||
{pool.ticket_type_pool_allocations.map(allocation => (
|
||||
<div class="flex justify-between items-center py-2 px-3 bg-gray-50 rounded">
|
||||
<div>
|
||||
<span class="font-medium">{allocation.ticket_types.events.title}</span>
|
||||
<span class="text-gray-500 ml-2">- {allocation.ticket_types.name}</span>
|
||||
</div>
|
||||
<span class="font-medium">{allocation.allocated_quantity} tickets</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Create Pool Modal -->
|
||||
<div id="create-pool-modal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Create Inventory Pool</h3>
|
||||
|
||||
<form id="create-pool-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="pool-name" class="block text-sm font-medium text-gray-700">Pool Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pool-name"
|
||||
name="name"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="e.g., VIP Pool, General Admission Pool"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pool-description" class="block text-sm font-medium text-gray-700">Description (optional)</label>
|
||||
<textarea
|
||||
id="pool-description"
|
||||
name="description"
|
||||
rows="3"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Describe this inventory pool..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pool-capacity" class="block text-sm font-medium text-gray-700">Total Capacity</label>
|
||||
<input
|
||||
type="number"
|
||||
id="pool-capacity"
|
||||
name="total_capacity"
|
||||
required
|
||||
min="1"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="e.g., 1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-create-pool"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Create Pool
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const createPoolBtn = document.getElementById('create-pool-btn');
|
||||
const createFirstPoolBtn = document.getElementById('create-first-pool-btn');
|
||||
const createPoolModal = document.getElementById('create-pool-modal');
|
||||
const createPoolForm = document.getElementById('create-pool-form');
|
||||
const cancelCreatePool = document.getElementById('cancel-create-pool');
|
||||
|
||||
// Show modal
|
||||
function showCreateModal() {
|
||||
createPoolModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
function hideCreateModal() {
|
||||
createPoolModal.classList.add('hidden');
|
||||
createPoolForm.reset();
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
createPoolBtn?.addEventListener('click', showCreateModal);
|
||||
createFirstPoolBtn?.addEventListener('click', showCreateModal);
|
||||
cancelCreatePool?.addEventListener('click', hideCreateModal);
|
||||
|
||||
// Close modal on outside click
|
||||
createPoolModal?.addEventListener('click', (e) => {
|
||||
if (e.target === createPoolModal) {
|
||||
hideCreateModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
createPoolForm?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(createPoolForm);
|
||||
const poolData = {
|
||||
name: formData.get('name'),
|
||||
description: formData.get('description') || null,
|
||||
total_capacity: parseInt(formData.get('total_capacity')),
|
||||
organization_id: window.userOrgId // This would be set from the server-side data
|
||||
};
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_pools')
|
||||
.insert(poolData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
hideCreateModal();
|
||||
location.reload(); // Refresh to show new pool
|
||||
} catch (error) {
|
||||
console.error('Error creating pool:', error);
|
||||
alert('Error creating inventory pool. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Store organization ID for form submission
|
||||
window.userOrgId = '{userProfile?.organization_id}';
|
||||
</script>
|
||||
257
src/pages/privacy.astro
Normal file
257
src/pages/privacy.astro
Normal file
@@ -0,0 +1,257 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import SimpleHeader from '../components/SimpleHeader.astro';
|
||||
---
|
||||
|
||||
<Layout title="Privacy Policy - Black Canyon Tickets">
|
||||
<SimpleHeader />
|
||||
<main class="min-h-screen bg-gradient-to-br from-slate-50 via-emerald-50 to-teal-50">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative overflow-hidden py-16">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-emerald-600/5 to-teal-600/5"></div>
|
||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-full bg-emerald-100 text-emerald-700 text-sm font-medium mb-6">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Privacy & Data Protection
|
||||
</div>
|
||||
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 mb-4">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-2">
|
||||
Your privacy and data security are our top priorities
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<!-- Table of Contents -->
|
||||
<div class="bg-gray-50 border-b border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Table of Contents</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
||||
<a href="#introduction" class="text-emerald-600 hover:text-emerald-800 hover:underline">1. Introduction</a>
|
||||
<a href="#information-collected" class="text-emerald-600 hover:text-emerald-800 hover:underline">2. Information We Collect</a>
|
||||
<a href="#how-we-use" class="text-emerald-600 hover:text-emerald-800 hover:underline">3. How We Use Your Information</a>
|
||||
<a href="#information-sharing" class="text-emerald-600 hover:text-emerald-800 hover:underline">4. Information Sharing</a>
|
||||
<a href="#data-security" class="text-emerald-600 hover:text-emerald-800 hover:underline">5. Data Security</a>
|
||||
<a href="#data-retention" class="text-emerald-600 hover:text-emerald-800 hover:underline">6. Data Retention</a>
|
||||
<a href="#your-rights" class="text-emerald-600 hover:text-emerald-800 hover:underline">7. Your Rights</a>
|
||||
<a href="#cookies" class="text-emerald-600 hover:text-emerald-800 hover:underline">8. Cookies</a>
|
||||
<a href="#third-party-links" class="text-emerald-600 hover:text-emerald-800 hover:underline">9. Third-Party Links</a>
|
||||
<a href="#childrens-privacy" class="text-emerald-600 hover:text-emerald-800 hover:underline">10. Children's Privacy</a>
|
||||
<a href="#california-rights" class="text-emerald-600 hover:text-emerald-800 hover:underline">11. California Privacy Rights</a>
|
||||
<a href="#changes" class="text-emerald-600 hover:text-emerald-800 hover:underline">12. Changes to This Policy</a>
|
||||
<a href="#contact" class="text-emerald-600 hover:text-emerald-800 hover:underline">13. Contact Us</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="prose prose-emerald prose-lg max-w-none">
|
||||
<h2 id="introduction">1. Introduction</h2>
|
||||
<p>
|
||||
San Juan Events dba Black Canyon Tickets ("we," "our," or "us") respects your privacy and is committed to protecting your personal information. This Privacy Policy explains how we collect, use, and safeguard your information when you use our ticketing platform operated at portal.blackcanyontickets.com.
|
||||
</p>
|
||||
|
||||
<div class="bg-emerald-50 border border-emerald-200 rounded-lg p-4 my-6">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-emerald-600 mt-0.5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-semibold text-emerald-900 mb-1">GDPR & CCPA Compliant</h4>
|
||||
<p class="text-emerald-800 text-sm">We are committed to compliance with the General Data Protection Regulation (GDPR) and California Consumer Privacy Act (CCPA).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 id="information-collected">2. Information We Collect</h2>
|
||||
|
||||
<h3>Personal Information</h3>
|
||||
<p>We collect information you provide directly to us, including:</p>
|
||||
<ul>
|
||||
<li>Name and contact information (email, phone number)</li>
|
||||
<li>Payment information (processed securely through Stripe)</li>
|
||||
<li>Event details and preferences</li>
|
||||
<li>Account credentials and profile information</li>
|
||||
</ul>
|
||||
|
||||
<h3>Automatically Collected Information</h3>
|
||||
<p>We automatically collect certain information when you use our Service:</p>
|
||||
<ul>
|
||||
<li>Device information (IP address, browser type, operating system)</li>
|
||||
<li>Usage data (pages visited, time spent on site)</li>
|
||||
<li>Cookies and similar tracking technologies</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. How We Use Your Information</h2>
|
||||
<p>We use your information to:</p>
|
||||
<ul>
|
||||
<li>Provide and maintain our ticketing services</li>
|
||||
<li>Process payments and ticket transactions</li>
|
||||
<li>Send you important updates about your events or tickets</li>
|
||||
<li>Improve our platform and user experience</li>
|
||||
<li>Comply with legal obligations</li>
|
||||
<li>Prevent fraud and ensure security</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Information Sharing</h2>
|
||||
<p>We may share your information in the following circumstances:</p>
|
||||
|
||||
<h3>With Event Organizers</h3>
|
||||
<p>When you purchase tickets, we share necessary information with event organizers to facilitate entry and provide event services.</p>
|
||||
|
||||
<h3>With Service Providers</h3>
|
||||
<p>We work with third-party service providers who help us operate our platform:</p>
|
||||
<ul>
|
||||
<li>Stripe for payment processing</li>
|
||||
<li>Supabase for database and authentication services</li>
|
||||
<li>Email service providers for communications</li>
|
||||
</ul>
|
||||
|
||||
<h3>Legal Requirements</h3>
|
||||
<p>We may disclose your information if required by law or to protect our rights and safety.</p>
|
||||
|
||||
<h2>5. Data Security</h2>
|
||||
<p>
|
||||
We implement appropriate technical and organizational security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the internet is 100% secure.
|
||||
</p>
|
||||
|
||||
<h2>6. Data Retention</h2>
|
||||
<p>
|
||||
We retain your personal information for as long as necessary to fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required or permitted by law.
|
||||
</p>
|
||||
|
||||
<h2>7. Your Rights</h2>
|
||||
<p>You have the right to:</p>
|
||||
<ul>
|
||||
<li>Access your personal information</li>
|
||||
<li>Correct inaccurate information</li>
|
||||
<li>Request deletion of your information</li>
|
||||
<li>Object to processing of your information</li>
|
||||
<li>Request data portability</li>
|
||||
</ul>
|
||||
|
||||
<h2>8. Cookies</h2>
|
||||
<p>
|
||||
We use cookies and similar tracking technologies to enhance your experience on our platform. You can control cookie preferences through your browser settings, but disabling cookies may affect functionality.
|
||||
</p>
|
||||
|
||||
<h2>9. Third-Party Links</h2>
|
||||
<p>
|
||||
Our Service may contain links to third-party websites. We are not responsible for the privacy practices of these external sites. We encourage you to review their privacy policies.
|
||||
</p>
|
||||
|
||||
<h2>10. Children's Privacy</h2>
|
||||
<p>
|
||||
Our Service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have inadvertently collected such information, please contact us immediately.
|
||||
</p>
|
||||
|
||||
<h2>11. California Privacy Rights</h2>
|
||||
<p>
|
||||
If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA), including the right to know what personal information we collect, use, and share about you.
|
||||
</p>
|
||||
|
||||
<h2>12. Changes to This Privacy Policy</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.
|
||||
</p>
|
||||
|
||||
<h2 id="contact">13. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about this Privacy Policy or our privacy practices, please contact us using the methods below. We are committed to responding to your inquiries promptly.
|
||||
</p>
|
||||
|
||||
<div class="bg-emerald-50 border border-emerald-200 rounded-lg p-6 mt-6">
|
||||
<h4 class="font-semibold text-emerald-900 mb-4">Contact Information</h4>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<svg class="w-5 h-5 text-emerald-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||
</svg>
|
||||
<strong class="text-emerald-900">Privacy Inquiries:</strong>
|
||||
</div>
|
||||
<p class="text-emerald-800 ml-7 mb-3">privacy@blackcanyontickets.com</p>
|
||||
|
||||
<div class="flex items-center mb-3">
|
||||
<svg class="w-5 h-5 text-emerald-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||
</svg>
|
||||
<strong class="text-emerald-900">General Support:</strong>
|
||||
</div>
|
||||
<p class="text-emerald-800 ml-7">support@blackcanyontickets.com</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<svg class="w-5 h-5 text-emerald-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<strong class="text-emerald-900">Business Entity:</strong>
|
||||
</div>
|
||||
<p class="text-emerald-800 ml-7 mb-3">San Juan Events dba Black Canyon Tickets</p>
|
||||
|
||||
<div class="flex items-center mb-3">
|
||||
<svg class="w-5 h-5 text-emerald-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<strong class="text-emerald-900">Location:</strong>
|
||||
</div>
|
||||
<p class="text-emerald-800 ml-7">Montrose, Colorado</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-white rounded border border-emerald-300">
|
||||
<p class="text-sm text-emerald-700">
|
||||
<strong>Data Rights Requests:</strong> To exercise your rights under GDPR or CCPA (data access, deletion, portability), please email privacy@blackcanyontickets.com with "Data Rights Request" in the subject line.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Footer -->
|
||||
<div class="bg-gray-50 border-t border-gray-200 px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0">
|
||||
<div class="text-sm text-gray-600">
|
||||
<p>This privacy policy is effective as of the last updated date above.</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/terms" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a href="/support" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-2 0c0 .993-.241 1.929-.668 2.754l-1.524-1.525a3.997 3.997 0 00.078-2.183l1.562-1.562C17.756 8.249 18 9.1 18 10z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Get Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="mt-8 text-center">
|
||||
<a href="/" class="inline-flex items-center text-emerald-600 hover:text-emerald-800 font-medium transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Back to Application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
1559
src/pages/scan.astro
Normal file
1559
src/pages/scan.astro
Normal file
File diff suppressed because it is too large
Load Diff
326
src/pages/settings/fees.astro
Normal file
326
src/pages/settings/fees.astro
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Navigation from '../../components/Navigation.astro';
|
||||
---
|
||||
|
||||
<Layout title="Ticket Fee Calculator - Black Canyon Tickets">
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<!-- Animated background elements -->
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-purple-600/20 to-pink-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-blue-600/20 to-cyan-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-gradient-to-br from-indigo-600/10 to-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
</div>
|
||||
<Navigation
|
||||
title="Ticket Fee Calculator"
|
||||
showBackLink={true}
|
||||
backLinkUrl="/dashboard"
|
||||
backLinkText="← Dashboard"
|
||||
/>
|
||||
|
||||
<main class="max-w-4xl mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<div class="px-4 sm:px-0">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-3 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">Ticket Fee Calculator</h1>
|
||||
<p class="text-white/80">Calculate ticketing fees and payouts for your events with transparent fee breakdowns</p>
|
||||
</div>
|
||||
|
||||
<!-- Fee Structure Info -->
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 mb-8">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2 flex items-center">
|
||||
<svg class="w-5 h-5 text-purple-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
BCT Platform Fee Structure
|
||||
</h3>
|
||||
<div class="text-2xl font-bold text-purple-300 mb-2">$1.50 + 2.5% per ticket</div>
|
||||
<div class="text-lg text-indigo-300 mb-2">Stripe Processing Fee: 2.9% + $0.30</div>
|
||||
<p class="text-sm text-white/70">Choose how these fees are handled for your events</p>
|
||||
</div>
|
||||
<div class="bg-purple-500/20 rounded-xl p-3">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calculator Form -->
|
||||
<div class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 mb-8">
|
||||
<form id="fee-calculator-form" class="space-y-6">
|
||||
<!-- Step 1: Enter ticket price -->
|
||||
<div>
|
||||
<label for="ticket-price" class="block text-lg font-medium text-white/90 mb-3">
|
||||
Enter the base ticket price:
|
||||
</label>
|
||||
<div class="relative max-w-xs">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span class="text-white/60 text-lg">$</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
id="ticket-price"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="20.00"
|
||||
class="block w-full pl-8 pr-3 py-3 bg-white/10 border border-white/30 rounded-lg shadow-sm focus:ring-2 focus:ring-purple-400 focus:border-purple-400 text-lg text-white placeholder-white/50 backdrop-blur-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Fee handling option -->
|
||||
<div>
|
||||
<label class="block text-lg font-medium text-white/90 mb-3">
|
||||
Who will pay the fees?
|
||||
</label>
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start cursor-pointer p-4 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10 hover:bg-white/10 transition-colors">
|
||||
<input type="radio" name="fee_option" value="organizer_absorbs" class="mt-1 mr-3 text-purple-600 focus:ring-purple-500" />
|
||||
<div class="flex-1">
|
||||
<span class="font-medium text-white text-lg">1. The organizer will absorb the fees</span>
|
||||
<p class="text-sm text-white/70 mt-1">(fees deducted from payout)</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start cursor-pointer p-4 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10 hover:bg-white/10 transition-colors">
|
||||
<input type="radio" name="fee_option" value="customer_pays" class="mt-1 mr-3 text-purple-600 focus:ring-purple-500" />
|
||||
<div class="flex-1">
|
||||
<span class="font-medium text-white text-lg">2. The customer will pay the fees</span>
|
||||
<p class="text-sm text-white/70 mt-1">(fees added on top of ticket price)</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white py-3 px-8 rounded-xl text-lg font-medium transition-all duration-200 shadow-lg hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
Calculate Fees
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results Display -->
|
||||
<div id="results-display" class="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 hidden">
|
||||
<h3 class="text-2xl font-semibold text-white mb-6 text-center">Fee Breakdown</h3>
|
||||
<div id="results-content" class="space-y-4">
|
||||
<!-- Results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div id="error-message" class="mt-4 text-sm text-red-400 bg-red-500/20 border border-red-400/30 rounded-lg p-3 hidden"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Fee calculation logic
|
||||
const BCT_FEE_PERCENTAGE = 0.025; // 2.5%
|
||||
const BCT_FEE_FIXED = 1.50;
|
||||
const STRIPE_FEE_PERCENTAGE = 0.029; // 2.9%
|
||||
const STRIPE_FEE_FIXED = 0.30;
|
||||
|
||||
// Calculate fees for organizer absorbs scenario
|
||||
function calculateOrganizerAbsorbs(ticketPrice) {
|
||||
const bctFee = (ticketPrice * BCT_FEE_PERCENTAGE) + BCT_FEE_FIXED;
|
||||
const stripeFee = (ticketPrice * STRIPE_FEE_PERCENTAGE) + STRIPE_FEE_FIXED;
|
||||
const netPayout = ticketPrice - bctFee - stripeFee;
|
||||
|
||||
return {
|
||||
totalTicketPrice: ticketPrice,
|
||||
bctFee: bctFee,
|
||||
stripeFee: stripeFee,
|
||||
netPayout: netPayout
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate fees for customer pays scenario
|
||||
function calculateCustomerPays(ticketPrice) {
|
||||
const bctFee = (ticketPrice * BCT_FEE_PERCENTAGE) + BCT_FEE_FIXED;
|
||||
const subtotal = ticketPrice + bctFee;
|
||||
const stripeFee = (subtotal * STRIPE_FEE_PERCENTAGE) + STRIPE_FEE_FIXED;
|
||||
const totalTicketPrice = subtotal + stripeFee;
|
||||
const netPayout = ticketPrice;
|
||||
|
||||
return {
|
||||
totalTicketPrice: totalTicketPrice,
|
||||
bctFee: bctFee,
|
||||
stripeFee: stripeFee,
|
||||
netPayout: netPayout
|
||||
};
|
||||
}
|
||||
|
||||
// Display calculation results
|
||||
function displayResults(results, scenario) {
|
||||
const resultsDisplay = document.getElementById('results-display');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
|
||||
if (!resultsDisplay || !resultsContent) return;
|
||||
|
||||
let html = '';
|
||||
|
||||
if (scenario === 'organizer_absorbs') {
|
||||
html = `
|
||||
<div class="bg-white/5 backdrop-blur-sm rounded-lg p-6 border border-white/10">
|
||||
<h4 class="text-lg font-semibold text-white mb-4 text-center">Organizer Absorbs Fees</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-white/90 py-2">
|
||||
<span class="text-lg">Total Ticket Price:</span>
|
||||
<span class="text-lg font-bold text-blue-400">$${results.totalTicketPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-white/90 py-2">
|
||||
<span>BCT Fee:</span>
|
||||
<span class="font-medium text-red-400">$${results.bctFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-white/90 py-2">
|
||||
<span>Stripe Fee:</span>
|
||||
<span class="font-medium text-orange-400">$${results.stripeFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="border-t border-white/20 pt-2">
|
||||
<div class="flex justify-between text-white py-2">
|
||||
<span class="text-lg font-semibold">Net Payout to Organizer:</span>
|
||||
<span class="text-lg font-bold text-green-400">$${results.netPayout.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white/5 backdrop-blur-sm rounded-lg p-4 border border-white/10">
|
||||
<h5 class="text-sm font-medium text-white/90 mb-2">What this means:</h5>
|
||||
<ul class="text-sm text-white/70 space-y-1">
|
||||
<li>• Customer pays the ticket price only</li>
|
||||
<li>• Fees are deducted from your payout</li>
|
||||
<li>• Simple checkout experience</li>
|
||||
<li>• You receive less per ticket</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html = `
|
||||
<div class="bg-white/5 backdrop-blur-sm rounded-lg p-6 border border-white/10">
|
||||
<h4 class="text-lg font-semibold text-white mb-4 text-center">Customer Pays Fees</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-white/90 py-2">
|
||||
<span class="text-lg">Total Ticket Price:</span>
|
||||
<span class="text-lg font-bold text-blue-400">$${results.totalTicketPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-white/90 py-2">
|
||||
<span>BCT Fee:</span>
|
||||
<span class="font-medium text-red-400">$${results.bctFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-white/90 py-2">
|
||||
<span>Stripe Fee:</span>
|
||||
<span class="font-medium text-orange-400">$${results.stripeFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="border-t border-white/20 pt-2">
|
||||
<div class="flex justify-between text-white py-2">
|
||||
<span class="text-lg font-semibold">Net Payout to Organizer:</span>
|
||||
<span class="text-lg font-bold text-green-400">$${results.netPayout.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white/5 backdrop-blur-sm rounded-lg p-4 border border-white/10">
|
||||
<h5 class="text-sm font-medium text-white/90 mb-2">What this means:</h5>
|
||||
<ul class="text-sm text-white/70 space-y-1">
|
||||
<li>• Customer pays ticket price + fees</li>
|
||||
<li>• Fees are shown separately at checkout</li>
|
||||
<li>• You receive the full ticket price</li>
|
||||
<li>• Higher total cost for customer</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultsContent.innerHTML = html;
|
||||
resultsDisplay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
if (errorMessage) {
|
||||
errorMessage.textContent = message;
|
||||
errorMessage.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
function hideError() {
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
if (errorMessage) {
|
||||
errorMessage.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission handler
|
||||
document.getElementById('fee-calculator-form')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
|
||||
const ticketPriceInput = document.getElementById('ticket-price') as HTMLInputElement;
|
||||
const feeOptionInput = document.querySelector('input[name="fee_option"]:checked') as HTMLInputElement;
|
||||
|
||||
if (!ticketPriceInput || !feeOptionInput) {
|
||||
showError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketPrice = parseFloat(ticketPriceInput.value);
|
||||
const feeOption = feeOptionInput.value;
|
||||
|
||||
if (isNaN(ticketPrice) || ticketPrice <= 0) {
|
||||
showError('Please enter a valid ticket price');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticketPrice > 10000) {
|
||||
showError('Ticket price cannot exceed $10,000');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate fees based on selected option
|
||||
let results;
|
||||
if (feeOption === 'organizer_absorbs') {
|
||||
results = calculateOrganizerAbsorbs(ticketPrice);
|
||||
} else {
|
||||
results = calculateCustomerPays(ticketPrice);
|
||||
}
|
||||
|
||||
// Display results
|
||||
displayResults(results, feeOption);
|
||||
|
||||
// Scroll to results
|
||||
document.getElementById('results-display')?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Clear results when inputs change
|
||||
document.getElementById('ticket-price')?.addEventListener('input', () => {
|
||||
const resultsDisplay = document.getElementById('results-display');
|
||||
if (resultsDisplay) {
|
||||
resultsDisplay.classList.add('hidden');
|
||||
}
|
||||
hideError();
|
||||
});
|
||||
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target && (e.target as HTMLInputElement).name === 'fee_option') {
|
||||
const resultsDisplay = document.getElementById('results-display');
|
||||
if (resultsDisplay) {
|
||||
resultsDisplay.classList.add('hidden');
|
||||
}
|
||||
hideError();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
187
src/pages/stripe/connect.astro
Normal file
187
src/pages/stripe/connect.astro
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Connect Stripe Account - Black Canyon Tickets">
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<nav class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/dashboard" class="text-xl font-semibold text-gray-900">Black Canyon Tickets</a>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<a href="/dashboard" class="text-gray-600 hover:text-gray-900">← Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-2xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-indigo-100">
|
||||
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-4 text-2xl font-bold text-gray-900">Connect Your Stripe Account</h2>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Connect your Stripe account to receive payments directly from ticket sales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="account-status" class="hidden mb-6">
|
||||
<div class="rounded-md bg-green-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-green-800">Stripe Account Connected</h3>
|
||||
<p class="mt-1 text-sm text-green-700">Your Stripe account is connected and ready to receive payments.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connect-section">
|
||||
<div class="space-y-4">
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-medium text-gray-900 mb-2">How It Works</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• Payments go directly to your Stripe account</li>
|
||||
<li>• We collect a 3% + $0.30 platform fee per transaction</li>
|
||||
<li>• You receive the remaining amount instantly</li>
|
||||
<li>• Full transparency - see exactly what you're paying</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4 bg-blue-50">
|
||||
<h3 class="font-medium text-blue-900 mb-2">Example</h3>
|
||||
<p class="text-sm text-blue-800">
|
||||
For a $100 ticket: You receive $96.70, we collect $3.30 platform fee
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="connect-stripe-btn"
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-3 px-4 rounded-md font-medium"
|
||||
>
|
||||
Connect with Stripe
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-gray-500 text-center">
|
||||
By connecting your Stripe account, you agree to our Terms of Service and Stripe's Terms of Service
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="hidden text-center py-4">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p class="mt-2 text-sm text-gray-500">Checking account status...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateConnectOnboardingUrl } from '../../lib/stripe';
|
||||
|
||||
const connectBtn = document.getElementById('connect-stripe-btn');
|
||||
const accountStatus = document.getElementById('account-status');
|
||||
const connectSection = document.getElementById('connect-section');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
// Check authentication
|
||||
async function checkAuth() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
// Check if organization already has Stripe account
|
||||
async function checkStripeStatus() {
|
||||
try {
|
||||
loading.classList.remove('hidden');
|
||||
connectSection.classList.add('hidden');
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
// Get user's organization
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userData?.organization_id) {
|
||||
const { data: org } = await supabase
|
||||
.from('organizations')
|
||||
.select('stripe_account_id')
|
||||
.eq('id', userData.organization_id)
|
||||
.single();
|
||||
|
||||
if (org?.stripe_account_id) {
|
||||
// Account is connected
|
||||
accountStatus.classList.remove('hidden');
|
||||
loading.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No account connected
|
||||
connectSection.classList.remove('hidden');
|
||||
loading.classList.add('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error checking Stripe status:', error);
|
||||
connectSection.classList.remove('hidden');
|
||||
loading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Connect button click
|
||||
connectBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
// Get user's organization
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!userData?.organization_id) {
|
||||
throw new Error('No organization found. Please create an event first.');
|
||||
}
|
||||
|
||||
// Generate Stripe Connect URL
|
||||
const connectUrl = generateConnectOnboardingUrl(userData.organization_id);
|
||||
|
||||
// Redirect to Stripe Connect
|
||||
window.location.href = connectUrl;
|
||||
} catch (error) {
|
||||
console.error('Error connecting Stripe:', error);
|
||||
alert('Error connecting to Stripe. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
checkAuth().then(session => {
|
||||
if (session) {
|
||||
checkStripeStatus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
569
src/pages/support.astro
Normal file
569
src/pages/support.astro
Normal file
@@ -0,0 +1,569 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import SimpleHeader from '../components/SimpleHeader.astro';
|
||||
import ChatWidget from '../components/ChatWidget.tsx';
|
||||
---
|
||||
|
||||
<Layout title="Support - Black Canyon Tickets">
|
||||
<SimpleHeader />
|
||||
<main class="min-h-screen bg-gray-50">
|
||||
<!-- Hero Section -->
|
||||
<div class="bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800 text-white">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
How can we help you?
|
||||
</h1>
|
||||
<p class="text-xl text-blue-100 mb-8">
|
||||
Find answers, get support, and learn how to make the most of Black Canyon Tickets
|
||||
</p>
|
||||
|
||||
<!-- Enhanced Search -->
|
||||
<div class="max-w-lg mx-auto">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="help-search"
|
||||
placeholder="Search help articles, guides, and FAQs..."
|
||||
class="w-full pl-12 pr-4 py-4 text-gray-900 bg-white border-0 rounded-xl shadow-lg focus:ring-4 focus:ring-blue-300 focus:outline-none text-lg"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-blue-200 mt-3">
|
||||
Try searching: "create event", "scan tickets", "payments", "refunds"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Help Cards -->
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8 relative z-10">
|
||||
<div class="grid md:grid-cols-3 gap-6 mb-16">
|
||||
<!-- Get Started -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center transform hover:scale-105 transition-all duration-200">
|
||||
<div class="w-16 h-16 bg-gradient-to-r from-green-400 to-green-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3">New to the Platform?</h3>
|
||||
<p class="text-gray-600 mb-6 text-sm leading-relaxed">Start here for account setup, first event creation, and basic tutorials.</p>
|
||||
<a href="/docs" class="inline-flex items-center px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
Getting Started Guide
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center transform hover:scale-105 transition-all duration-200">
|
||||
<div class="w-16 h-16 bg-gradient-to-r from-blue-400 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3">Need Personal Help?</h3>
|
||||
<p class="text-gray-600 mb-6 text-sm leading-relaxed">Get direct support from our team. We typically respond within 24 hours.</p>
|
||||
<a href="mailto:support@blackcanyontickets.com" class="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
Email Support
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Browse Knowledge Base -->
|
||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center transform hover:scale-105 transition-all duration-200">
|
||||
<div class="w-16 h-16 bg-gradient-to-r from-purple-400 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3">Browse All Guides</h3>
|
||||
<p class="text-gray-600 mb-6 text-sm leading-relaxed">Comprehensive documentation covering every feature and use case.</p>
|
||||
<a href="#documentation" class="inline-flex items-center px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium">
|
||||
View Documentation
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popular Topics -->
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 mb-16">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">Popular Help Topics</h2>
|
||||
<p class="text-lg text-gray-600">Quick access to the most commonly searched topics</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Topic Card 1 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Creating Events</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Learn how to set up your first event, add ticket types, and configure settings.</p>
|
||||
<a href="#" class="text-blue-600 hover:text-blue-800 text-sm font-medium">Learn more →</a>
|
||||
</div>
|
||||
|
||||
<!-- Topic Card 2 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2V5h1v1H5zM3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm2 2v-1h1v1H5zM13 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm2 2V5h1v1h-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">QR Code Scanning</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Set up mobile scanning for door staff and handle check-ins efficiently.</p>
|
||||
<a href="#" class="text-green-600 hover:text-green-800 text-sm font-medium">Learn more →</a>
|
||||
</div>
|
||||
|
||||
<!-- Topic Card 3 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z"></path>
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Payments & Payouts</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Understand fee structures, Stripe setup, and when you receive payments.</p>
|
||||
<a href="#" class="text-yellow-600 hover:text-yellow-800 text-sm font-medium">Learn more →</a>
|
||||
</div>
|
||||
|
||||
<!-- Topic Card 4 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Troubleshooting</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Fix common issues with events, payments, scanning, and technical problems.</p>
|
||||
<a href="#" class="text-red-600 hover:text-red-800 text-sm font-medium">Learn more →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 mb-16">
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-gray-800 to-gray-900 px-8 py-8">
|
||||
<h2 class="text-3xl font-bold text-white mb-2">Frequently Asked Questions</h2>
|
||||
<p class="text-gray-300">Quick answers to the most common questions</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="space-y-1">
|
||||
<!-- FAQ Item 1 -->
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button class="w-full px-6 py-4 text-left focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-50 hover:bg-gray-100 transition-colors" data-faq-toggle>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">How do I create my first event?</h3>
|
||||
<svg class="w-5 h-5 text-gray-400 transform transition-transform" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="hidden px-6 pb-4 bg-white">
|
||||
<div class="pt-2 text-gray-600">
|
||||
<ol class="list-decimal list-inside space-y-2">
|
||||
<li>Complete your account setup and verify your email</li>
|
||||
<li>Connect your Stripe account for payment processing</li>
|
||||
<li>Click "Create Event" in your dashboard</li>
|
||||
<li>Fill in event details, date, time, and venue information</li>
|
||||
<li>Set up ticket types with pricing</li>
|
||||
<li>Upload cover image and additional photos</li>
|
||||
<li>Preview your event and test the checkout process</li>
|
||||
<li>Publish and start promoting your event</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Item 2 -->
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button class="w-full px-6 py-4 text-left focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-50 hover:bg-gray-100 transition-colors" data-faq-toggle>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">What are the platform fees?</h3>
|
||||
<svg class="w-5 h-5 text-gray-400 transform transition-transform" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="hidden px-6 pb-4 bg-white">
|
||||
<div class="pt-2 text-gray-600">
|
||||
<p class="mb-3">Our transparent fee structure is <strong>2.5% + $1.50 per ticket</strong>. This includes:</p>
|
||||
<ul class="list-disc list-inside space-y-1 mb-3">
|
||||
<li>Payment processing through Stripe</li>
|
||||
<li>QR code generation and scanning</li>
|
||||
<li>Event hosting and management tools</li>
|
||||
<li>Customer support</li>
|
||||
<li>Real-time analytics and reporting</li>
|
||||
</ul>
|
||||
<p>Fees are automatically deducted before funds are transferred to your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Item 3 -->
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button class="w-full px-6 py-4 text-left focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-50 hover:bg-gray-100 transition-colors" data-faq-toggle>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">How does QR code scanning work?</h3>
|
||||
<svg class="w-5 h-5 text-gray-400 transform transition-transform" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="hidden px-6 pb-4 bg-white">
|
||||
<div class="pt-2 text-gray-600">
|
||||
<p class="mb-3">Our QR scanning system is designed for simplicity:</p>
|
||||
<ul class="list-disc list-inside space-y-1 mb-3">
|
||||
<li><strong>No app required</strong> - works in any web browser</li>
|
||||
<li><strong>Access</strong> the scanner at <code class="bg-gray-100 px-2 py-1 rounded">/scan</code></li>
|
||||
<li><strong>Mobile-friendly</strong> - works on smartphones and tablets</li>
|
||||
<li><strong>Real-time validation</strong> - instantly checks ticket authenticity</li>
|
||||
<li><strong>Offline capability</strong> - continues working with poor connectivity</li>
|
||||
<li><strong>Check-in tracking</strong> - prevents duplicate entries</li>
|
||||
</ul>
|
||||
<p>Train your door staff in minutes, not hours.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Item 4 -->
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button class="w-full px-6 py-4 text-left focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-50 hover:bg-gray-100 transition-colors" data-faq-toggle>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">When do I receive payment for ticket sales?</h3>
|
||||
<svg class="w-5 h-5 text-gray-400 transform transition-transform" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="hidden px-6 pb-4 bg-white">
|
||||
<div class="pt-2 text-gray-600">
|
||||
<p class="mb-3">Payments are processed automatically through Stripe Connect:</p>
|
||||
<ul class="list-disc list-inside space-y-1 mb-3">
|
||||
<li><strong>Automatic processing</strong> - funds transfer without manual intervention</li>
|
||||
<li><strong>Platform fees deducted</strong> - you receive the net amount</li>
|
||||
<li><strong>Payout schedule</strong> - typically 2-7 business days (set by Stripe)</li>
|
||||
<li><strong>Direct deposit</strong> - funds go straight to your connected bank account</li>
|
||||
<li><strong>Real-time tracking</strong> - view pending and completed payouts in your dashboard</li>
|
||||
</ul>
|
||||
<p>You can view detailed payout information in your Stripe dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentation Section -->
|
||||
<div class="bg-white py-16" id="documentation">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">Complete Documentation</h2>
|
||||
<p class="text-xl text-gray-600">Everything you need to master Black Canyon Tickets</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<!-- Getting Started -->
|
||||
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-2xl p-8 border border-green-200">
|
||||
<div class="w-12 h-12 bg-green-600 rounded-xl flex items-center justify-center mb-6">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4">Getting Started</h3>
|
||||
<ul class="space-y-3 text-gray-700 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-600 mr-2">•</span>
|
||||
Account setup and verification
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-600 mr-2">•</span>
|
||||
Connecting your Stripe account
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-600 mr-2">•</span>
|
||||
Creating your first event
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-600 mr-2">•</span>
|
||||
Platform overview and basics
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/getting-started" class="inline-flex items-center text-green-600 hover:text-green-800 font-semibold">
|
||||
View Getting Started Guide
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Event Management -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-8 border border-blue-200">
|
||||
<div class="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center mb-6">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4">Event Management</h3>
|
||||
<ul class="space-y-3 text-gray-700 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-blue-600 mr-2">•</span>
|
||||
Creating and configuring events
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-blue-600 mr-2">•</span>
|
||||
Setting up ticket types and pricing
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-blue-600 mr-2">•</span>
|
||||
Managing seating and capacity
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-blue-600 mr-2">•</span>
|
||||
Publishing and promotion
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/events" class="inline-flex items-center text-blue-600 hover:text-blue-800 font-semibold">
|
||||
View Event Guides
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Scanning -->
|
||||
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-2xl p-8 border border-purple-200">
|
||||
<div class="w-12 h-12 bg-purple-600 rounded-xl flex items-center justify-center mb-6">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2V5h1v1H5zM3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm2 2v-1h1v1H5zM13 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm2 2V5h1v1h-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4">QR Code Scanning</h3>
|
||||
<ul class="space-y-3 text-gray-700 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-purple-600 mr-2">•</span>
|
||||
Setting up mobile scanning
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-purple-600 mr-2">•</span>
|
||||
Training door staff
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-purple-600 mr-2">•</span>
|
||||
Handling check-in issues
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-purple-600 mr-2">•</span>
|
||||
Offline scanning capabilities
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/scanning" class="inline-flex items-center text-purple-600 hover:text-purple-800 font-semibold">
|
||||
View Scanning Guide
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-2xl p-8 border border-yellow-200">
|
||||
<div class="w-12 h-12 bg-yellow-600 rounded-xl flex items-center justify-center mb-6">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z"></path>
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4">Payments & Payouts</h3>
|
||||
<ul class="space-y-3 text-gray-700 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-yellow-600 mr-2">•</span>
|
||||
Stripe Connect setup
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-yellow-600 mr-2">•</span>
|
||||
Understanding platform fees
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-yellow-600 mr-2">•</span>
|
||||
Payout schedules and tracking
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-yellow-600 mr-2">•</span>
|
||||
Processing refunds
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/payments" class="inline-flex items-center text-yellow-600 hover:text-yellow-800 font-semibold">
|
||||
View Payment Guide
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- API Documentation -->
|
||||
<div class="bg-gradient-to-br from-indigo-50 to-indigo-100 rounded-2xl p-8 border border-indigo-200">
|
||||
<div class="w-12 h-12 bg-indigo-600 rounded-xl flex items-center justify-center mb-6">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4">API Documentation</h3>
|
||||
<ul class="space-y-3 text-gray-700 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-indigo-600 mr-2">•</span>
|
||||
REST API endpoints
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-indigo-600 mr-2">•</span>
|
||||
Authentication and security
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-indigo-600 mr-2">•</span>
|
||||
Webhook integrations
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-indigo-600 mr-2">•</span>
|
||||
Code examples and SDKs
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/api" class="inline-flex items-center text-indigo-600 hover:text-indigo-800 font-semibold">
|
||||
View API Docs
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Troubleshooting -->
|
||||
<div class="bg-gradient-to-br from-red-50 to-red-100 rounded-2xl p-8 border border-red-200">
|
||||
<div class="w-12 h-12 bg-red-600 rounded-xl flex items-center justify-center mb-6">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4">Troubleshooting</h3>
|
||||
<ul class="space-y-3 text-gray-700 mb-6">
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-2">•</span>
|
||||
Common issues and solutions
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-2">•</span>
|
||||
Payment processing problems
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-2">•</span>
|
||||
Scanning and check-in issues
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-2">•</span>
|
||||
Error codes and meanings
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/docs/troubleshooting" class="inline-flex items-center text-red-600 hover:text-red-800 font-semibold">
|
||||
View Troubleshooting
|
||||
<svg class="w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<div class="bg-gradient-to-br from-gray-900 via-blue-900 to-indigo-900 py-16">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-white mb-4">Still Need Help?</h2>
|
||||
<p class="text-xl text-blue-100 mb-8">Our support team is here to help you succeed</p>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8 mb-8">
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||
<div class="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Email Support</h3>
|
||||
<p class="text-blue-100 mb-4">Get detailed help from our team</p>
|
||||
<a href="mailto:support@blackcanyontickets.com" class="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
Send Email
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||
<div class="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Live Chat</h3>
|
||||
<p class="text-blue-100 mb-4">Instant help when you need it</p>
|
||||
<button class="inline-flex items-center px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
Start Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-blue-200">
|
||||
<strong>Response Time:</strong> We typically respond within 24 hours during business days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- AI Chat Widget -->
|
||||
<ChatWidget client:load />
|
||||
|
||||
<script>
|
||||
// FAQ Toggle Functionality
|
||||
function toggleFaq(button) {
|
||||
const content = button.nextElementSibling;
|
||||
const arrow = button.querySelector('svg');
|
||||
|
||||
if (content.classList.contains('hidden')) {
|
||||
content.classList.remove('hidden');
|
||||
arrow.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
content.classList.add('hidden');
|
||||
arrow.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize FAQ functionality when DOM loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const faqButtons = document.querySelectorAll('[data-faq-toggle]');
|
||||
faqButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
toggleFaq(this);
|
||||
});
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.querySelector('#help-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
// TODO: Implement search functionality
|
||||
console.log('Searching for:', searchTerm);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
189
src/pages/terms.astro
Normal file
189
src/pages/terms.astro
Normal file
@@ -0,0 +1,189 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import SimpleHeader from '../components/SimpleHeader.astro';
|
||||
---
|
||||
|
||||
<Layout title="Terms of Service - Black Canyon Tickets">
|
||||
<SimpleHeader />
|
||||
<main class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative overflow-hidden py-16">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600/5 to-indigo-600/5"></div>
|
||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-full bg-blue-100 text-blue-700 text-sm font-medium mb-6">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Legal Agreement
|
||||
</div>
|
||||
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 mb-4">
|
||||
Terms of Service
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-2">
|
||||
Our commitment to transparent and fair service
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<!-- Table of Contents -->
|
||||
<div class="bg-gray-50 border-b border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Table of Contents</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
||||
<a href="#acceptance" class="text-blue-600 hover:text-blue-800 hover:underline">1. Acceptance of Terms</a>
|
||||
<a href="#service" class="text-blue-600 hover:text-blue-800 hover:underline">2. Description of Service</a>
|
||||
<a href="#accounts" class="text-blue-600 hover:text-blue-800 hover:underline">3. User Accounts</a>
|
||||
<a href="#organizer" class="text-blue-600 hover:text-blue-800 hover:underline">4. Event Organizer Responsibilities</a>
|
||||
<a href="#payment" class="text-blue-600 hover:text-blue-800 hover:underline">5. Payment Terms</a>
|
||||
<a href="#refunds" class="text-blue-600 hover:text-blue-800 hover:underline">6. Refunds and Cancellations</a>
|
||||
<a href="#prohibited" class="text-blue-600 hover:text-blue-800 hover:underline">7. Prohibited Uses</a>
|
||||
<a href="#liability" class="text-blue-600 hover:text-blue-800 hover:underline">8. Limitation of Liability</a>
|
||||
<a href="#indemnification" class="text-blue-600 hover:text-blue-800 hover:underline">9. Indemnification</a>
|
||||
<a href="#termination" class="text-blue-600 hover:text-blue-800 hover:underline">10. Termination</a>
|
||||
<a href="#changes" class="text-blue-600 hover:text-blue-800 hover:underline">11. Changes to Terms</a>
|
||||
<a href="#contact" class="text-blue-600 hover:text-blue-800 hover:underline">12. Contact Information</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="prose prose-blue prose-lg max-w-none">
|
||||
<h2 id="acceptance">1. Acceptance of Terms</h2>
|
||||
<p>
|
||||
By accessing and using the ticketing platform operated by San Juan Events dba Black Canyon Tickets ("we," "us," "our," or "the Service"), you accept and agree to be bound by the terms and provisions of this agreement. If you do not agree to abide by these terms, please do not use this service.
|
||||
</p>
|
||||
|
||||
<h2 id="service">2. Description of Service</h2>
|
||||
<p>
|
||||
San Juan Events dba Black Canyon Tickets operates a self-service ticketing platform designed for upscale venues. We provide event organizers with tools to create, manage, and sell tickets for their events, including:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Event creation and management</li>
|
||||
<li>Ticket sales and processing</li>
|
||||
<li>QR code generation and scanning</li>
|
||||
<li>Payment processing through Stripe</li>
|
||||
<li>Analytics and reporting</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="accounts">3. User Accounts</h2>
|
||||
<p>
|
||||
To use certain features of the Service, you must register for an account. You are responsible for maintaining the confidentiality of your account credentials and for all activities that occur under your account. You must immediately notify us of any unauthorized access to your account.
|
||||
</p>
|
||||
|
||||
<h2 id="organizer">4. Event Organizer Responsibilities</h2>
|
||||
<p>As an event organizer using our platform, you agree to:</p>
|
||||
<ul>
|
||||
<li>Provide accurate and complete information about your events</li>
|
||||
<li>Honor all ticket sales and provide the advertised services</li>
|
||||
<li>Comply with all applicable laws and regulations</li>
|
||||
<li>Respond to customer inquiries in a timely manner</li>
|
||||
<li>Maintain appropriate licenses and permits for your events</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="payment">5. Payment Terms</h2>
|
||||
<p>
|
||||
Payment processing is handled through Stripe Connect. By using our Service, you agree to Stripe's terms of service. We charge a platform fee on each ticket sold, which will be clearly disclosed during the setup process. Platform fees are automatically deducted from each transaction before funds are transferred to event organizers.
|
||||
</p>
|
||||
|
||||
<h2 id="refunds">6. Refunds and Cancellations</h2>
|
||||
<p>
|
||||
Refund policies are set by individual event organizers. San Juan Events dba Black Canyon Tickets does not guarantee refunds for any events. Disputes regarding refunds should be resolved directly between the ticket purchaser and the event organizer. We may facilitate communication but are not responsible for refund decisions.
|
||||
</p>
|
||||
|
||||
<h2 id="prohibited">7. Prohibited Uses</h2>
|
||||
<p>You may not use our Service for:</p>
|
||||
<ul>
|
||||
<li>Illegal activities or events</li>
|
||||
<li>Fraudulent or deceptive practices</li>
|
||||
<li>Harassment or harm to others</li>
|
||||
<li>Violating intellectual property rights</li>
|
||||
<li>Spam or unsolicited communications</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="liability">8. Limitation of Liability</h2>
|
||||
<p>
|
||||
San Juan Events dba Black Canyon Tickets shall not be liable for any indirect, incidental, special, or consequential damages arising from your use of the Service. Our total liability is limited to the platform fees paid for the Service in the twelve months preceding the claim.
|
||||
</p>
|
||||
|
||||
<h2 id="indemnification">9. Indemnification</h2>
|
||||
<p>
|
||||
You agree to indemnify and hold harmless San Juan Events dba Black Canyon Tickets, its officers, directors, employees, and agents from any claims, damages, losses, or expenses (including legal fees) arising from your use of the Service, violation of these terms, or infringement of any rights of third parties.
|
||||
</p>
|
||||
|
||||
<h2 id="termination">10. Termination</h2>
|
||||
<p>
|
||||
We may terminate or suspend your account at any time for violation of these terms, with or without notice. Upon termination, your right to use the Service will cease immediately. All provisions that by their nature should survive termination shall survive, including liability limitations and indemnification provisions.
|
||||
</p>
|
||||
|
||||
<h2 id="changes">11. Changes to Terms</h2>
|
||||
<p>
|
||||
We reserve the right to modify these terms at any time. Material changes will be posted on this page with an updated date and, where required by law, we will provide additional notice. Continued use of the Service after changes constitutes acceptance of the new terms.
|
||||
</p>
|
||||
|
||||
<h2 id="contact">12. Contact Information</h2>
|
||||
<p>
|
||||
If you have questions about these Terms of Service, please contact us at:
|
||||
</p>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||
</svg>
|
||||
<strong class="text-blue-900">Email:</strong>
|
||||
</div>
|
||||
<p class="text-blue-800 ml-7">support@blackcanyontickets.com</p>
|
||||
|
||||
<div class="flex items-center mt-3 mb-2">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<strong class="text-blue-900">Business Entity:</strong>
|
||||
</div>
|
||||
<p class="text-blue-800 ml-7">San Juan Events dba Black Canyon Tickets<br>Montrose, Colorado</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Footer -->
|
||||
<div class="bg-gray-50 border-t border-gray-200 px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0">
|
||||
<div class="text-sm text-gray-600">
|
||||
<p>These terms are effective as of the last updated date above.</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/privacy" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="/support" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-2 0c0 .993-.241 1.929-.668 2.754l-1.524-1.525a3.997 3.997 0 00.078-2.183l1.562-1.562C17.756 8.249 18 9.1 18 10z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Get Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="mt-8 text-center">
|
||||
<a href="/" class="inline-flex items-center text-blue-600 hover:text-blue-800 font-medium transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Back to Application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
594
src/pages/venues.astro
Normal file
594
src/pages/venues.astro
Normal file
@@ -0,0 +1,594 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Venue Management - Black Canyon Tickets">
|
||||
<style>
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
</style>
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900">
|
||||
<!-- Animated background elements -->
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-purple-600/20 to-pink-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-blue-600/20 to-cyan-600/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-gradient-to-br from-indigo-600/10 to-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<!-- Elegant Navigation -->
|
||||
<nav class="bg-white/90 backdrop-blur-lg shadow-xl border-b border-slate-200/50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20">
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/dashboard" class="text-2xl font-light text-slate-800 tracking-wide">
|
||||
Black Canyon <span class="font-semibold">Tickets</span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center space-x-6">
|
||||
<a href="/dashboard" class="text-slate-600 hover:text-slate-900 font-medium transition-colors duration-200">Dashboard</a>
|
||||
<a href="/venues" class="text-slate-900 border-b-2 border-slate-800 pb-1 font-medium">Venues</a>
|
||||
<a href="/events/new" class="text-slate-600 hover:text-slate-900 font-medium transition-colors duration-200">Create Event</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span id="user-name" class="text-sm text-slate-700 font-medium"></span>
|
||||
<button
|
||||
id="logout-btn"
|
||||
class="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header Section -->
|
||||
<div class="mb-12">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-light text-slate-900 mb-4 tracking-wide">
|
||||
Venue Management
|
||||
</h1>
|
||||
<p class="text-xl text-slate-600 max-w-2xl mx-auto leading-relaxed">
|
||||
Create and manage elegant venues for your distinguished events
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
id="create-venue-btn"
|
||||
class="bg-gradient-to-r from-slate-800 to-slate-900 hover:from-slate-900 hover:to-black text-white px-8 py-4 rounded-2xl font-medium text-lg shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<svg class="w-5 h-5 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Create New Venue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Venues Grid -->
|
||||
<div id="venues-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<!-- Venues will be loaded here -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="empty-state" class="text-center py-16 hidden">
|
||||
<div class="mx-auto h-24 w-24 text-slate-400 mb-4">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-light text-slate-900 mb-2">No venues created yet</h3>
|
||||
<p class="text-slate-600 mb-6">Create your first venue to start hosting distinguished events</p>
|
||||
<button
|
||||
onclick="showCreateVenueModal()"
|
||||
class="bg-slate-800 hover:bg-slate-900 text-white px-6 py-3 rounded-xl font-medium transition-colors duration-200"
|
||||
>
|
||||
Create Your First Venue
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Create/Edit Venue Modal -->
|
||||
<div id="venue-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<form id="venue-form" class="p-8">
|
||||
<div class="mb-8">
|
||||
<h2 id="modal-title" class="text-3xl font-light text-slate-900 mb-2">Create New Venue</h2>
|
||||
<p class="text-slate-600">Define the perfect setting for your distinguished events</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="venue-name" class="block text-sm font-semibold text-slate-700 mb-2">Venue Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="venue-name"
|
||||
name="name"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Grand Ballroom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="venue-type" class="block text-sm font-semibold text-slate-700 mb-2">Venue Type</label>
|
||||
<select
|
||||
id="venue-type"
|
||||
name="type"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all duration-200"
|
||||
>
|
||||
<option value="hotel">Luxury Hotel</option>
|
||||
<option value="resort">Resort</option>
|
||||
<option value="private_club">Private Club</option>
|
||||
<option value="restaurant">Fine Dining</option>
|
||||
<option value="gallery">Gallery</option>
|
||||
<option value="theater">Theater</option>
|
||||
<option value="outdoor">Outdoor Venue</option>
|
||||
<option value="private_residence">Private Residence</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div>
|
||||
<label for="venue-address" class="block text-sm font-semibold text-slate-700 mb-2">Address</label>
|
||||
<textarea
|
||||
id="venue-address"
|
||||
name="address"
|
||||
rows="3"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="123 Main Street, City, State 12345"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Capacity & Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label for="venue-capacity" class="block text-sm font-semibold text-slate-700 mb-2">Max Capacity</label>
|
||||
<input
|
||||
type="number"
|
||||
id="venue-capacity"
|
||||
name="capacity"
|
||||
min="1"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="150"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="venue-phone" class="block text-sm font-semibold text-slate-700 mb-2">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="venue-phone"
|
||||
name="phone"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="venue-website" class="block text-sm font-semibold text-slate-700 mb-2">Website</label>
|
||||
<input
|
||||
type="url"
|
||||
id="venue-website"
|
||||
name="website"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="venue-description" class="block text-sm font-semibold text-slate-700 mb-2">Description</label>
|
||||
<textarea
|
||||
id="venue-description"
|
||||
name="description"
|
||||
rows="4"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="An elegant venue perfect for sophisticated events..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Amenities -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-3">Amenities</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<label class="flex items-center space-x-2 text-sm">
|
||||
<input type="checkbox" name="amenities" value="parking" class="rounded border-slate-300">
|
||||
<span>Valet Parking</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 text-sm">
|
||||
<input type="checkbox" name="amenities" value="catering" class="rounded border-slate-300">
|
||||
<span>In-house Catering</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 text-sm">
|
||||
<input type="checkbox" name="amenities" value="av" class="rounded border-slate-300">
|
||||
<span>A/V Equipment</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 text-sm">
|
||||
<input type="checkbox" name="amenities" value="wifi" class="rounded border-slate-300">
|
||||
<span>High-Speed WiFi</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 text-sm">
|
||||
<input type="checkbox" name="amenities" value="climate" class="rounded border-slate-300">
|
||||
<span>Climate Control</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 text-sm">
|
||||
<input type="checkbox" name="amenities" value="accessible" class="rounded border-slate-300">
|
||||
<span>Accessibility</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<div class="flex justify-end space-x-4 mt-8 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeVenueModal()"
|
||||
class="px-6 py-3 border border-slate-300 text-slate-700 rounded-xl hover:bg-slate-50 font-medium transition-colors duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-8 py-3 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl hover:from-slate-900 hover:to-black font-medium transition-all duration-200"
|
||||
>
|
||||
<span id="submit-text">Create Venue</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seating Maps Modal -->
|
||||
<div id="seating-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white rounded-3xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-8">
|
||||
<h2 class="text-3xl font-light text-slate-900 mb-6">Seating Maps</h2>
|
||||
<div id="seating-maps-content">
|
||||
<!-- Seating maps will be loaded here -->
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
onclick="closeSeatingModal()"
|
||||
class="px-6 py-3 border border-slate-300 text-slate-700 rounded-xl hover:bg-slate-50 font-medium transition-colors duration-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const venuesContainer = document.getElementById('venues-container');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const venueModal = document.getElementById('venue-modal');
|
||||
const venueForm = document.getElementById('venue-form');
|
||||
const createVenueBtn = document.getElementById('create-venue-btn');
|
||||
const userNameSpan = document.getElementById('user-name');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
let currentVenueId = null;
|
||||
let currentOrganizationId = null;
|
||||
|
||||
// Check authentication
|
||||
async function checkAuth() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get user details
|
||||
const { data: user } = await supabase
|
||||
.from('users')
|
||||
.select('name, email, organization_id')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (user) {
|
||||
userNameSpan.textContent = user.name || user.email;
|
||||
currentOrganizationId = user.organization_id;
|
||||
|
||||
// If user doesn't have an organization, create one
|
||||
if (!currentOrganizationId) {
|
||||
try {
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
.insert([
|
||||
{ name: `${user.name || user.email}'s Organization` }
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!orgError) {
|
||||
currentOrganizationId = org.id;
|
||||
|
||||
// Update user with organization_id
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ organization_id: currentOrganizationId })
|
||||
.eq('id', session.user.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating organization:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// Load venues
|
||||
async function loadVenues() {
|
||||
try {
|
||||
const { data: venues, error } = await supabase
|
||||
.from('venues')
|
||||
.select(`
|
||||
*,
|
||||
seating_maps(count)
|
||||
`)
|
||||
.eq('organization_id', currentOrganizationId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (venues.length === 0) {
|
||||
venuesContainer.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
venuesContainer.classList.remove('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
venuesContainer.innerHTML = venues.map(venue => `
|
||||
<div class="bg-white rounded-2xl shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 ease-out overflow-hidden border border-slate-200/50">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-slate-900 mb-1">${venue.name}</h3>
|
||||
<p class="text-sm text-slate-600 capitalize">${venue.type?.replace('_', ' ') || 'Venue'}</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium">
|
||||
${venue.capacity || 'No limit'} ${venue.capacity ? 'guests' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-slate-600 text-sm mb-4 line-clamp-2">
|
||||
${venue.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
<div class="text-xs text-slate-500 mb-4">
|
||||
<p>${venue.address || 'No address specified'}</p>
|
||||
${venue.phone ? `<p class="mt-1">${venue.phone}</p>` : ''}
|
||||
</div>
|
||||
|
||||
${venue.amenities ? `
|
||||
<div class="flex flex-wrap gap-1 mb-4">
|
||||
${JSON.parse(venue.amenities).slice(0, 3).map(amenity => `
|
||||
<span class="px-2 py-1 bg-slate-50 text-slate-600 rounded text-xs">${amenity}</span>
|
||||
`).join('')}
|
||||
${JSON.parse(venue.amenities).length > 3 ? `<span class="text-xs text-slate-500">+${JSON.parse(venue.amenities).length - 3} more</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-slate-100">
|
||||
<div class="text-xs text-slate-500">
|
||||
${venue.seating_maps?.[0]?.count || 0} seating map${venue.seating_maps?.[0]?.count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
onclick="viewSeatingMaps('${venue.id}')"
|
||||
class="text-slate-600 hover:text-slate-900 text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
Seating
|
||||
</button>
|
||||
<button
|
||||
onclick="editVenue('${venue.id}')"
|
||||
class="text-slate-600 hover:text-slate-900 text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onclick="deleteVenue('${venue.id}')"
|
||||
class="text-red-600 hover:text-red-800 text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading venues:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Show create venue modal
|
||||
function showCreateVenueModal() {
|
||||
currentVenueId = null;
|
||||
document.getElementById('modal-title').textContent = 'Create New Venue';
|
||||
document.getElementById('submit-text').textContent = 'Create Venue';
|
||||
venueForm.reset();
|
||||
venueModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Edit venue
|
||||
async function editVenue(venueId) {
|
||||
try {
|
||||
currentVenueId = venueId;
|
||||
|
||||
const { data: venue, error } = await supabase
|
||||
.from('venues')
|
||||
.select('*')
|
||||
.eq('id', venueId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Populate form
|
||||
document.getElementById('venue-name').value = venue.name || '';
|
||||
document.getElementById('venue-type').value = venue.type || '';
|
||||
document.getElementById('venue-address').value = venue.address || '';
|
||||
document.getElementById('venue-capacity').value = venue.capacity || '';
|
||||
document.getElementById('venue-phone').value = venue.phone || '';
|
||||
document.getElementById('venue-website').value = venue.website || '';
|
||||
document.getElementById('venue-description').value = venue.description || '';
|
||||
|
||||
// Set amenities
|
||||
if (venue.amenities) {
|
||||
const amenities = JSON.parse(venue.amenities);
|
||||
document.querySelectorAll('input[name="amenities"]').forEach(checkbox => {
|
||||
checkbox.checked = amenities.includes(checkbox.value);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('modal-title').textContent = 'Edit Venue';
|
||||
document.getElementById('submit-text').textContent = 'Update Venue';
|
||||
venueModal.classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading venue:', error);
|
||||
alert('Error loading venue data');
|
||||
}
|
||||
}
|
||||
|
||||
// Close venue modal
|
||||
function closeVenueModal() {
|
||||
venueModal.classList.add('hidden');
|
||||
currentVenueId = null;
|
||||
}
|
||||
|
||||
// Save venue
|
||||
venueForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const formData = new FormData(venueForm);
|
||||
const amenities = Array.from(document.querySelectorAll('input[name="amenities"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
|
||||
const venueData = {
|
||||
name: formData.get('name'),
|
||||
type: formData.get('type'),
|
||||
address: formData.get('address'),
|
||||
capacity: formData.get('capacity') ? parseInt(formData.get('capacity')) : null,
|
||||
phone: formData.get('phone') || null,
|
||||
website: formData.get('website') || null,
|
||||
description: formData.get('description') || null,
|
||||
amenities: JSON.stringify(amenities),
|
||||
organization_id: currentOrganizationId
|
||||
};
|
||||
|
||||
let error;
|
||||
if (currentVenueId) {
|
||||
({ error } = await supabase
|
||||
.from('venues')
|
||||
.update(venueData)
|
||||
.eq('id', currentVenueId));
|
||||
} else {
|
||||
({ error } = await supabase
|
||||
.from('venues')
|
||||
.insert([venueData]));
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
closeVenueModal();
|
||||
await loadVenues();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving venue:', error);
|
||||
alert('Error saving venue: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete venue
|
||||
async function deleteVenue(venueId) {
|
||||
if (!confirm('Are you sure you want to delete this venue? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('venues')
|
||||
.delete()
|
||||
.eq('id', venueId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
await loadVenues();
|
||||
} catch (error) {
|
||||
console.error('Error deleting venue:', error);
|
||||
alert('Error deleting venue: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// View seating maps
|
||||
async function viewSeatingMaps(venueId) {
|
||||
// TODO: Implement seating maps view
|
||||
alert('Seating maps management coming soon!');
|
||||
}
|
||||
|
||||
// Close seating modal
|
||||
function closeSeatingModal() {
|
||||
document.getElementById('seating-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
createVenueBtn.addEventListener('click', showCreateVenueModal);
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
await supabase.auth.signOut();
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
// Make functions globally available
|
||||
window.showCreateVenueModal = showCreateVenueModal;
|
||||
window.editVenue = editVenue;
|
||||
window.deleteVenue = deleteVenue;
|
||||
window.viewSeatingMaps = viewSeatingMaps;
|
||||
window.closeVenueModal = closeVenueModal;
|
||||
window.closeSeatingModal = closeSeatingModal;
|
||||
|
||||
// Initialize
|
||||
checkAuth().then(session => {
|
||||
if (session && currentOrganizationId) {
|
||||
loadVenues();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
167
src/styles/glassmorphism.css
Normal file
167
src/styles/glassmorphism.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* Glassmorphism Theme Utility Classes */
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.glass-card-lg {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgb(96, 165, 250);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gradient-button {
|
||||
background: linear-gradient(to right, rgb(37, 99, 235), rgb(147, 51, 234));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-button:hover {
|
||||
background: linear-gradient(to right, rgb(29, 78, 216), rgb(126, 34, 206));
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 8px 32px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slideIn {
|
||||
animation: slideIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Grid Pattern */
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* Text Utilities */
|
||||
.text-glass-primary {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-glass-secondary {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.text-glass-tertiary {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.text-glass-accent {
|
||||
color: rgb(96, 165, 250);
|
||||
}
|
||||
|
||||
/* Status Colors */
|
||||
.text-success {
|
||||
color: rgb(52, 211, 153);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: rgb(251, 191, 36);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
|
||||
.bg-success {
|
||||
background-color: rgba(52, 211, 153, 0.2);
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.bg-error {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
/* Responsive Glassmorphism */
|
||||
@media (max-width: 768px) {
|
||||
.glass-card {
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.glass-card-lg {
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
248
src/styles/global.css
Normal file
248
src/styles/global.css
Normal file
@@ -0,0 +1,248 @@
|
||||
@import "tailwindcss";
|
||||
@import "./glassmorphism.css";
|
||||
|
||||
/* Accessibility Styles */
|
||||
|
||||
/* Screen reader only content */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Screen reader only content that becomes visible on focus */
|
||||
.sr-only-focusable:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: inherit;
|
||||
margin: inherit;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Skip links */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
text-decoration: none;
|
||||
z-index: 9999;
|
||||
border-radius: 4px;
|
||||
transition: top 0.3s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
/* Enhanced focus styles */
|
||||
.focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* High contrast mode styles */
|
||||
.high-contrast {
|
||||
--tw-bg-opacity: 1;
|
||||
--tw-text-opacity: 1;
|
||||
}
|
||||
|
||||
.high-contrast * {
|
||||
border-color: currentColor !important;
|
||||
}
|
||||
|
||||
.high-contrast button,
|
||||
.high-contrast input,
|
||||
.high-contrast select,
|
||||
.high-contrast textarea {
|
||||
border: 2px solid currentColor !important;
|
||||
}
|
||||
|
||||
.high-contrast a {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
/* Reduced motion styles */
|
||||
.reduce-motion *,
|
||||
.reduce-motion *::before,
|
||||
.reduce-motion *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* Better focus management for modals */
|
||||
.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Improved button contrast */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Enhanced error state styles */
|
||||
input[aria-invalid="true"],
|
||||
select[aria-invalid="true"],
|
||||
textarea[aria-invalid="true"] {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 1px #ef4444;
|
||||
}
|
||||
|
||||
/* Better spacing for form elements */
|
||||
label + input,
|
||||
label + select,
|
||||
label + textarea {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Improved link styling */
|
||||
a:not(.btn):not(.button) {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:not(.btn):not(.button):hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Better table accessibility */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
td {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-indicator::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Ensure sufficient color contrast */
|
||||
.text-slate-500 {
|
||||
color: #64748b; /* Improved contrast */
|
||||
}
|
||||
|
||||
.text-slate-600 {
|
||||
color: #475569; /* Improved contrast */
|
||||
}
|
||||
|
||||
/* Better button states */
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Keyboard-only focus for better UX */
|
||||
@media (hover: hover) {
|
||||
.focus\:outline-none:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom checkbox and radio styles for better accessibility */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Better error message styling */
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.error-message::before {
|
||||
content: '⚠';
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Success message styling */
|
||||
.success-message {
|
||||
color: #10b981;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.success-message::before {
|
||||
content: '✓';
|
||||
font-weight: bold;
|
||||
}
|
||||
Reference in New Issue
Block a user