Admin website

This commit is contained in:
Timo Knuth 2025-09-25 16:54:50 +02:00
parent 8dbf6caa6f
commit 24f15b6e28
36 changed files with 3451 additions and 561 deletions

View File

@ -1,5 +1,5 @@

# Annaville SDA Church Modern, Accessible Website # Annaville SDA Church — Modern, Accessible Website
React + Vite + Tailwind. WCAG 2.2 AA, Core Web Vitals-minded. Text-first hero keeps H1 as LCP. React + Vite + Tailwind. WCAG 2.2 AA, Core Web Vitals-minded. Text-first hero keeps H1 as LCP.
@ -22,9 +22,16 @@ docker compose up --build
``` ```
### Notes ### Notes
- Mobile sticky bar (📞 Call • 🧭 Directions • 📝 Plan a Visit) is persistent. - Mobile sticky bar (📞 Call • 🧭 Directions • 📝 Plan a Visit) is persistent.
- GA events in `src/utils/analytics.js`: - GA events in `src/utils/analytics.js`:
`cta_click`, `click_to_call`, `open_directions`, `visit_form_start`, `visit_form_submit`, `newsletter_signup`, `event_details_view`, `sermon_play`. `cta_click`, `click_to_call`, `open_directions`, `visit_form_start`, `visit_form_submit`, `newsletter_signup`, `event_details_view`, `sermon_play`.
- Local WOFF2 fonts preloaded; replace placeholders with real files before production. - Local WOFF2 fonts preloaded; replace placeholders with real files before production.
- JSON-LD is injected on Home (Organization, Website, FAQ) and Event/Sermon detail pages. - JSON-LD is injected on Home (Organization, Website, FAQ) and Event/Sermon detail pages.
# annaville-sda-site # annaville-sda-site
## Events Admin
- \
pm run dev\ starts both the Vite client (5173) and the Express API (4001).
- Set the \ADMIN_TOKEN\ environment variable before running the server (defaults to \dev-secret\).
- Admin dashboard: http://localhost:5173/admin (login via token, manage events, add/edit/delete).
- API endpoints live under \/api/events\ and require the \X-Admin-Token\ header for write operations.

1682
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,21 +4,28 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently \"npm:dev:client\" \"npm:dev:server\"",
"dev:client": "vite",
"dev:server": "node server/index.js",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 5173" "preview": "vite preview --port 5173",
"start:server": "node server/index.js"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"multer": "^2.0.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.26.0", "react-helmet-async": "^2.0.5",
"react-helmet-async": "^2.0.5" "react-router-dom": "^6.26.0"
}, },
"devDependencies": { "devDependencies": {
"vite": "^5.4.2",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^3.4.10", "autoprefixer": "^10.4.18",
"concurrently": "^9.2.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"autoprefixer": "^10.4.18" "tailwindcss": "^3.4.10",
"vite": "^5.4.2"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 6.7 MiB

28
server/data/events.json Normal file
View File

@ -0,0 +1,28 @@
[
{
"id": "57fb4ac3-3896-44a0-a61b-42f3bed558d5",
"slug": "test",
"createdAt": "2025-09-24T15:57:31.513Z",
"updatedAt": "2025-09-24T15:57:31.513Z",
"title": "test",
"description": "Test",
"date": "2025-10-10",
"time": "10:00 AM",
"location": "Fellowship Hall",
"category": "Youth",
"image": "/uploads/michael-peskov-magier-taschendieb-453624-jpeg-1758729451448.jpeg"
},
{
"id": "6e1b9995-e153-478a-a2d6-713e48d05891",
"slug": "eg",
"createdAt": "2025-09-24T18:18:07.806Z",
"updatedAt": "2025-09-24T18:18:07.806Z",
"title": "eg",
"description": "HSAHA",
"date": "2025-10-12",
"time": "12:30 PM",
"location": "Erkrath",
"category": "hi",
"image": "/uploads/atos-logo-blau-jpg-1758737887080.jpg"
}
]

234
server/index.js Normal file
View File

@ -0,0 +1,234 @@
import express from 'express'
import cors from 'cors'
import { fileURLToPath } from 'url'
import { promises as fs } from 'fs'
import path from 'path'
import crypto from 'crypto'
import multer from 'multer'
const app = express()
const port = process.env.PORT || 4001
const adminToken = process.env.ADMIN_TOKEN || 'timo'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dataDir = path.join(__dirname, 'data')
const dataPath = path.join(dataDir, 'events.json')
const uploadsDir = path.join(__dirname, 'uploads')
await fs.mkdir(uploadsDir, { recursive: true })
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname || '') || '.png'
const base = (file.originalname || 'upload')
.replace(/[^a-z0-9]+/gi, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase() || 'upload'
const unique = `${base}-${Date.now()}${ext}`
cb(null, unique)
}
})
const allowedMimeTypes = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/svg+xml'
])
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (allowedMimeTypes.has(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('Only image uploads are allowed'))
}
}
})
async function ensureDataFile() {
try {
await fs.access(dataPath)
} catch {
await fs.mkdir(dataDir, { recursive: true })
await fs.writeFile(dataPath, '[]', 'utf-8')
}
}
function slugify(text) {
return text
.toString()
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
async function readEvents() {
await ensureDataFile()
const raw = await fs.readFile(dataPath, 'utf-8')
try {
return JSON.parse(raw)
} catch (error) {
console.error('Failed to parse events file, resetting to []', error)
await fs.writeFile(dataPath, '[]', 'utf-8')
return []
}
}
async function writeEvents(events) {
await fs.writeFile(dataPath, JSON.stringify(events, null, 2), 'utf-8')
}
function requireAuth(req, res, next) {
const token = req.header('x-admin-token')
if (!token || token !== adminToken) {
return res.status(401).json({ error: 'Unauthorized' })
}
return next()
}
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
function buildEventPayload(input, base = {}) {
const allowed = ['title', 'description', 'date', 'time', 'location', 'category', 'image']
const payload = { ...base }
for (const key of allowed) {
if (key in input && input[key] !== undefined) {
payload[key] = typeof input[key] === 'string' ? input[key].trim() : input[key]
}
}
return payload
}
app.use(cors())
app.use(express.json({ limit: '1mb' }))
app.use('/uploads', express.static(uploadsDir))
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' })
})
app.get('/api/events', asyncHandler(async (req, res) => {
const events = await readEvents()
const sorted = events.slice().sort((a, b) => new Date(a.date) - new Date(b.date))
res.json(sorted)
}))
app.get('/api/events/:slug', asyncHandler(async (req, res) => {
const events = await readEvents()
const event = events.find(e => e.slug === req.params.slug)
if (!event) {
return res.status(404).json({ error: 'Event not found' })
}
res.json(event)
}))
app.post('/api/events', requireAuth, asyncHandler(async (req, res) => {
const data = buildEventPayload(req.body)
if (!data.title || !data.date) {
return res.status(400).json({ error: 'title and date are required' })
}
const events = await readEvents()
const baseSlug = slugify(req.body.slug || data.title || '') || `event-${Date.now()}`
let uniqueSlug = baseSlug
let suffix = 1
while (events.some(event => event.slug === uniqueSlug)) {
uniqueSlug = `${baseSlug}-${suffix++}`
}
const now = new Date().toISOString()
const newEvent = {
id: crypto.randomUUID(),
slug: uniqueSlug,
createdAt: now,
updatedAt: now,
...data,
}
events.push(newEvent)
await writeEvents(events)
res.status(201).json(newEvent)
}))
app.patch('/api/events/:slug', requireAuth, asyncHandler(async (req, res) => {
const events = await readEvents()
const index = events.findIndex(event => event.slug === req.params.slug)
if (index === -1) {
return res.status(404).json({ error: 'Event not found' })
}
const event = events[index]
const updated = buildEventPayload(req.body, event)
let slugToUse = event.slug
if (req.body.slug && req.body.slug !== event.slug) {
const requestedSlug = slugify(req.body.slug)
let uniqueSlug = requestedSlug || event.slug
let suffix = 1
while (events.some((e, i) => i !== index && e.slug === uniqueSlug)) {
uniqueSlug = `${requestedSlug}-${suffix++}`
}
slugToUse = uniqueSlug
}
const merged = {
...event,
...updated,
slug: slugToUse,
updatedAt: new Date().toISOString(),
}
events[index] = merged
await writeEvents(events)
res.json(merged)
}))
app.delete('/api/events/:slug', requireAuth, asyncHandler(async (req, res) => {
const events = await readEvents()
const index = events.findIndex(event => event.slug === req.params.slug)
if (index === -1) {
return res.status(404).json({ error: 'Event not found' })
}
const [removed] = events.splice(index, 1)
await writeEvents(events)
res.json({ success: true, removed })
}))
app.post('/api/uploads', requireAuth, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' })
}
const relativeUrl = `/uploads/${req.file.filename}`
const absoluteUrl = `${req.protocol}://${req.get('host')}${relativeUrl}`
res.status(201).json({ url: absoluteUrl, path: relativeUrl })
})
app.get('/api/admin/verify', requireAuth, (req, res) => {
res.json({ ok: true })
})
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: err.message })
}
if (err && err.message === 'Only image uploads are allowed') {
return res.status(400).json({ error: err.message })
}
console.error(err)
res.status(500).json({ error: 'Internal server error' })
})
app.listen(port, () => {
console.log(`Events API listening on port ${port}`)
})

6
server/test.js Normal file
View File

