Files
blackcanyontickets/src/components/AgeVerification.astro
2025-07-08 12:31:31 -06:00

284 lines
9.5 KiB
Plaintext

---
// 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>