Admin website
This commit is contained in:
@@ -1,61 +1,268 @@
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import events from '../data/events.json'
|
||||
import { getEvent } from '../utils/api'
|
||||
import { googleCalendarUrl, downloadICS } from '../utils/calendar'
|
||||
import { track, events as ga } from '../utils/analytics'
|
||||
|
||||
export default function EventDetail(){
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4001').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)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(timeStr) {
|
||||
if (!timeStr) return 'TBA'
|
||||
return timeStr
|
||||
}
|
||||
|
||||
export default function EventDetail() {
|
||||
const { slug } = useParams()
|
||||
const e = events.find(x => x.slug === slug)
|
||||
const [event, setEvent] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(()=>{ if (e) track(ga.EVENT_DETAILS_VIEW,{slug:e.slug}) },[slug])
|
||||
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])
|
||||
|
||||
if(!e) return <section className="section"><div className="container"><p>Event not found.</p></div></section>
|
||||
useEffect(() => {
|
||||
if (event) {
|
||||
track(ga.EVENT_DETAILS_VIEW, { slug: event.slug })
|
||||
}
|
||||
}, [event])
|
||||
|
||||
// Parse time from 12-hour format to 24-hour format
|
||||
const 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])
|
||||
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}`
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<p className="text-muted">Loading event...</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const time24 = parseTime(e.time || '10:00 AM')
|
||||
const start = new Date(`${e.date}T${time24}:00`)
|
||||
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":e.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":e.description
|
||||
'@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">
|
||||
<section className="section bg-sand/30">
|
||||
<Helmet>
|
||||
<title>{e.title} | Events</title>
|
||||
<title>{event.title} | Events</title>
|
||||
<script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
|
||||
</Helmet>
|
||||
<div className="container">
|
||||
<h1 className="font-heading text-display30 mb-2">{e.title}</h1>
|
||||
<div className="text-muted mb-4">{e.date} • {e.time} • {e.location}</div>
|
||||
<p className="text-[16px]">{e.description}</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<a className="btn-ghost underline" target="_blank" rel="noreferrer"
|
||||
href={googleCalendarUrl({title:e.title, details:e.description, location:e.location, start, end})}>Add to Google Calendar</a>
|
||||
<button className="btn-ghost underline" onClick={()=>downloadICS({title:e.title, details:e.description, location:e.location, start, end, filename:`${e.slug}.ics`})}>Add to Apple Calendar</button>
|
||||
<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}{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}</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>
|
||||
|
||||
Reference in New Issue
Block a user