@ -0,0 +1,6 @@
import express from "express"
const app = express()
app.listen(4010, () => console.log('listening 4010'))

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -1,15 +1,34 @@
import React from 'react'
import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
export function EventCard({ e }){ const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4001').replace(/\/$/, '')
const dt = new Date(e.date)
const mon = dt.toLocaleString('en', { month:'short' }) function resolveEventImage(value, fallback) {
const day = dt.getDate() 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
}
export function EventCard({ e }) {
const dt = new Date(e.date)
const hasValidDate = !Number.isNaN(dt)
const mon = hasValidDate ? dt.toLocaleString('en', { month: 'short' }) : ''
const day = hasValidDate ? dt.getDate() : ''
const details = [e.time, e.location].filter(Boolean).join(' | ')
// Map specific event names to images
const getEventImage = (title) => { const getEventImage = (title) => {
const lowerTitle = title.toLowerCase() const lowerTitle = `${title || ''}`.toLowerCase()
if (lowerTitle.includes('community sabbath lunch')) { if (lowerTitle.includes('community sabbath lunch')) {
return '/assets/potluck.png' return '/assets/potluck.png'
} }
@ -28,16 +47,18 @@ export function EventCard({ e }){
if (lowerTitle.includes('welcome') || lowerTitle.includes('committee')) { if (lowerTitle.includes('welcome') || lowerTitle.includes('committee')) {
return '/assets/welcome_commite.png' return '/assets/welcome_commite.png'
} }
// Default event image
return '/assets/potluck.png' return '/assets/potluck.png'
} }
const defaultImage = getEventImage(e.title)
const imageSrc = resolveEventImage(e.image, defaultImage)
return ( return (
<article className="card p-10 flex flex-col" style={{aspectRatio:'4/3'}}> <article className="card p-10 flex flex-col" style={{ aspectRatio: '4/3' }}>
<div className="flex items-center gap-6 mb-8"> <div className="flex items-center gap-6 mb-8">
<div className="w-20 h-20 rounded-full overflow-hidden bg-primary text-white flex items-center justify-center"> <div className="w-20 h-20 rounded-full overflow-hidden bg-primary text-white flex items-center justify-center">
<img <img
src={getEventImage(e.title)} src={imageSrc}
alt={`${e.title} event`} alt={`${e.title} event`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
@ -45,7 +66,10 @@ export function EventCard({ e }){
</div> </div>
<div> <div>
<h3 className="font-heading text-h3">{e.title}</h3> <h3 className="font-heading text-h3">{e.title}</h3>
<div className="text-muted text-small mt-2">{e.time} {e.location}</div> <div className="text-muted text-small mt-2">
{hasValidDate ? `${mon} ${day}` : e.date}
{details ? ` | ${details}` : ''}
</div>
</div> </div>
</div> </div>
<p className="mt-6 text-body mb-8">{e.description}</p> <p className="mt-6 text-body mb-8">{e.description}</p>
@ -53,20 +77,19 @@ export function EventCard({ e }){
<Link <Link
to={`/events/${e.slug}`} to={`/events/${e.slug}`}
className="btn-outline" className="btn-outline"
aria-label={`Event details: ${e.title} on ${mon} ${day}`} aria-label={`Event details: ${e.title}${hasValidDate ? ` on ${mon} ${day}` : ''}`}
> >
Details {e.title} Details - {e.title}
</Link> </Link>
</div> </div>
</article> </article>
) )
} }
export function MinistryCard({ m }){ export function MinistryCard({ m }) {
// Map specific ministry names to images
const getMinistryImage = (name) => { const getMinistryImage = (name) => {
const lowerName = name.toLowerCase() const lowerName = name.toLowerCase()
if (lowerName === 'children\'s ministry') { if (lowerName === "children's ministry") {
return '/assets/children_ministry_craft.png' return '/assets/children_ministry_craft.png'
} }
if (lowerName === 'youth ministry') { if (lowerName === 'youth ministry') {
@ -75,16 +98,15 @@ export function MinistryCard({ m }){
if (lowerName === 'adult sabbath school') { if (lowerName === 'adult sabbath school') {
return '/assets/speeking.png' return '/assets/speeking.png'
} }
if (lowerName === 'women\'s ministry') { if (lowerName === "women's ministry") {
return '/assets/pray_heart.png' return '/assets/pray_heart.png'
} }
if (lowerName === 'men\'s ministry') { if (lowerName === "men's ministry") {
return '/assets/family_entry.png' return '/assets/family_entry.png'
} }
if (lowerName === 'community outreach') { if (lowerName === 'community outreach') {
return '/assets/welcome_commite.png' return '/assets/welcome_commite.png'
} }
// Fallback for other ministries
if (lowerName.includes('children') || lowerName.includes('kids')) { if (lowerName.includes('children') || lowerName.includes('kids')) {
return '/assets/children_ministry_craft.png' return '/assets/children_ministry_craft.png'
} }
@ -97,7 +119,6 @@ export function MinistryCard({ m }){
if (lowerName.includes('prayer') || lowerName.includes('pray')) { if (lowerName.includes('prayer') || lowerName.includes('pray')) {
return '/assets/pray_heart.png' return '/assets/pray_heart.png'
} }
// Default ministry image
return '/assets/welcome_commite.png' return '/assets/welcome_commite.png'
} }
@ -127,9 +148,9 @@ export function MinistryCard({ m }){
) )
} }
export function SermonCard({ s }){ export function SermonCard({ s }) {
return ( return (
<article className="card p-10 flex flex-col" style={{aspectRatio:'4/3'}}> <article className="card p-10 flex flex-col" style={{ aspectRatio: '4/3' }}>
<div className="flex items-center gap-6 mb-8"> <div className="flex items-center gap-6 mb-8">
<div className="w-24 h-24 bg-sand rounded-lg overflow-hidden flex items-center justify-center"> <div className="w-24 h-24 bg-sand rounded-lg overflow-hidden flex items-center justify-center">
<img <img
@ -141,7 +162,7 @@ export function SermonCard({ s }){
</div> </div>
<div> <div>
<h3 className="font-heading text-h3">{s.title}</h3> <h3 className="font-heading text-h3">{s.title}</h3>
<div className="text-muted text-small mt-2">{s.speaker} {new Date(s.date).toLocaleDateString()}</div> <div className="text-muted text-small mt-2">{s.speaker} | {new Date(s.date).toLocaleDateString()}</div>
</div> </div>
</div> </div>
<p className="mt-6 text-body mb-8">{s.summary}</p> <p className="mt-6 text-body mb-8">{s.summary}</p>
@ -151,7 +172,7 @@ export function SermonCard({ s }){
className="btn-outline" className="btn-outline"
aria-label={`Watch or listen to ${s.title} by ${s.speaker}`} aria-label={`Watch or listen to ${s.title} by ${s.speaker}`}
> >
Watch/Listen {s.title} Watch/Listen - {s.title}
</Link> </Link>
</div> </div>
</article> </article>

View File

@ -83,10 +83,11 @@ export default function Footer(){
<div className="text-small text-muted"> <div className="text-small text-muted">
© {year} Annaville Seventh-day Adventist Church. All rights reserved. © {year} Annaville Seventh-day Adventist Church. All rights reserved.
</div> </div>
<div className="text-small flex gap-6"> <div className="text-small flex flex-wrap items-center gap-4">
<Link className="text-muted hover:text-primary transition-colors" to="/privacy">Privacy Policy</Link> <Link className="text-muted hover:text-primary transition-colors" to="/privacy">Privacy Policy</Link>
<Link className="text-muted hover:text-primary transition-colors" to="/terms">Terms of Use</Link> <Link className="text-muted hover:text-primary transition-colors" to="/terms">Terms of Use</Link>
<Link className="text-muted hover:text-primary transition-colors" to="/accessibility">Accessibility</Link> <Link className="text-muted hover:text-primary transition-colors" to="/accessibility">Accessibility</Link>
<Link to="/admin/events" className="btn-outline text-xs px-3 py-1">Admin Events</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@ const navItems = [
{ to:'/about', label:'ABOUT US' }, { to:'/about', label:'ABOUT US' },
{ to:'/services', label:'SERVICES' }, { to:'/services', label:'SERVICES' },
{ to:'/resources', label:'RESOURCES' }, { to:'/resources', label:'RESOURCES' },
{ to:'/events', label:'EVENTS' },
{ to:'/prayer-requests', label:'PRAYER REQUESTS' }, { to:'/prayer-requests', label:'PRAYER REQUESTS' },
{ to:'/calendar', label:'CALENDAR' }, { to:'/calendar', label:'CALENDAR' },
{ to:'/beliefs', label:'OUR BELIEFS' }, { to:'/beliefs', label:'OUR BELIEFS' },

30
src/hooks/useEvents.js Normal file
View File

@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from 'react'
import { getEvents } from '../utils/api'
export function useEvents(autoLoad = true) {
const [events, setEvents] = useState([])
const [loading, setLoading] = useState(autoLoad)
const [error, setError] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await getEvents()
setEvents(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to load events', err)
setError(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (autoLoad) {
load()
}
}, [autoLoad, load])
return { events, loading, error, reload: load, setEvents }
}

View File

@ -1,7 +1,6 @@
import React from 'react'
import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async'
import './index.css' import './index.css'
import App from './App' import App from './App'
@ -15,25 +14,52 @@ import Beliefs from './pages/Beliefs'
import Contact from './pages/Contact' import Contact from './pages/Contact'
import Privacy from './pages/Privacy' import Privacy from './pages/Privacy'
import Terms from './pages/Terms' import Terms from './pages/Terms'
import Events from './pages/Events'
import EventDetail from './pages/EventDetail'
import AdminLayout from './pages/admin/AdminLayout'
import AdminLogin from './pages/admin/AdminLogin'
import AdminEvents from './pages/admin/AdminEvents'
import AdminEventForm from './pages/admin/AdminEventForm'
import RequireAdmin from './pages/admin/RequireAdmin'
import { initAnalytics } from './utils/analytics' import { initAnalytics } from './utils/analytics'
import { initGA, initGTM } from './utils/analytics-config' import { initGA, initGTM } from './utils/analytics-config'
const router = createBrowserRouter([{ const router = createBrowserRouter([
{
path: '/', path: '/',
element: <App />, element: <App />,
children: [ children: [
{ index:true, element:<Home/> }, { index: true, element: <Home /> },
{ path:'about', element:<About/> }, { path: 'about', element: <About /> },
{ path:'services', element:<Services/> }, { path: 'services', element: <Services /> },
{ path:'resources', element:<Resources/> }, { path: 'resources', element: <Resources /> },
{ path:'prayer-requests', element:<PrayerRequests/> }, { path: 'prayer-requests', element: <PrayerRequests /> },
{ path:'calendar', element:<Calendar/> }, { path: 'calendar', element: <Calendar /> },
{ path:'beliefs', element:<Beliefs/> }, { path: 'beliefs', element: <Beliefs /> },
{ path:'contact', element:<Contact/> }, { path: 'contact', element: <Contact /> },
{ path:'privacy', element:<Privacy/> }, { path: 'privacy', element: <Privacy /> },
{ path:'terms', element:<Terms/> } { path: 'terms', element: <Terms /> },
{ path: 'events', element: <Events /> },
{ path: 'events/:slug', element: <EventDetail /> }
] ]
}]) },
{
path: '/admin',
element: <AdminLayout />,
children: [
{ index: true, element: <Navigate to="/admin/events" replace /> },
{ path: 'login', element: <AdminLogin /> },
{
element: <RequireAdmin />,
children: [
{ path: 'events', element: <AdminEvents /> },
{ path: 'events/new', element: <AdminEventForm /> },
{ path: 'events/:slug/edit', element: <AdminEventForm /> }
]
}
]
}
])
// Initialize analytics after DOM is ready // Initialize analytics after DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@ -1,10 +1,9 @@
import React from 'react'
import { Helmet } from 'react-helmet-async'
import { Link } from 'react-router-dom'
import StaticMap from '../components/StaticMap'
import React from 'react' export default function About(){
import { Helmet } from 'react-helmet-async'
import { Link } from 'react-router-dom'
import StaticMap from '../components/StaticMap'
export default function About() {
return ( return (
<> <>
<Helmet> <Helmet>
@ -12,71 +11,105 @@
<meta name="description" content="Learn about Annaville SDA Church in Corpus Christi, Texas. Our mission, location, and service times." /> <meta name="description" content="Learn about Annaville SDA Church in Corpus Christi, Texas. Our mission, location, and service times." />
</Helmet> </Helmet>
<section className="bg-gradient-to-br from-primary/15 via-sand/20 to-white py-20">
<div className="container grid gap-12 lg:grid-cols-[1.4fr,1fr] items-center">
<div className="space-y-6">
<p className="text-xs uppercase tracking-[0.3em] text-primary font-semibold">
Welcome to Annaville SDA Church
</p>
<h1 className="font-heading text-display30 text-ink max-w-3xl">
A Community Growing Together in Faith, Hope, and Service
</h1>
<p className="text-body text-muted max-w-2xl">
We are a family of believers in Corpus Christi, Texas, seeking to know Jesus, grow in faith, and serve our
community. Whether you have been a long-time member or are visiting for the first time, you are welcome here.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link to="/services" className="btn">Plan Your Visit</Link>
<Link to="/contact" className="btn-outline">Contact the Church Office</Link>
</div>
</div>
<div className="bg-white rounded-2xl shadow-level1 overflow-hidden">
<img
src="/assets/pray_heart.png"
alt="Church members praying together"
className="w-full h-64 object-cover"
/>
</div>
</div>
</section>
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<h1 className="font-heading text-h1 mb-16">About Us</h1>
<div className="grid lg:grid-cols-2 gap-24 items-start"> <div className="grid lg:grid-cols-2 gap-24 items-start">
<div className="space-y-10">
<div> <div>
<h2 className="font-heading text-h2 mb-10">Our Mission</h2> <h2 className="font-heading text-h2 mb-4">Our Mission</h2>
<p className="text-body mb-12"> <p className="text-body text-muted mb-6">
To know Jesus, grow in faith, and serve our community. To know Jesus, grow in faith, and serve our community. We believe the gospel transforms lives and that the
church is called to share hope and practical compassion with everyone we meet.
</p> </p>
<p className="text-body text-muted">
<p className="text-body mb-12"> You will find vibrant worship, thoughtful Bible study, and many ways to connect through ministries for
We are a welcoming Seventh-day Adventist congregation in Annaville. children, youth, adults, and families.
</p> </p>
</div>
<h3 className="font-heading text-h3 mb-8">Service Times</h3> <div className="space-y-6">
<div className="space-y-8 mb-12"> <h3 className="font-heading text-h3">Weekly Service Times</h3>
<div className="p-8 bg-sand rounded-xl"> <div className="space-y-4">
<h4 className="font-semibold text-ink mb-3">Sabbath School</h4> <div className="p-6 bg-sand/60 rounded-xl border border-subtle">
<p className="text-body">Begins at 9:30 a.m. each Saturday</p> <p className="font-semibold text-ink">Sabbath School</p>
<p className="text-sm text-muted">Saturdays at 9:30 AM</p>
</div> </div>
<div className="p-8 bg-sand rounded-xl"> <div className="p-6 bg-sand/60 rounded-xl border border-subtle">
<h4 className="font-semibold text-ink mb-3">Divine Worship</h4> <p className="font-semibold text-ink">Divine Worship</p>
<p className="text-body">Begins at 11:00 a.m. each Saturday</p> <p className="text-sm text-muted">Saturdays at 11:00 AM</p>
</div>
<div className="p-6 bg-sand/60 rounded-xl border border-subtle">
<p className="font-semibold text-ink">Potluck Fellowship Dinner</p>
<p className="text-sm text-muted">Immediately following the worship service</p>
</div> </div>
<div className="p-8 bg-sand rounded-xl">
<h4 className="font-semibold text-ink mb-3">Potluck Fellowship Dinner</h4>
<p className="text-body">Immediately following worship services. Visitors are encouraged to stay and fellowship.</p>
</div> </div>
</div> </div>
</div> </div>
<div> <div className="space-y-8">
<h2 className="font-heading text-h2 mb-10">Location</h2> <div className="bg-white rounded-2xl shadow-level1 overflow-hidden">
<p className="text-body mb-10"> <StaticMap />
We are located at 2710 Violet Road in Corpus Christi, Texas. </div>
<div className="space-y-4">
<h2 className="font-heading text-h2">Visit Us</h2>
<p className="text-body text-muted">
We are located at 2710 Violet Road in Corpus Christi, Texas. Parking is available on site, and our
hospitality team will gladly help you find your way around.
</p> </p>
<p className="text-body mb-12"> <div className="flex flex-col md:flex-row gap-4">
Click on the link below to see a map to our church location.
</p>
<div className="space-y-8 mb-12">
<a <a
href="https://maps.google.com/?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410" href="https://maps.google.com/?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn-outline block text-center" className="btn-outline flex-1 text-center"
> >
View Map of 2710 Violet Rd, Corpus Christi, TX 78410 View Map
</a> </a>
<a <a
href="https://maps.google.com/directions?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410" href="https://maps.google.com/directions?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn-outline block text-center" className="btn-outline flex-1 text-center"
> >
Get Driving Directions Get Directions
</a> </a>
</div> </div>
<p className="text-sm text-muted">
<StaticMap /> Need a ride? Contact the church office and ask about transportation assistance.
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</> </>
) )
} }

View File

@ -1,7 +1,33 @@
import React from 'react' import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import SEOHead from '../components/SEOHead' import SEOHead from '../components/SEOHead'
import { useEvents } from '../hooks/useEvents'
function formatDateRange(dateStr, timeStr) {
const date = new Date(dateStr)
if (Number.isNaN(date)) {
return `${dateStr}${timeStr ? ` - ${timeStr}` : ''}`
}
return `${date.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
})}${timeStr ? ` - ${timeStr}` : ''}`
}
export default function Calendar() { export default function Calendar() {
const { events, loading, error } = useEvents()
const upcoming = useMemo(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return events.filter(event => {
const date = new Date(event.date)
if (Number.isNaN(date)) return true
return date >= today
})
}, [events])
return ( return (
<> <>
<SEOHead <SEOHead
@ -26,9 +52,31 @@ export default function Calendar() {
</h2> </h2>
<div className="bg-gray-50 p-8 rounded-lg border border-gray-200"> <div className="bg-gray-50 p-8 rounded-lg border border-gray-200">
{loading && (
<p className="text-gray-600 text-center">Loading events...</p>
)}
{error && !loading && (
<p className="text-red-600 text-center">Unable to load events right now. Please try again later.</p>
)}
{!loading && !error && upcoming.length === 0 && (
<p className="text-gray-600 text-center"> <p className="text-gray-600 text-center">
No upcoming events at this time. Please check back later for updates. No upcoming events at this time. Please check back later for updates.
</p> </p>
)}
{!loading && !error && upcoming.length > 0 && (
<ul className="space-y-6">
{upcoming.map(event => (
<li key={event.slug} className="p-6 bg-white rounded-lg shadow-sm border border-subtle">
<h3 className="text-2xl font-semibold text-blue-700 mb-2">{event.title}</h3>
<div className="text-muted mb-3">{formatDateRange(event.date, event.time)}{event.location ? ` - ${event.location}` : ''}</div>
<p className="text-body mb-4">{event.description}</p>
<Link to={`/events/${event.slug}`} className="text-primary font-medium underline">
View details
</Link>
</li>
))}
</ul>
)}
</div> </div>
</div> </div>
</> </>

View File

@ -1,26 +1,18 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect } from 'react' import { Link, useParams } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import events from '../data/events.json' import { getEvent } from '../utils/api'
import { googleCalendarUrl, downloadICS } from '../utils/calendar' import { googleCalendarUrl, downloadICS } from '../utils/calendar'
import { track, events as ga } from '../utils/analytics' 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(/\/$/, '')
const { slug } = useParams()
const e = events.find(x => x.slug === slug)
useEffect(()=>{ if (e) track(ga.EVENT_DETAILS_VIEW,{slug:e.slug}) },[slug]) function parseTime(timeStr) {
if(!e) return <section className="section"><div className="container"><p>Event not found.</p></div></section>
// Parse time from 12-hour format to 24-hour format
const parseTime = (timeStr) => {
if (!timeStr) return '10:00' if (!timeStr) return '10:00'
const match = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i) const match = `${timeStr}`.match(/(\d+):(\d+)\s*(AM|PM)/i)
if (!match) return '10:00' if (!match) return '10:00'
let hours = parseInt(match[1]) let hours = parseInt(match[1], 10)
const minutes = match[2] const minutes = match[2]
const period = match[3].toUpperCase() const period = match[3].toUpperCase()
@ -28,34 +20,249 @@ export default function EventDetail(){
if (period === 'AM' && hours === 12) hours = 0 if (period === 'AM' && hours === 12) hours = 0
return `${hours.toString().padStart(2, '0')}:${minutes}` 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 [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>
)
} }
const time24 = parseTime(e.time || '10:00 AM') if (error) {
const start = new Date(`${e.date}T${time24}:00`) 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 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 = { const jsonLd = {
"@context":"https://schema.org","@type":"Event","name":e.title, '@context': 'https://schema.org',
"startDate":start.toISOString(),"endDate":end.toISOString(), '@type': 'Event',
"eventAttendanceMode":"https://schema.org/OfflineEventAttendanceMode", name: event.title,
"location":{"@type":"Place","name":"Annaville SDA Church","address":"2710 Violet Rd, Corpus Christi, TX 78410"}, startDate: start.toISOString(),
"description":e.description 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 ( return (
<section className="section"> <section className="section bg-sand/30">
<Helmet> <Helmet>
<title>{e.title} | Events</title> <title>{event.title} | Events</title>
<script type="application/ld+json">{JSON.stringify(jsonLd)}</script> <script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
</Helmet> </Helmet>
<div className="container"> <div className="container space-y-8">
<h1 className="font-heading text-display30 mb-2">{e.title}</h1> <Link to="/events" className="inline-flex items-center text-sm text-primary hover:text-primaryHover">
<div className="text-muted mb-4">{e.date} {e.time} {e.location}</div> Back to all events
<p className="text-[16px]">{e.description}</p> </Link>
<div className="mt-4 flex gap-3"> <div className="grid lg:grid-cols-[2fr,1fr] gap-10 items-start">
<a className="btn-ghost underline" target="_blank" rel="noreferrer" <article className="bg-white rounded-2xl shadow-level1 overflow-hidden">
href={googleCalendarUrl({title:e.title, details:e.description, location:e.location, start, end})}>Add to Google Calendar</a> <div className="relative h-80 md:h-96 bg-sand">
<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> <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>
</div> </div>
</section> </section>

View File

@ -1,27 +1,209 @@
import React, { useMemo, useState } from 'react'
import React, { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import events from '../data/events.json' import { Link } from 'react-router-dom'
import { EventCard } from '../components/Cards' import { EventCard } from '../components/Cards'
import { useEvents } from '../hooks/useEvents'
const filters = ['This Month','Next Month','Family','Outreach'] const filters = ['All', 'This Month', 'Next Month', 'Family', 'Outreach']
export default function Events(){ function isSameMonth(dateStr, baseDate) {
const [active, setActive] = useState('This Month') const date = new Date(dateStr)
if (Number.isNaN(date)) return false
return ( return (
<section className="section"> date.getFullYear() === baseDate.getFullYear() &&
date.getMonth() === baseDate.getMonth()
)
}
function filterEvents(events, activeFilter) {
const now = new Date()
if (activeFilter === 'All') {
return events
}
if (activeFilter === 'This Month') {
return events.filter(event => isSameMonth(event.date, now))
}
if (activeFilter === 'Next Month') {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
return events.filter(event => isSameMonth(event.date, nextMonth))
}
const category = activeFilter.toLowerCase()
return events.filter(event => (event.category || '').toLowerCase().includes(category))
}
function getValidDate(event) {
const date = new Date(event.date)
return Number.isNaN(date.getTime()) ? null : date
}
function formatEventDate(dateStr) {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr || 'Date TBA'
return date.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
function formatEventTime(timeStr) {
if (!timeStr) return 'Time TBA'
return timeStr
}
export default function Events() {
const [active, setActive] = useState('All')
const { events, loading, error } = useEvents(true)
const futureEvents = useMemo(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return events.filter(event => {
const date = new Date(event.date)
if (Number.isNaN(date.getTime())) return true
return date >= today
})
}, [events])
const actionableEvents = useMemo(
() => futureEvents.filter(event => Boolean(getValidDate(event))),
[futureEvents]
)
const nextEvent = useMemo(() => {
if (actionableEvents.length > 0) {
return actionableEvents
.slice()
.sort((a, b) => getValidDate(a) - getValidDate(b))[0]
}
return futureEvents[0] || null
}, [actionableEvents, futureEvents])
const upcomingEvents = useMemo(
() => filterEvents(futureEvents, active),
[futureEvents, active]
)
const totalUpcoming = futureEvents.length
const familyCount = futureEvents.filter(event => (event.category || '').toLowerCase().includes('family')).length
const outreachCount = futureEvents.filter(event => (event.category || '').toLowerCase().includes('outreach')).length
const nextEventDate = nextEvent ? formatEventDate(nextEvent.date) : null
const nextEventTime = nextEvent ? formatEventTime(nextEvent.time) : null
return (
<>
<Helmet><title>Events | Annaville SDA Church</title></Helmet> <Helmet><title>Events | Annaville SDA Church</title></Helmet>
<div className="container">
<h1 className="font-heading text-display30 mb-10">Events</h1> <section className="bg-gradient-to-r from-primary/10 via-sand/20 to-white py-16 md:py-20">
<div className="flex gap-6 mb-12 flex-wrap"> <div className="container grid gap-10 lg:grid-cols-[1.6fr,1fr] items-center">
{filters.map(f => ( <div className="space-y-6">
<button key={f} className="chip" aria-pressed={active===f} onClick={()=>setActive(f)}>{f}</button> <p className="text-xs uppercase tracking-[0.35em] text-primary font-semibold">
))} Gather. Serve. Grow.
</p>
<h1 className="font-heading text-display30 text-ink">
Upcoming Events & Community Moments
</h1>
<p className="text-body text-muted max-w-2xl">
There is always something happening at Annaville SDA Church. Explore ways to worship, volunteer,
and connect with families throughout the Corpus Christi community.
</p>
<div className="grid gap-6 sm:grid-cols-3">
<div>
<p className="font-heading text-display20 text-primary">
{totalUpcoming}
</p>
<p className="text-muted text-sm">Upcoming gatherings</p>
</div> </div>
<div className="grid gap-10 md:grid-cols-3"> <div>
{events.map(e => <EventCard key={e.slug} e={e} />)} <p className="font-heading text-display20 text-primary">
{familyCount}
</p>
<p className="text-muted text-sm">Family-focused events</p>
</div>
<div>
<p className="font-heading text-display20 text-primary">
{outreachCount}
</p>
<p className="text-muted text-sm">Community outreach</p>
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur rounded-2xl shadow-level1 p-6 md:p-8 space-y-4">
{nextEvent ? (
<>
<span className="text-xs uppercase tracking-[0.3em] text-primary font-semibold">Next gathering</span>
<h2 className="font-heading text-h3 text-ink">{nextEvent.title}</h2>
<div className="space-y-1 text-sm text-muted">
<div>{nextEventDate}</div>
<div>{nextEventTime}{nextEvent.location ? ` - ${nextEvent.location}` : ''}</div>
</div>
<p className="text-body text-sm text-muted line-clamp-3">
{nextEvent.description || 'We would love for you to join us. Everyone is welcome!'}
</p>
<Link to={`/events/${nextEvent.slug}`} className="btn w-full sm:w-auto">
View Event Details
</Link>
</>
) : (
<div className="space-y-3">
<h2 className="font-heading text-h3 text-ink">Check back soon</h2>
<p className="text-muted text-sm">
We are finalizing new activities and will post them here shortly. Stay tuned!
</p>
</div>
)}
</div> </div>
</div> </div>
</section> </section>
<section className="section -mt-10 md:-mt-14">
<div className="container">
<div className="bg-white rounded-2xl shadow-level1 p-6 md:p-10">
<div className="flex flex-wrap items-center justify-between gap-6 mb-8">
<h2 className="font-heading text-h3 text-ink mb-2 md:mb-0">Browse Events</h2>
<div className="flex gap-3 flex-wrap">
{filters.map(filter => (
<button
key={filter}
className={`chip ${active === filter ? 'bg-primary text-white shadow-level1 border border-primary' : ''}`}
aria-pressed={active === filter}
onClick={() => setActive(filter)}
>
{filter}
</button>
))}
</div>
</div>
<div className="min-h-[200px]">
{loading && (
<p className="text-muted">Loading events...</p>
)}
{error && !loading && (
<p className="text-red-600">Unable to load events right now. Please try again later.</p>
)}
{!loading && !error && upcomingEvents.length === 0 && (
<div className="bg-sand/40 border border-subtle rounded-xl p-8 text-center text-muted">
No upcoming events match this filter. Try another category or check back soon.
</div>
)}
{!loading && !error && upcomingEvents.length > 0 && (
<div className="grid gap-10 md:grid-cols-3">
{upcomingEvents.map(event => (
<EventCard key={event.slug} e={event} />
))}
</div>
)}
</div>
</div>
</div>
</section>
</>
) )
} }

View File

@ -1,13 +1,39 @@
import React, { useMemo } from 'react'
import React from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { TextHero } from '../components/Hero' import { TextHero } from '../components/Hero'
import VisitForm from '../components/VisitForm' import VisitForm from '../components/VisitForm'
import StaticMap from '../components/StaticMap' import StaticMap from '../components/StaticMap'
import { track, events } from '../utils/analytics' import { useEvents } from '../hooks/useEvents'
function formatEventDate(dateStr, timeStr) {
const date = new Date(dateStr)
if (Number.isNaN(date)) {
return dateStr
}
const formattedDate = date.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
})
return timeStr ? `${formattedDate} - ${timeStr}` : formattedDate
}
export default function Home() { export default function Home() {
const { events, loading, error } = useEvents()
const upcoming = useMemo(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return events
.filter(event => {
const date = new Date(event.date)
if (Number.isNaN(date)) return true
return date >= today
})
.slice(0, 3)
}, [events])
return ( return (
<> <>
<Helmet> <Helmet>
@ -126,10 +152,35 @@ export default function Home() {
<div className="text-center"> <div className="text-center">
<h2 className="font-heading text-h2 mb-8">Upcoming Events</h2> <h2 className="font-heading text-h2 mb-8">Upcoming Events</h2>
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<div className="bg-white p-12 rounded-xl shadow-sm border border-subtle"> <div className="bg-white p-12 rounded-xl shadow-sm border border-subtle text-left">
<p className="text-body text-muted"> {loading && (
<p className="text-muted text-center">Loading upcoming events...</p>
)}
{error && !loading && (
<p className="text-red-600 text-center">Unable to load events right now. Please try again later.</p>
)}
{!loading && !error && upcoming.length === 0 && (
<p className="text-body text-muted text-center">
No upcoming events at this time. Please check back later for updates. No upcoming events at this time. Please check back later for updates.
</p> </p>
)}
{!loading && !error && upcoming.length > 0 && (
<ul className="space-y-6">
{upcoming.map(event => (
<li key={event.slug} className="border-b border-subtle last:border-b-0 pb-6 last:pb-0">
<h3 className="font-heading text-h4 text-primary mb-2">{event.title}</h3>
<div className="text-muted text-small mb-3">
{formatEventDate(event.date, event.time)}
{event.location ? ` - ${event.location}` : ''}
</div>
<p className="text-body mb-3">{event.description}</p>
<Link to={`/events/${event.slug}`} className="text-primary font-medium underline">
View details
</Link>
</li>
))}
</ul>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,16 +1,29 @@
import React, { useMemo } from 'react'
import React from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import ministries from '../data/ministries.json' import ministries from '../data/ministries.json'
import events from '../data/events.json' import { useEvents } from '../hooks/useEvents'
import LazyImage from '../components/LazyImage' import LazyImage from '../components/LazyImage'
export default function MinistryDetail(){ export default function MinistryDetail() {
const { slug } = useParams() const { slug } = useParams()
const m = ministries.find(x => x.slug === slug) const { events: eventItems } = useEvents()
const ministry = ministries.find(x => x.slug === slug)
if(!m) return ( const relatedEvents = useMemo(() => {
if (!ministry) return []
const category = `${ministry.category || ''}`.toLowerCase()
return eventItems
.filter(event => {
const title = `${event.title || ''}`.toLowerCase()
const description = `${event.description || ''}`.toLowerCase()
return title.includes(category) || description.includes(category)
})
.slice(0, 3)
}, [eventItems, ministry])
if (!ministry) {
return (
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<div className="text-center py-12"> <div className="text-center py-12">
@ -21,18 +34,13 @@ export default function MinistryDetail(){
</div> </div>
</section> </section>
) )
}
// Filter events that might be related to this ministry
const relatedEvents = events.filter(e =>
e.title.toLowerCase().includes(m.category.toLowerCase()) ||
e.description.toLowerCase().includes(m.category.toLowerCase())
).slice(0, 3)
return ( return (
<> <>
<Helmet> <Helmet>
<title>{m.name} - Annaville Seventh-day Adventist Church</title> <title>{ministry.name} - Annaville Seventh-day Adventist Church</title>
<meta name="description" content={m.description} /> <meta name="description" content={ministry.description} />
</Helmet> </Helmet>
{/* Hero Section */} {/* Hero Section */}
@ -41,29 +49,29 @@ export default function MinistryDetail(){
<div className="grid lg:grid-cols-2 gap-12 items-center"> <div className="grid lg:grid-cols-2 gap-12 items-center">
<div> <div>
<div className="inline-block bg-primary/10 text-primary px-4 py-2 rounded-full text-sm font-medium mb-4"> <div className="inline-block bg-primary/10 text-primary px-4 py-2 rounded-full text-sm font-medium mb-4">
{m.category} {ministry.category}
</div> </div>
<h1 className="font-heading text-h1 mb-6">{m.name}</h1> <h1 className="font-heading text-h1 mb-6">{ministry.name}</h1>
<p className="text-body text-lg mb-6">{m.description}</p> <p className="text-body text-lg mb-6">{ministry.description}</p>
<div className="flex flex-wrap gap-4 text-muted"> <div className="flex flex-wrap gap-4 text-muted">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl">🕒</span> <span className="text-2xl"><EFBFBD>Y'</span>
<span>{m.meeting}</span> <span>{ministry.meeting}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl">📍</span> <span className="text-2xl"><EFBFBD>Y"?</span>
<span>{m.where}</span> <span>{ministry.where}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl">👥</span> <span className="text-2xl"><EFBFBD>Y'<EFBFBD></span>
<span>{m.ages}</span> <span>{ministry.ages}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="relative"> <div className="relative">
<LazyImage <LazyImage
src={m.image} src={ministry.image}
alt={`${m.name} at Annaville SDA Church`} alt={`${ministry.name} at Annaville SDA Church`}
className="w-full h-80 rounded-2xl shadow-lg" className="w-full h-80 rounded-2xl shadow-lg"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent rounded-2xl"></div> <div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent rounded-2xl"></div>
@ -83,9 +91,9 @@ export default function MinistryDetail(){
<div> <div>
<h2 className="font-heading text-h2 mb-6">What We Do</h2> <h2 className="font-heading text-h2 mb-6">What We Do</h2>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{m.activities.map((activity, index) => ( {ministry.activities.map((activity, index) => (
<div key={index} className="flex items-start gap-3 p-4 bg-sand rounded-lg"> <div key={index} className="flex items-start gap-3 p-4 bg-sand rounded-lg">
<span className="text-primary text-xl"></span> <span className="text-primary text-xl"><EFBFBD>o"</span>
<span className="text-body">{activity}</span> <span className="text-body">{activity}</span>
</div> </div>
))} ))}
@ -97,15 +105,15 @@ export default function MinistryDetail(){
<div> <div>
<h2 className="font-heading text-h2 mb-6">Upcoming Events</h2> <h2 className="font-heading text-h2 mb-6">Upcoming Events</h2>
<div className="grid gap-4"> <div className="grid gap-4">
{relatedEvents.map(e => ( {relatedEvents.map(event => (
<Link <Link
key={e.slug} key={event.slug}
to={`/events/${e.slug}`} to={`/events/${event.slug}`}
className="block p-6 bg-white border border-subtle rounded-lg hover:shadow-md transition-shadow" className="block p-6 bg-white border border-subtle rounded-lg hover:shadow-md transition-shadow"
> >
<h3 className="font-heading text-h3 mb-2">{e.title}</h3> <h3 className="font-heading text-h3 mb-2">{event.title}</h3>
<p className="text-muted mb-2">{e.date} {e.time}</p> <p className="text-muted mb-2">{event.date} - {event.time}</p>
<p className="text-body">{e.description}</p> <p className="text-body">{event.description}</p>
</Link> </Link>
))} ))}
</div> </div>
@ -116,12 +124,12 @@ export default function MinistryDetail(){
<div> <div>
<h2 className="font-heading text-h2 mb-6">Frequently Asked Questions</h2> <h2 className="font-heading text-h2 mb-6">Frequently Asked Questions</h2>
<div className="space-y-4"> <div className="space-y-4">
{m.faq.map((item, index) => ( {ministry.faq.map((item, index) => (
<details key={index} className="group"> <details key={index} className="group">
<summary className="cursor-pointer p-6 bg-white border border-subtle rounded-lg hover:bg-sand/50 transition-colors list-none"> <summary className="cursor-pointer p-6 bg-white border border-subtle rounded-lg hover:bg-sand/50 transition-colors list-none">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-heading text-h3 text-ink">{item.question}</h3> <h3 className="font-heading text-h3 text-ink">{item.question}</h3>
<span className="text-primary text-xl group-open:rotate-180 transition-transform"></span> <span className="text-primary text-xl group-open:rotate-180 transition-transform"><EFBFBD>-<EFBFBD></span>
</div> </div>
</summary> </summary>
<div className="p-6 bg-sand/30 rounded-b-lg border-t-0 border border-subtle"> <div className="p-6 bg-sand/30 rounded-b-lg border-t-0 border border-subtle">
@ -142,29 +150,29 @@ export default function MinistryDetail(){
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h4 className="font-medium text-ink mb-2">Ministry Leader</h4> <h4 className="font-medium text-ink mb-2">Ministry Leader</h4>
<p className="text-body">{m.leader}</p> <p className="text-body">{ministry.leader}</p>
</div> </div>
<div> <div>
<h4 className="font-medium text-ink mb-2">Contact</h4> <h4 className="font-medium text-ink mb-2">Contact</h4>
<div className="space-y-2"> <div className="space-y-2">
<a <a
href={`mailto:${m.contact.email}`} href={`mailto:${ministry.contact.email}`}
className="block text-primary hover:text-primaryHover transition-colors" className="block text-primary hover:text-primaryHover transition-colors"
> >
📧 {m.contact.email} <EFBFBD>Y"<EFBFBD> {ministry.contact.email}
</a> </a>
<a <a
href={`tel:${m.contact.phone.replace(/\D/g, '')}`} href={`tel:${ministry.contact.phone.replace(/\D/g, '')}`}
className="block text-primary hover:text-primaryHover transition-colors" className="block text-primary hover:text-primaryHover transition-colors"
> >
📞 {m.contact.phone} <EFBFBD>Y"z {ministry.contact.phone}
</a> </a>
</div> </div>
</div> </div>
<div> <div>
<h4 className="font-medium text-ink mb-2">Meeting Details</h4> <h4 className="font-medium text-ink mb-2">Meeting Details</h4>
<p className="text-body">{m.meeting}</p> <p className="text-body">{ministry.meeting}</p>
<p className="text-muted text-sm">{m.where}</p> <p className="text-muted text-sm">{ministry.where}</p>
</div> </div>
</div> </div>
<div className="mt-6"> <div className="mt-6">
@ -182,7 +190,7 @@ export default function MinistryDetail(){
to="/ministries" to="/ministries"
className="block text-primary hover:text-primaryHover transition-colors" className="block text-primary hover:text-primaryHover transition-colors"
> >
Back to All Ministries <EFBFBD><EFBFBD>? Back to All Ministries
</Link> </Link>
<Link <Link
to="/events" to="/events"
@ -204,16 +212,16 @@ export default function MinistryDetail(){
<h3 className="font-heading text-h3 mb-4">Other Ministries</h3> <h3 className="font-heading text-h3 mb-4">Other Ministries</h3>
<div className="space-y-3"> <div className="space-y-3">
{ministries {ministries
.filter(ministry => ministry.slug !== m.slug) .filter(other => other.slug !== ministry.slug)
.slice(0, 3) .slice(0, 3)
.map(ministry => ( .map(other => (
<Link <Link
key={ministry.slug} key={other.slug}
to={`/ministries/${ministry.slug}`} to={`/ministries/${other.slug}`}
className="block p-3 bg-sand rounded-lg hover:bg-sand/80 transition-colors" className="block p-3 bg-sand rounded-lg hover:bg-sand/80 transition-colors"
> >
<h4 className="font-medium text-ink">{ministry.name}</h4> <h4 className="font-medium text-ink">{other.name}</h4>
<p className="text-muted text-sm">{ministry.meeting}</p> <p className="text-muted text-sm">{other.meeting}</p>
</Link> </Link>
))} ))}
</div> </div>

View File

@ -1,131 +1,132 @@
import React from 'react' import React from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
export default function Services() { const services = [
const services = [
{ {
title: "Sabbath School", title: 'Sabbath School',
time: "9:30 AM", image: '/assets/family_entry.png',
day: "Saturday", time: '9:30 AM',
description: "Interactive Bible study and discussion groups for all ages. Join us for meaningful conversations about Scripture and practical Christian living.", day: 'Saturday',
icon: "📖", description: 'Interactive Bible study and discussion groups for all ages. Join us for meaningful conversations about Scripture and practical Christian living.',
color: "from-blue-500 to-blue-600" tagColor: 'bg-blue-100 text-blue-800'
}, },
{ {
title: "Divine Worship", title: 'Divine Worship',
time: "11:00 AM", image: '/assets/speeking.png',
day: "Saturday", time: '11:00 AM',
description: "Our main worship service featuring inspiring music, prayer, and biblical messages that speak to everyday life and spiritual growth.", day: 'Saturday',
icon: "⛪", description: 'Our main worship service featuring inspiring music, prayer, and biblical messages that speak to everyday life and spiritual growth.',
color: "from-primary to-primaryDeep" tagColor: 'bg-primary/15 text-primary'
}, },
{ {
title: "Potluck Fellowship", title: 'Potluck Fellowship',
time: "After Worship", image: '/assets/potluck.png',
day: "Saturday", time: 'After Worship',
description: "Stay after the service for a delicious meal and warm fellowship. Visitors are especially welcome to join us for this time of community.", day: 'Saturday',
icon: "🍽️", description: 'Stay after the service for a delicious meal and warm fellowship. Visitors are especially welcome to join us for this time of community.',
color: "from-green-500 to-green-600" tagColor: 'bg-green-100 text-green-700'
} }
] ]
const additionalInfo = [ const additionalInfo = [
{ {
title: "Church Bus Service", title: 'Church Bus Service',
description: "Transportation available for those who need assistance getting to church. Please contact us to arrange pickup.", description: 'Transportation is available for those who need assistance getting to church. Please contact us to arrange pickup.',
icon: "🚌" icon: '🚌'
}, },
{ {
title: "Family-Friendly Environment", title: 'Family-Friendly Environment',
description: "Children are welcome in all our services. We also offer age-appropriate activities and care during worship times.", description: 'Children are welcome in all our services. We also offer age-appropriate activities and care during worship times.',
icon: "👨‍👩‍👧‍👦" icon: '👨‍👩‍👧‍👦'
}, },
{ {
title: "Visitor Welcome", title: 'Visitor Welcome',
description: "First time visiting? We're excited to meet you! Greeters are available to help you feel at home.", description: "First time visiting? We're excited to meet you! Greeters are available to help you feel at home.",
icon: "🤝" icon: '🤝'
} }
] ]
export default function Services(){
return ( return (
<> <>
<Helmet> <Helmet>
<title>Services - Annaville Seventh-day Adventist Church</title> <title>Services - Annaville Seventh-day Adventist Church</title>
<meta name="description" content="Join us for Sabbath School at 9:30 AM and Divine Worship at 11:00 AM every Saturday. Experience meaningful fellowship and spiritual growth in a welcoming community." /> <meta
<meta name="keywords" content="worship services, Sabbath School, Divine Worship, fellowship, church services, Annaville SDA" /> name="description"
content="Join us for Sabbath School at 9:30 AM and Divine Worship at 11:00 AM every Saturday. Experience meaningful fellowship and spiritual growth in a welcoming community."
/>
<meta
name="keywords"
content="worship services, Sabbath School, Divine Worship, fellowship, church services, Annaville SDA"
/>
</Helmet> </Helmet>
{/* Hero Section */}
<section className="bg-gradient-to-br from-primary to-primaryDeep text-white py-20"> <section className="bg-gradient-to-br from-primary to-primaryDeep text-white py-20">
<div className="container"> <div className="container">
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-4xl mx-auto text-center space-y-6">
<h1 className="font-heading text-h1 mb-6">Worship Services</h1> <h1 className="font-heading text-h1">Worship Services</h1>
<p className="text-xl text-white/90 leading-relaxed"> <p className="text-xl text-white/90 leading-relaxed">
Join us every Saturday for inspiring worship, meaningful Bible study, and warm fellowship. Join us every Saturday for inspiring worship, meaningful Bible study, and warm fellowship. All are welcome to experience the love of Christ in our community.
All are welcome to experience the love of Christ in our community.
</p> </p>
</div> </div>
</div> </div>
</section> </section>
{/* Main Services Section */}
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="font-heading text-h2 mb-6">Weekly Services</h2> <h2 className="font-heading text-h2 mb-6">Weekly Services</h2>
<p className="text-body text-muted max-w-3xl mx-auto"> <p className="text-body text-muted max-w-3xl mx-auto">
The Annaville SDA Church offers worship services for members, non-members, or anyone interested The Annaville SDA Church offers worship services for members, non-members, and anyone interested in learning more about practical Christian living from the Word of God.
in learning more about practical Christian living from the Word of God.
</p> </p>
</div> </div>
<div className="grid lg:grid-cols-2 gap-12 items-start"> <div className="grid lg:grid-cols-2 gap-12 items-start">
{/* Services Cards */}
<div className="space-y-8"> <div className="space-y-8">
{services.map((service, index) => ( {services.map(service => (
<div key={index} className="group"> <article key={service.title} className="bg-white p-8 rounded-xl shadow-sm border border-subtle hover:shadow-level1 transition-shadow duration-300">
<div className="bg-white p-8 rounded-xl shadow-sm border border-subtle hover:shadow-md transition-all duration-300"> <div className="flex gap-6 items-start">
<div className="flex items-start gap-6"> <div className="w-28 h-28 rounded-xl overflow-hidden border border-subtle shadow-sm">
<div className={`text-4xl p-3 rounded-lg bg-gradient-to-br ${service.color} text-white`}> <img
{service.icon} src={service.image}
alt={`${service.title} illustration`}
className="w-full h-full object-cover"
loading="lazy"
/>
</div> </div>
<div className="flex-1"> <div className="flex-1 space-y-4">
<h3 className="font-heading text-h3 text-ink mb-3">{service.title}</h3> <div>
<div className="flex items-center gap-4 mb-4"> <h3 className="font-heading text-h3 text-ink mb-2">{service.title}</h3>
<span className="inline-flex items-center px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium"> <div className="flex items-center gap-4">
<span className={`inline-flex items-center px-4 py-2 rounded-full text-sm font-medium ${service.tagColor}`}>
{service.time} {service.time}
</span> </span>
<span className="text-muted text-sm">{service.day}</span> <span className="text-muted text-sm">{service.day}</span>
</div> </div>
</div>
<p className="text-body text-muted leading-relaxed"> <p className="text-body text-muted leading-relaxed">
{service.description} {service.description}
</p> </p>
</div> </div>
</div> </div>
</div> </article>
</div>
))} ))}
</div> </div>
{/* Image Section */}
<div className="relative"> <div className="relative">
<div className="sticky top-8"> <div className="sticky top-8">
<img <img
src="/assets/family_entry.png" src="/assets/family_entry.png"
alt="Church bus and family entry - People walking up steps to the church entrance" alt="Families arriving at church"
className="w-full rounded-xl shadow-lg" className="w-full rounded-xl shadow-lg"
/> />
<div className="mt-6 p-6 bg-sand rounded-xl"> <div className="mt-6 p-6 bg-sand rounded-xl">
<h3 className="font-heading text-h3 mb-4 text-primary">Plan Your Visit</h3> <h3 className="font-heading text-h3 mb-4 text-primary">Plan Your Visit</h3>
<p className="text-body text-muted mb-4"> <p className="text-body text-muted mb-4">
We're located at 2710 Violet Road in Corpus Christi. We're located at 2710 Violet Road in Corpus Christi. Parking is available on-site, and our greeters will help you find your way.
Parking is available on-site, and our greeters will help you find your way.
</p> </p>
<a <a href="/contact" className="btn w-full">
href="/contact"
className="btn w-full"
>
Get Directions Get Directions
</a> </a>
</div> </div>
@ -136,7 +137,6 @@ export default function Services() {
</div> </div>
</section> </section>
{/* Additional Information Section */}
<section className="section bg-sand"> <section className="section bg-sand">
<div className="container"> <div className="container">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
@ -148,13 +148,13 @@ export default function Services() {
</div> </div>
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
{additionalInfo.map((info, index) => ( {additionalInfo.map(item => (
<div key={index} className="text-center"> <div key={item.title} className="text-center">
<div className="bg-white p-8 rounded-xl shadow-sm border border-subtle"> <div className="bg-white p-8 rounded-xl shadow-sm border border-subtle">
<div className="text-4xl mb-4">{info.icon}</div> <div className="text-3xl mb-4" aria-hidden="true">{item.icon}</div>
<h3 className="font-heading text-h3 mb-4 text-primary">{info.title}</h3> <h3 className="font-heading text-h3 mb-4 text-primary">{item.title}</h3>
<p className="text-body text-muted leading-relaxed"> <p className="text-body text-muted leading-relaxed">
{info.description} {item.description}
</p> </p>
</div> </div>
</div> </div>
@ -164,27 +164,19 @@ export default function Services() {
</div> </div>
</section> </section>
{/* Call to Action Section */}
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-4xl mx-auto text-center">
<div className="bg-white p-12 rounded-xl shadow-sm border border-subtle"> <div className="bg-white p-12 rounded-xl shadow-sm border border-subtle">
<h3 className="font-heading text-h3 mb-6">Ready to Join Us?</h3> <h3 className="font-heading text-h3 mb-6">Ready to Join Us?</h3>
<p className="text-body text-muted mb-8"> <p className="text-body text-muted mb-8">
We'd love to welcome you this Saturday! Have questions about our services or need assistance? We'd love to welcome you this Saturday! Have questions about our services or need assistance? Don't hesitate to reach out.
Don't hesitate to reach out.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<a <a href="/contact" className="btn">
href="/contact"
className="btn"
>
Contact Us Contact Us
</a> </a>
<a <a href="/visit" className="btn-outline">
href="/visit"
className="btn-outline"
>
Plan Your Visit Plan Your Visit
</a> </a>
</div> </div>

View File

@ -0,0 +1,374 @@
import React, { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { createEvent, getAdminToken, getEvent, updateEvent, uploadImage } from '../../utils/api'
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4001').replace(/\/$/, '')
function resolveImageUrl(value) {
if (!value) return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (/^(?:https?:)?\/\//i.test(trimmed) || trimmed.startsWith('data:')) {
return trimmed
}
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
if (apiBaseUrl) {
return `${apiBaseUrl}${path}`
}
return path
}
const emptyForm = {
title: '',
date: '',
time: '',
location: '',
description: '',
category: '',
image: '',
slug: '',
}
export default function AdminEventForm() {
const { slug } = useParams()
const isEdit = Boolean(slug)
const navigate = useNavigate()
const [form, setForm] = useState(emptyForm)
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [selectedFile, setSelectedFile] = useState(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState('')
const [uploadMessage, setUploadMessage] = useState('')
const fileInputRef = useRef(null)
const previewSrc = resolveImageUrl(form.image)
useEffect(() => {
if (!isEdit) return
let ignore = false
async function loadEvent() {
setLoading(true)
setError('')
try {
const data = await getEvent(slug)
if (!ignore) {
setForm({
title: data.title || '',
date: data.date || '',
time: data.time || '',
location: data.location || '',
description: data.description || '',
category: data.category || '',
image: data.image || '',
slug: data.slug || '',
})
}
} catch (err) {
console.error('Failed to load event for editing', err)
if (!ignore) {
setError(err.message || 'Unable to load event data.')
}
} finally {
if (!ignore) {
setLoading(false)
}
}
}
loadEvent()
return () => {
ignore = true
}
}, [isEdit, slug])
const handleChange = (event) => {
const { name, value } = event.target
setForm((prev) => ({ ...prev, [name]: value }))
}
const handleFileSelect = (event) => {
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null
setSelectedFile(file)
setUploadError('')
setUploadMessage('')
}
const handleUpload = async () => {
setUploadError('')
setUploadMessage('')
if (!selectedFile) {
setUploadError('Select an image file before uploading.')
return
}
const token = getAdminToken()
if (!token) {
setError('No admin token found. Please log in again.')
return
}
setUploading(true)
try {
const { url } = await uploadImage(selectedFile, token)
setForm(prev => ({ ...prev, image: url }))
setSelectedFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
setUploadMessage('Image uploaded successfully.')
} catch (err) {
console.error('Failed to upload image', err)
setUploadError(err.message || 'Unable to upload image. Please try again.')
} finally {
setUploading(false)
}
}
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
if (!form.title || !form.date) {
setError('Title and date are required.')
return
}
const token = getAdminToken()
if (!token) {
setError('No admin token found. Please log in again.')
return
}
setSaving(true)
let imageValue = form.image
try {
if (selectedFile) {
setUploadError('')
setUploadMessage('')
setUploading(true)
try {
const { url, path } = await uploadImage(selectedFile, token)
imageValue = path || url
setForm(prev => ({ ...prev, image: imageValue }))
setSelectedFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
setUploadMessage('Image uploaded successfully.')
} catch (err) {
console.error('Failed to upload image', err)
setError(err.message || 'Unable to upload image. Please try again.')
setSaving(false)
setUploading(false)
return
} finally {
setUploading(false)
}
}
const payload = {
title: form.title,
date: form.date,
time: form.time,
location: form.location,
description: form.description,
category: form.category,
image: imageValue,
}
if (form.slug) {
payload.slug = form.slug
}
if (isEdit) {
await updateEvent(slug, payload, token)
} else {
await createEvent(payload, token)
}
navigate('/admin/events', { replace: true })
} catch (err) {
console.error('Failed to save event', err)
setError(err.message || 'Unable to save event. Please try again.')
} finally {
setSaving(false)
}
}
return (
<div className="max-w-3xl">
<h1 className="text-3xl font-semibold text-slate-900 mb-6">
{isEdit ? 'Edit Event' : 'Add New Event'}
</h1>
<p className="text-muted mb-6">
Provide the event details below. Title and date are required. Slug is optional and generated automatically if left blank.
</p>
{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6">{error}</div>}
{loading ? (
<p className="text-muted">Loading event...</p>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-slate-700 mb-2">Title *</label>
<input
id="title"
name="title"
type="text"
value={form.title}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
required
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="date" className="block text-sm font-medium text-slate-700 mb-2">Date *</label>
<input
id="date"
name="date"
type="date"
value={form.date}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
required
/>
</div>
<div>
<label htmlFor="time" className="block text-sm font-medium text-slate-700 mb-2">Time</label>
<input
id="time"
name="time"
type="text"
placeholder="e.g. 11:00 AM"
value={form.time}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium text-slate-700 mb-2">Location</label>
<input
id="location"
name="location"
type="text"
value={form.location}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
placeholder="Sanctuary, Fellowship Hall, etc."
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-slate-700 mb-2">Description</label>
<textarea
id="description"
name="description"
rows={5}
value={form.description}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="category" className="block text-sm font-medium text-slate-700 mb-2">Category</label>
<input
id="category"
name="category"
type="text"
value={form.category}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
placeholder="Family, Outreach, Youth"
/>
</div>
<div>
<label htmlFor="image" className="block text-sm font-medium text-slate-700 mb-2">Image</label>
<div className="space-y-3">
<input
id="image"
name="image"
type="text"
value={form.image}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
placeholder="Paste an image URL or upload a file below"
/>
<div className="flex flex-wrap items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="text-sm"
/>
<button
type="button"
className="btn-outline"
onClick={handleUpload}
disabled={!selectedFile || uploading}
>
{uploading ? 'Uploading...' : 'Upload Image'}
</button>
</div>
{selectedFile && (
<p className="text-xs text-muted">Selected file: {selectedFile.name}</p>
)}
{uploadError && (
<p className="text-xs text-red-600">{uploadError}</p>
)}
{uploadMessage && (
<p className="text-xs text-emerald-600">{uploadMessage}</p>
)}
{previewSrc && (
<div className="pt-2">
<span className="text-xs text-muted block mb-1">Preview</span>
<img
src={previewSrc}
alt="Event image preview"
className="h-24 w-24 rounded-md border border-subtle object-cover"
/>
</div>
)}
</div>
</div>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-slate-700 mb-2">Slug (optional)</label>
<input
id="slug"
name="slug"
type="text"
value={form.slug}
onChange={handleChange}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
placeholder="custom-event-slug"
/>
<p className="text-xs text-muted mt-2">If blank, the slug will be generated from the title.</p>
</div>
<div className="flex items-center gap-3">
<button type="submit" className="btn" disabled={saving}>
{saving ? (isEdit ? 'Saving...' : 'Creating...') : (isEdit ? 'Save Changes' : 'Create Event')}
</button>
<button type="button" className="btn-outline" onClick={() => navigate('/admin/events')} disabled={saving}>
Cancel
</button>
</div>
</form>
)}
</div>
)
}

View File

@ -0,0 +1,102 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { deleteEvent, getAdminToken } from '../../utils/api'
import { useEvents } from '../../hooks/useEvents'
export default function AdminEvents() {
const { events, loading, error, reload } = useEvents()
const [busySlug, setBusySlug] = useState('')
const [feedback, setFeedback] = useState('')
const handleDelete = async (slug) => {
if (!window.confirm('Delete this event? This action cannot be undone.')) {
return
}
const token = getAdminToken()
if (!token) {
setFeedback('No admin token found. Please log in again.')
return
}
setBusySlug(slug)
setFeedback('')
try {
await deleteEvent(slug, token)
await reload()
setFeedback('Event deleted successfully.')
} catch (err) {
console.error('Failed to delete event', err)
setFeedback(err.message || 'Unable to delete event. Please try again.')
} finally {
setBusySlug('')
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold text-slate-900 mb-1">Manage Events</h1>
<p className="text-muted">Create, edit, and remove upcoming events.</p>
</div>
<Link to="/admin/events/new" className="btn">
Add Event
</Link>
</div>
{feedback && <div className="bg-sand border border-subtle rounded-md px-4 py-3 text-sm">{feedback}</div>}
{loading && <p className="text-muted">Loading events...</p>}
{error && !loading && <p className="text-red-600">Unable to load events. Please refresh.</p>}
{!loading && !error && (
<div className="overflow-x-auto bg-white border border-subtle rounded-xl shadow-sm">
<table className="min-w-full divide-y divide-subtle">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wide">Title</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wide">Date</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wide">Time</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wide">Location</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wide">Slug</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-600 uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-subtle">
{events.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-muted">
No events yet. Click "Add Event" to create the first one.
</td>
</tr>
)}
{events.map(event => (
<tr key={event.slug} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm font-medium text-slate-900">{event.title}</td>
<td className="px-4 py-3 text-sm text-slate-600">{event.date}</td>
<td className="px-4 py-3 text-sm text-slate-600">{event.time}</td>
<td className="px-4 py-3 text-sm text-slate-600">{event.location}</td>
<td className="px-4 py-3 text-sm text-slate-600">{event.slug}</td>
<td className="px-4 py-3 text-sm text-right flex justify-end gap-2">
<Link to={`/admin/events/${event.slug}/edit`} className="btn-outline text-sm">
Edit
</Link>
<button
type="button"
className="btn-outline text-sm text-red-600 border-red-200 hover:bg-red-50"
onClick={() => handleDelete(event.slug)}
disabled={busySlug === event.slug}
>
{busySlug === event.slug ? 'Deleting...' : 'Delete'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,49 @@
import React from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { getAdminToken, setAdminToken } from '../../utils/api'
function navClass({ isActive }) {
return `px-3 py-2 rounded-md text-sm font-medium ${isActive ? 'bg-slate-800 text-white' : 'text-slate-200 hover:bg-slate-800 hover:text-white'}`
}
export default function AdminLayout() {
const navigate = useNavigate()
const token = getAdminToken()
const handleLogout = () => {
setAdminToken('')
navigate('/admin/login', { replace: true })
}
return (
<div className="min-h-screen bg-slate-100">
<header className="bg-slate-900 text-white">
<div className="max-w-6xl mx-auto px-6 py-4 flex flex-wrap items-center justify-between gap-4">
<NavLink to="/admin/events" className="text-lg font-semibold tracking-tight">
Annaville SDA Admin
</NavLink>
<nav className="flex items-center gap-3">
<NavLink to="/admin/events" className={navClass}>
Events
</NavLink>
<NavLink to="/admin/events/new" className={navClass}>
Add Event
</NavLink>
{token ? (
<button type="button" className="btn" onClick={handleLogout}>
Log out
</button>
) : (
<NavLink to="/admin/login" className={navClass}>
Log in
</NavLink>
)}
</nav>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-10">
<Outlet />
</main>
</div>
)
}

View File

@ -0,0 +1,65 @@
import React, { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { getAdminToken, setAdminToken, verifyAdminToken } from '../../utils/api'
export default function AdminLogin() {
const [token, setToken] = useState(() => getAdminToken())
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const location = useLocation()
const navigate = useNavigate()
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
if (!token) {
setError('Please provide the admin access token.')
return
}
setLoading(true)
try {
await verifyAdminToken(token)
setAdminToken(token)
const redirectTo = location.state?.from?.pathname || '/admin/events'
navigate(redirectTo, { replace: true })
} catch (err) {
console.error('Admin login failed', err)
setError(err.message || 'Unable to verify token. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-lg mx-auto bg-white border border-subtle rounded-xl shadow-sm p-8">
<h1 className="text-2xl font-semibold mb-6 text-slate-900">Admin Login</h1>
<p className="text-body text-muted mb-6">
Enter the admin access token provided to your team to manage events.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="admin-token" className="block text-sm font-medium text-slate-700 mb-2">
Access Token
</label>
<input
id="admin-token"
type="password"
value={token}
onChange={(event) => setToken(event.target.value)}
className="w-full border border-subtle rounded-md px-4 py-2 outline-none focus:ring-2 focus:ring-primary"
placeholder="Enter secure token"
autoComplete="off"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex items-center justify-between">
<button type="submit" className="btn" disabled={loading}>
{loading ? 'Verifying...' : 'Log in'}
</button>
<span className="text-sm text-muted">Need access? Contact the site administrator.</span>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,14 @@
import React from 'react'
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { getAdminToken } from '../../utils/api'
export default function RequireAdmin() {
const token = getAdminToken()
const location = useLocation()
if (!token) {
return <Navigate to="/admin/login" replace state={{ from: location }} />
}
return <Outlet />
}

85
src/utils/api.js Normal file
View File

@ -0,0 +1,85 @@
const baseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4001').replace(/\/$/, '')
async function request(path, { method = 'GET', body, token, headers = {} } = {}) {
const url = `${baseUrl}${path}`
const init = { method, headers: { ...headers } }
if (token) {
init.headers['x-admin-token'] = token
}
if (body !== undefined) {
if (body instanceof FormData) {
init.body = body
} else {
init.headers['Content-Type'] = init.headers['Content-Type'] || 'application/json'
init.body = JSON.stringify(body)
}
}
const response = await fetch(url, init)
const text = await response.text()
let data = null
if (text) {
try {
data = JSON.parse(text)
} catch (error) {
data = text
}
}
if (!response.ok) {
const message = typeof data === 'object' && data !== null && 'error' in data
? data.error
: `Request failed with status ${response.status}`
const err = new Error(message)
err.status = response.status
err.data = data
throw err
}
return data
}
export function getEvents() {
return request('/api/events')
}
export function getEvent(slug) {
return request(`/api/events/${slug}`)
}
export function createEvent(payload, token) {
return request('/api/events', { method: 'POST', body: payload, token })
}
export function updateEvent(slug, payload, token) {
return request(`/api/events/${slug}`, { method: 'PATCH', body: payload, token })
}
export function deleteEvent(slug, token) {
return request(`/api/events/${slug}`, { method: 'DELETE', token })
}
export function verifyAdminToken(token) {
return request('/api/admin/verify', { token })
}
export function uploadImage(file, token) {
const form = new FormData()
form.append('file', file)
return request('/api/uploads', { method: 'POST', body: form, token })
}
export function getAdminToken() {
return localStorage.getItem('annaville-admin-token') || ''
}
export function setAdminToken(token) {
if (!token) {
localStorage.removeItem('annaville-admin-token')
} else {
localStorage.setItem('annaville-admin-token', token)
}
}

View File

@ -1,9 +1,22 @@
import { defineConfig } from 'vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: { target: 'es2018', sourcemap: false }, build: { target: 'es2018', sourcemap: false },
server: { port: 5173 } server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:4001',
changeOrigin: true,
secure: false,
},
'/uploads': {
target: 'http://localhost:4001',
changeOrigin: true,
secure: false,
},
},
},
}) })