272 lines
8.6 KiB
JavaScript
272 lines
8.6 KiB
JavaScript
import React, { useEffect, useState } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import { Helmet } from 'react-helmet-async'
|
|
import { getEvent } from '../utils/api'
|
|
import { googleCalendarUrl, downloadICS } from '../utils/calendar'
|
|
import { track, events as ga } from '../utils/analytics'
|
|
|
|
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '')
|
|
|
|
function parseTime(timeStr) {
|
|
if (!timeStr) return '10:00'
|
|
const match = `${timeStr}`.match(/(\d+):(\d+)\s*(AM|PM)/i)
|
|
if (!match) return '10:00'
|
|
|
|
let hours = parseInt(match[1], 10)
|
|
const minutes = match[2]
|
|
const period = match[3].toUpperCase()
|
|
|
|
if (period === 'PM' && hours !== 12) hours += 12
|
|
if (period === 'AM' && hours === 12) hours = 0
|
|
|
|
return `${hours.toString().padStart(2, '0')}:${minutes}`
|
|
}
|
|
|
|
function resolveImageUrl(value, fallback) {
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return fallback
|
|
if (/^(?:https?:)?\/\//i.test(trimmed) || trimmed.startsWith('data:')) {
|
|
return trimmed
|
|
}
|
|
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
if (apiBaseUrl) {
|
|
return `${apiBaseUrl}${path}`
|
|
}
|
|
return path
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
function getFallbackImage(event) {
|
|
const title = `${event?.title || ''}`.toLowerCase()
|
|
if (title.includes('vespers')) return '/assets/youth_vespers.png'
|
|
if (title.includes('food') || title.includes('community')) return '/assets/family_entry.png'
|
|
if (title.includes('lunch') || title.includes('dinner') || title.includes('potluck')) return '/assets/potluck.png'
|
|
return '/assets/potluck.png'
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
const date = new Date(dateStr + 'T00:00:00')
|
|
if (Number.isNaN(date.getTime())) return dateStr
|
|
return date.toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
timeZone: 'America/Chicago',
|
|
})
|
|
}
|
|
|
|
function formatTime(timeStr) {
|
|
if (!timeStr) return 'TBA'
|
|
return timeStr
|
|
}
|
|
|
|
export default function EventDetail() {
|
|
const { slug } = useParams()
|
|
const [event, setEvent] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(null)
|
|
|
|
useEffect(() => {
|
|
let ignore = false
|
|
async function load() {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const data = await getEvent(slug)
|
|
if (!ignore) {
|
|
setEvent(data)
|
|
}
|
|
} catch (err) {
|
|
if (!ignore) {
|
|
setError(err)
|
|
setEvent(null)
|
|
}
|
|
} finally {
|
|
if (!ignore) {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
}
|
|
load()
|
|
return () => {
|
|
ignore = true
|
|
}
|
|
}, [slug])
|
|
|
|
useEffect(() => {
|
|
if (event) {
|
|
track(ga.EVENT_DETAILS_VIEW, { slug: event.slug })
|
|
}
|
|
}, [event])
|
|
|
|
if (loading) {
|
|
return (
|
|
<section className="section">
|
|
<div className="container">
|
|
<p className="text-muted">Loading event...</p>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<section className="section">
|
|
<div className="container">
|
|
<p className="text-red-600">Unable to load this event. Please try again later.</p>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
if (!event) {
|
|
return (
|
|
<section className="section">
|
|
<div className="container">
|
|
<p>Event not found.</p>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const time24 = parseTime(event.time || '10:00 AM')
|
|
const start = new Date(`${event.date}T${time24}:00`)
|
|
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
|
const fallbackImage = getFallbackImage(event)
|
|
const coverImage = resolveImageUrl(event.image, fallbackImage)
|
|
const displayDate = formatDate(event.date)
|
|
const displayTime = formatTime(event.time)
|
|
const eventUrl = typeof window !== 'undefined' ? window.location.href : ''
|
|
|
|
const jsonLd = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Event',
|
|
name: event.title,
|
|
startDate: start.toISOString(),
|
|
endDate: end.toISOString(),
|
|
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
|
location: {
|
|
'@type': 'Place',
|
|
name: 'Annaville SDA Church',
|
|
address: '2710 Violet Rd, Corpus Christi, TX 78410',
|
|
},
|
|
description: event.description,
|
|
}
|
|
|
|
return (
|
|
<section className="section bg-sand/30">
|
|
<Helmet>
|
|
<title>{event.title} | Events</title>
|
|
<script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
|
|
</Helmet>
|
|
<div className="container space-y-8">
|
|
<Link to="/events" className="inline-flex items-center text-sm text-primary hover:text-primaryHover">
|
|
← Back to all events
|
|
</Link>
|
|
<div className="grid lg:grid-cols-[2fr,1fr] gap-10 items-start">
|
|
<article className="bg-white rounded-2xl shadow-level1 overflow-hidden">
|
|
<div className="relative h-80 md:h-96 bg-sand">
|
|
<img
|
|
src={coverImage}
|
|
alt={`${event.title} hero image`}
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
<div className="p-10 space-y-6">
|
|
<header className="space-y-3">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted">
|
|
{event.category || 'Featured Event'}
|
|
</p>
|
|
<h1 className="font-heading text-display30 text-ink">{event.title}</h1>
|
|
<p className="text-muted text-sm">
|
|
{displayDate} • {displayTime} CT{event.location ? ` • ${event.location}` : ''}
|
|
</p>
|
|
</header>
|
|
<div className="space-y-4 text-body leading-relaxed">
|
|
{`${event.description || ''}`.split(/\n{2,}/).map((paragraph, idx) => (
|
|
<p key={idx}>{paragraph}</p>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap gap-3 pt-4">
|
|
<a
|
|
className="btn"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
href={googleCalendarUrl({
|
|
title: event.title,
|
|
details: event.description,
|
|
location: event.location,
|
|
start,
|
|
end,
|
|
})}
|
|
>
|
|
Add to Google Calendar
|
|
</a>
|
|
<button
|
|
className="btn-outline"
|
|
onClick={() =>
|
|
downloadICS({
|
|
title: event.title,
|
|
details: event.description,
|
|
location: event.location,
|
|
start,
|
|
end,
|
|
filename: `${event.slug}.ics`,
|
|
})
|
|
}
|
|
>
|
|
Download iCal File
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
<aside className="bg-white rounded-2xl shadow-level1 p-8 space-y-6">
|
|
<div className="space-y-3">
|
|
<h2 className="font-heading text-h4 text-ink">Event Details</h2>
|
|
<dl className="space-y-4 text-sm">
|
|
<div>
|
|
<dt className="font-semibold text-ink">When</dt>
|
|
<dd className="text-muted">{displayDate} · {displayTime} CT</dd>
|
|
</div>
|
|
{event.location && (
|
|
<div>
|
|
<dt className="font-semibold text-ink">Where</dt>
|
|
<dd className="text-muted">{event.location}</dd>
|
|
</div>
|
|
)}
|
|
{event.category && (
|
|
<div>
|
|
<dt className="font-semibold text-ink">Category</dt>
|
|
<dd className="text-muted">{event.category}</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<h2 className="font-heading text-h4 text-ink">Share</h2>
|
|
<div className="flex flex-wrap gap-3 text-sm">
|
|
<a
|
|
className="btn-outline"
|
|
href={`mailto:?subject=${encodeURIComponent(event.title)}&body=${encodeURIComponent(`Join me at ${event.title} on ${displayDate}. More info: ${eventUrl}`)}`}
|
|
>
|
|
Email Invite
|
|
</a>
|
|
<button
|
|
className="btn-outline"
|
|
onClick={() => navigator?.clipboard?.writeText(eventUrl)}
|
|
>
|
|
Copy Link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|