Admin website
13
README.md
|
|
@ -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.
|
||||
|
||||
|
|
@ -22,9 +22,16 @@ docker compose up --build
|
|||
```
|
||||
|
||||
### 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`:
|
||||
`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.
|
||||
- JSON-LD is injected on Home (Organization, Website, FAQ) and Event/Sermon detail pages.
|
||||
# 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.
|
||||
|
|
|
|||
21
package.json
|
|
@ -4,21 +4,28 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "concurrently \"npm:dev:client\" \"npm:dev:server\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "node server/index.js",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173"
|
||||
"preview": "vite preview --port 5173",
|
||||
"start:server": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"react": "^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": {
|
||||
"vite": "^5.4.2",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"concurrently": "^9.2.1",
|
||||
"postcss": "^8.4.47",
|
||||
"autoprefixer": "^10.4.18"
|
||||
"tailwindcss": "^3.4.10",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 6.5 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 4.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 6.7 MiB |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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}`)
|
||||
})
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import express from "express"
|
||||
|
||||
const app = express()
|
||||
|
||||
app.listen(4010, () => console.log('listening 4010'))
|
||||
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
|
@ -1,15 +1,34 @@
|
|||
|
||||
import React from 'react'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function EventCard({ e }){
|
||||
const dt = new Date(e.date)
|
||||
const mon = dt.toLocaleString('en', { month:'short' })
|
||||
const day = dt.getDate()
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4001').replace(/\/$/, '')
|
||||
|
||||
function resolveEventImage(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
|
||||
}
|
||||
|
||||
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 lowerTitle = title.toLowerCase()
|
||||
const lowerTitle = `${title || ''}`.toLowerCase()
|
||||
if (lowerTitle.includes('community sabbath lunch')) {
|
||||
return '/assets/potluck.png'
|
||||
}
|
||||
|
|
@ -28,16 +47,18 @@ export function EventCard({ e }){
|
|||
if (lowerTitle.includes('welcome') || lowerTitle.includes('committee')) {
|
||||
return '/assets/welcome_commite.png'
|
||||
}
|
||||
// Default event image
|
||||
return '/assets/potluck.png'
|
||||
}
|
||||
|
||||
const defaultImage = getEventImage(e.title)
|
||||
const imageSrc = resolveEventImage(e.image, defaultImage)
|
||||
|
||||
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="w-20 h-20 rounded-full overflow-hidden bg-primary text-white flex items-center justify-center">
|
||||
<img
|
||||
src={getEventImage(e.title)}
|
||||
src={imageSrc}
|
||||
alt={`${e.title} event`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
|
|
@ -45,7 +66,10 @@ export function EventCard({ e }){
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<p className="mt-6 text-body mb-8">{e.description}</p>
|
||||
|
|
@ -53,20 +77,19 @@ export function EventCard({ e }){
|
|||
<Link
|
||||
to={`/events/${e.slug}`}
|
||||
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>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function MinistryCard({ m }){
|
||||
// Map specific ministry names to images
|
||||
export function MinistryCard({ m }) {
|
||||
const getMinistryImage = (name) => {
|
||||
const lowerName = name.toLowerCase()
|
||||
if (lowerName === 'children\'s ministry') {
|
||||
if (lowerName === "children's ministry") {
|
||||
return '/assets/children_ministry_craft.png'
|
||||
}
|
||||
if (lowerName === 'youth ministry') {
|
||||
|
|
@ -75,16 +98,15 @@ export function MinistryCard({ m }){
|
|||
if (lowerName === 'adult sabbath school') {
|
||||
return '/assets/speeking.png'
|
||||
}
|
||||
if (lowerName === 'women\'s ministry') {
|
||||
if (lowerName === "women's ministry") {
|
||||
return '/assets/pray_heart.png'
|
||||
}
|
||||
if (lowerName === 'men\'s ministry') {
|
||||
if (lowerName === "men's ministry") {
|
||||
return '/assets/family_entry.png'
|
||||
}
|
||||
if (lowerName === 'community outreach') {
|
||||
return '/assets/welcome_commite.png'
|
||||
}
|
||||
// Fallback for other ministries
|
||||
if (lowerName.includes('children') || lowerName.includes('kids')) {
|
||||
return '/assets/children_ministry_craft.png'
|
||||
}
|
||||
|
|
@ -97,7 +119,6 @@ export function MinistryCard({ m }){
|
|||
if (lowerName.includes('prayer') || lowerName.includes('pray')) {
|
||||
return '/assets/pray_heart.png'
|
||||
}
|
||||
// Default ministry image
|
||||
return '/assets/welcome_commite.png'
|
||||
}
|
||||
|
||||
|
|
@ -127,9 +148,9 @@ export function MinistryCard({ m }){
|
|||
)
|
||||
}
|
||||
|
||||
export function SermonCard({ s }){
|
||||
export function SermonCard({ s }) {
|
||||
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="w-24 h-24 bg-sand rounded-lg overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
|
|
@ -141,7 +162,7 @@ export function SermonCard({ s }){
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<p className="mt-6 text-body mb-8">{s.summary}</p>
|
||||
|
|
@ -151,7 +172,7 @@ export function SermonCard({ s }){
|
|||
className="btn-outline"
|
||||
aria-label={`Watch or listen to ${s.title} by ${s.speaker}`}
|
||||
>
|
||||
Watch/Listen — {s.title}
|
||||
Watch/Listen - {s.title}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -83,10 +83,11 @@ export default function Footer(){
|
|||
<div className="text-small text-muted">
|
||||
© {year} Annaville Seventh-day Adventist Church. All rights reserved.
|
||||
</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="/terms">Terms of Use</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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const navItems = [
|
|||
{ to:'/about', label:'ABOUT US' },
|
||||
{ to:'/services', label:'SERVICES' },
|
||||
{ to:'/resources', label:'RESOURCES' },
|
||||
{ to:'/events', label:'EVENTS' },
|
||||
{ to:'/prayer-requests', label:'PRAYER REQUESTS' },
|
||||
{ to:'/calendar', label:'CALENDAR' },
|
||||
{ to:'/beliefs', label:'OUR BELIEFS' },
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
56
src/main.jsx
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
import React from 'react'
|
||||
import React from 'react'
|
||||
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 './index.css'
|
||||
import App from './App'
|
||||
|
|
@ -15,25 +14,52 @@ import Beliefs from './pages/Beliefs'
|
|||
import Contact from './pages/Contact'
|
||||
import Privacy from './pages/Privacy'
|
||||
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 { initGA, initGTM } from './utils/analytics-config'
|
||||
|
||||
const router = createBrowserRouter([{
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index:true, element:<Home/> },
|
||||
{ path:'about', element:<About/> },
|
||||
{ path:'services', element:<Services/> },
|
||||
{ path:'resources', element:<Resources/> },
|
||||
{ path:'prayer-requests', element:<PrayerRequests/> },
|
||||
{ path:'calendar', element:<Calendar/> },
|
||||
{ path:'beliefs', element:<Beliefs/> },
|
||||
{ path:'contact', element:<Contact/> },
|
||||
{ path:'privacy', element:<Privacy/> },
|
||||
{ path:'terms', element:<Terms/> }
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: 'about', element: <About /> },
|
||||
{ path: 'services', element: <Services /> },
|
||||
{ path: 'resources', element: <Resources /> },
|
||||
{ path: 'prayer-requests', element: <PrayerRequests /> },
|
||||
{ path: 'calendar', element: <Calendar /> },
|
||||
{ path: 'beliefs', element: <Beliefs /> },
|
||||
{ path: 'contact', element: <Contact /> },
|
||||
{ path: 'privacy', element: <Privacy /> },
|
||||
{ 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
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { Link } from 'react-router-dom'
|
||||
import StaticMap from '../components/StaticMap'
|
||||
|
||||
export default function About() {
|
||||
export default function About(){
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
|
|
@ -12,71 +11,105 @@
|
|||
<meta name="description" content="Learn about Annaville SDA Church in Corpus Christi, Texas. Our mission, location, and service times." />
|
||||
</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">
|
||||
<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="space-y-10">
|
||||
<div>
|
||||
<h2 className="font-heading text-h2 mb-10">Our Mission</h2>
|
||||
<p className="text-body mb-12">
|
||||
To know Jesus, grow in faith, and serve our community.
|
||||
<h2 className="font-heading text-h2 mb-4">Our Mission</h2>
|
||||
<p className="text-body text-muted mb-6">
|
||||
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 className="text-body mb-12">
|
||||
We are a welcoming Seventh-day Adventist congregation in Annaville.
|
||||
<p className="text-body text-muted">
|
||||
You will find vibrant worship, thoughtful Bible study, and many ways to connect through ministries for
|
||||
children, youth, adults, and families.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 className="font-heading text-h3 mb-8">Service Times</h3>
|
||||
<div className="space-y-8 mb-12">
|
||||
<div className="p-8 bg-sand rounded-xl">
|
||||
<h4 className="font-semibold text-ink mb-3">Sabbath School</h4>
|
||||
<p className="text-body">Begins at 9:30 a.m. each Saturday</p>
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-heading text-h3">Weekly Service Times</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-6 bg-sand/60 rounded-xl border border-subtle">
|
||||
<p className="font-semibold text-ink">Sabbath School</p>
|
||||
<p className="text-sm text-muted">Saturdays at 9:30 AM</p>
|
||||
</div>
|
||||
<div className="p-8 bg-sand rounded-xl">
|
||||
<h4 className="font-semibold text-ink mb-3">Divine Worship</h4>
|
||||
<p className="text-body">Begins at 11:00 a.m. each Saturday</p>
|
||||
<div className="p-6 bg-sand/60 rounded-xl border border-subtle">
|
||||
<p className="font-semibold text-ink">Divine Worship</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 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>
|
||||
<h2 className="font-heading text-h2 mb-10">Location</h2>
|
||||
<p className="text-body mb-10">
|
||||
We are located at 2710 Violet Road in Corpus Christi, Texas.
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white rounded-2xl shadow-level1 overflow-hidden">
|
||||
<StaticMap />
|
||||
</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 className="text-body mb-12">
|
||||
Click on the link below to see a map to our church location.
|
||||
</p>
|
||||
|
||||
<div className="space-y-8 mb-12">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<a
|
||||
href="https://maps.google.com/?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410"
|
||||
target="_blank"
|
||||
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
|
||||
href="https://maps.google.com/directions?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-outline block text-center"
|
||||
className="btn-outline flex-1 text-center"
|
||||
>
|
||||
Get Driving Directions
|
||||
Get Directions
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StaticMap />
|
||||
<p className="text-sm text-muted">
|
||||
Need a ride? Contact the church office and ask about transportation assistance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { 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() {
|
||||
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 (
|
||||
<>
|
||||
<SEOHead
|
||||
|
|
@ -26,9 +52,31 @@ export default function Calendar() {
|
|||
</h2>
|
||||
|
||||
<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">
|
||||
No upcoming events at this time. Please check back later for updates.
|
||||
</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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
|
||||
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 { slug } = useParams()
|
||||
const e = events.find(x => x.slug === slug)
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4001').replace(/\/$/, '')
|
||||
|
||||
useEffect(()=>{ if (e) track(ga.EVENT_DETAILS_VIEW,{slug:e.slug}) },[slug])
|
||||
|
||||
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) => {
|
||||
function parseTime(timeStr) {
|
||||
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'
|
||||
|
||||
let hours = parseInt(match[1])
|
||||
let hours = parseInt(match[1], 10)
|
||||
const minutes = match[2]
|
||||
const period = match[3].toUpperCase()
|
||||
|
||||
|
|
@ -28,34 +20,249 @@ export default function EventDetail(){
|
|||
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 [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')
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,209 @@
|
|||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import events from '../data/events.json'
|
||||
import { Link } from 'react-router-dom'
|
||||
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(){
|
||||
const [active, setActive] = useState('This Month')
|
||||
function isSameMonth(dateStr, baseDate) {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date)) return false
|
||||
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>
|
||||
<div className="container">
|
||||
<h1 className="font-heading text-display30 mb-10">Events</h1>
|
||||
<div className="flex gap-6 mb-12 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f} className="chip" aria-pressed={active===f} onClick={()=>setActive(f)}>{f}</button>
|
||||
))}
|
||||
|
||||
<section className="bg-gradient-to-r from-primary/10 via-sand/20 to-white py-16 md:py-20">
|
||||
<div className="container grid gap-10 lg:grid-cols-[1.6fr,1fr] items-center">
|
||||
<div className="space-y-6">
|
||||
<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 className="grid gap-10 md:grid-cols-3">
|
||||
{events.map(e => <EventCard key={e.slug} e={e} />)}
|
||||
<div>
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,39 @@
|
|||
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { TextHero } from '../components/Hero'
|
||||
import VisitForm from '../components/VisitForm'
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
<Helmet>
|
||||
|
|
@ -126,10 +152,35 @@ export default function Home() {
|
|||
<div className="text-center">
|
||||
<h2 className="font-heading text-h2 mb-8">Upcoming Events</h2>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white p-12 rounded-xl shadow-sm border border-subtle">
|
||||
<p className="text-body text-muted">
|
||||
<div className="bg-white p-12 rounded-xl shadow-sm border border-subtle text-left">
|
||||
{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.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import ministries from '../data/ministries.json'
|
||||
import events from '../data/events.json'
|
||||
import { useEvents } from '../hooks/useEvents'
|
||||
import LazyImage from '../components/LazyImage'
|
||||
|
||||
export default function MinistryDetail(){
|
||||
export default function MinistryDetail() {
|
||||
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">
|
||||
<div className="container">
|
||||
<div className="text-center py-12">
|
||||
|
|
@ -21,18 +34,13 @@ export default function MinistryDetail(){
|
|||
</div>
|
||||
</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 (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{m.name} - Annaville Seventh-day Adventist Church</title>
|
||||
<meta name="description" content={m.description} />
|
||||
<title>{ministry.name} - Annaville Seventh-day Adventist Church</title>
|
||||
<meta name="description" content={ministry.description} />
|
||||
</Helmet>
|
||||
|
||||
{/* Hero Section */}
|
||||
|
|
@ -41,29 +49,29 @@ export default function MinistryDetail(){
|
|||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<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>
|
||||
<h1 className="font-heading text-h1 mb-6">{m.name}</h1>
|
||||
<p className="text-body text-lg mb-6">{m.description}</p>
|
||||
<h1 className="font-heading text-h1 mb-6">{ministry.name}</h1>
|
||||
<p className="text-body text-lg mb-6">{ministry.description}</p>
|
||||
<div className="flex flex-wrap gap-4 text-muted">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🕒</span>
|
||||
<span>{m.meeting}</span>
|
||||
<span className="text-2xl"><EFBFBD>Y'</span>
|
||||
<span>{ministry.meeting}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📍</span>
|
||||
<span>{m.where}</span>
|
||||
<span className="text-2xl"><EFBFBD>Y"?</span>
|
||||
<span>{ministry.where}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">👥</span>
|
||||
<span>{m.ages}</span>
|
||||
<span className="text-2xl"><EFBFBD>Y'<EFBFBD></span>
|
||||
<span>{ministry.ages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<LazyImage
|
||||
src={m.image}
|
||||
alt={`${m.name} at Annaville SDA Church`}
|
||||
src={ministry.image}
|
||||
alt={`${ministry.name} at Annaville SDA Church`}
|
||||
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>
|
||||
|
|
@ -83,9 +91,9 @@ export default function MinistryDetail(){
|
|||
<div>
|
||||
<h2 className="font-heading text-h2 mb-6">What We Do</h2>
|
||||
<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">
|
||||
<span className="text-primary text-xl">✓</span>
|
||||
<span className="text-primary text-xl"><EFBFBD>o"</span>
|
||||
<span className="text-body">{activity}</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -97,15 +105,15 @@ export default function MinistryDetail(){
|
|||
<div>
|
||||
<h2 className="font-heading text-h2 mb-6">Upcoming Events</h2>
|
||||
<div className="grid gap-4">
|
||||
{relatedEvents.map(e => (
|
||||
{relatedEvents.map(event => (
|
||||
<Link
|
||||
key={e.slug}
|
||||
to={`/events/${e.slug}`}
|
||||
key={event.slug}
|
||||
to={`/events/${event.slug}`}
|
||||
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>
|
||||
<p className="text-muted mb-2">{e.date} • {e.time}</p>
|
||||
<p className="text-body">{e.description}</p>
|
||||
<h3 className="font-heading text-h3 mb-2">{event.title}</h3>
|
||||
<p className="text-muted mb-2">{event.date} - {event.time}</p>
|
||||
<p className="text-body">{event.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -116,12 +124,12 @@ export default function MinistryDetail(){
|
|||
<div>
|
||||
<h2 className="font-heading text-h2 mb-6">Frequently Asked Questions</h2>
|
||||
<div className="space-y-4">
|
||||
{m.faq.map((item, index) => (
|
||||
{ministry.faq.map((item, index) => (
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</summary>
|
||||
<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>
|
||||
<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>
|
||||
<h4 className="font-medium text-ink mb-2">Contact</h4>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`mailto:${m.contact.email}`}
|
||||
href={`mailto:${ministry.contact.email}`}
|
||||
className="block text-primary hover:text-primaryHover transition-colors"
|
||||
>
|
||||
📧 {m.contact.email}
|
||||
<EFBFBD>Y"<EFBFBD> {ministry.contact.email}
|
||||
</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"
|
||||
>
|
||||
📞 {m.contact.phone}
|
||||
<EFBFBD>Y"z {ministry.contact.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-ink mb-2">Meeting Details</h4>
|
||||
<p className="text-body">{m.meeting}</p>
|
||||
<p className="text-muted text-sm">{m.where}</p>
|
||||
<p className="text-body">{ministry.meeting}</p>
|
||||
<p className="text-muted text-sm">{ministry.where}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
|
|
@ -182,7 +190,7 @@ export default function MinistryDetail(){
|
|||
to="/ministries"
|
||||
className="block text-primary hover:text-primaryHover transition-colors"
|
||||
>
|
||||
← Back to All Ministries
|
||||
<EFBFBD><EFBFBD>? Back to All Ministries
|
||||
</Link>
|
||||
<Link
|
||||
to="/events"
|
||||
|
|
@ -204,16 +212,16 @@ export default function MinistryDetail(){
|
|||
<h3 className="font-heading text-h3 mb-4">Other Ministries</h3>
|
||||
<div className="space-y-3">
|
||||
{ministries
|
||||
.filter(ministry => ministry.slug !== m.slug)
|
||||
.filter(other => other.slug !== ministry.slug)
|
||||
.slice(0, 3)
|
||||
.map(ministry => (
|
||||
.map(other => (
|
||||
<Link
|
||||
key={ministry.slug}
|
||||
to={`/ministries/${ministry.slug}`}
|
||||
key={other.slug}
|
||||
to={`/ministries/${other.slug}`}
|
||||
className="block p-3 bg-sand rounded-lg hover:bg-sand/80 transition-colors"
|
||||
>
|
||||
<h4 className="font-medium text-ink">{ministry.name}</h4>
|
||||
<p className="text-muted text-sm">{ministry.meeting}</p>
|
||||
<h4 className="font-medium text-ink">{other.name}</h4>
|
||||
<p className="text-muted text-sm">{other.meeting}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,131 +1,132 @@
|
|||
import React from 'react'
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
export default function Services() {
|
||||
const services = [
|
||||
const services = [
|
||||
{
|
||||
title: "Sabbath School",
|
||||
time: "9:30 AM",
|
||||
day: "Saturday",
|
||||
description: "Interactive Bible study and discussion groups for all ages. Join us for meaningful conversations about Scripture and practical Christian living.",
|
||||
icon: "📖",
|
||||
color: "from-blue-500 to-blue-600"
|
||||
title: 'Sabbath School',
|
||||
image: '/assets/family_entry.png',
|
||||
time: '9:30 AM',
|
||||
day: 'Saturday',
|
||||
description: 'Interactive Bible study and discussion groups for all ages. Join us for meaningful conversations about Scripture and practical Christian living.',
|
||||
tagColor: 'bg-blue-100 text-blue-800'
|
||||
},
|
||||
{
|
||||
title: "Divine Worship",
|
||||
time: "11:00 AM",
|
||||
day: "Saturday",
|
||||
description: "Our main worship service featuring inspiring music, prayer, and biblical messages that speak to everyday life and spiritual growth.",
|
||||
icon: "⛪",
|
||||
color: "from-primary to-primaryDeep"
|
||||
title: 'Divine Worship',
|
||||
image: '/assets/speeking.png',
|
||||
time: '11:00 AM',
|
||||
day: 'Saturday',
|
||||
description: 'Our main worship service featuring inspiring music, prayer, and biblical messages that speak to everyday life and spiritual growth.',
|
||||
tagColor: 'bg-primary/15 text-primary'
|
||||
},
|
||||
{
|
||||
title: "Potluck Fellowship",
|
||||
time: "After Worship",
|
||||
day: "Saturday",
|
||||
description: "Stay after the service for a delicious meal and warm fellowship. Visitors are especially welcome to join us for this time of community.",
|
||||
icon: "🍽️",
|
||||
color: "from-green-500 to-green-600"
|
||||
title: 'Potluck Fellowship',
|
||||
image: '/assets/potluck.png',
|
||||
time: 'After Worship',
|
||||
day: 'Saturday',
|
||||
description: 'Stay after the service for a delicious meal and warm fellowship. Visitors are especially welcome to join us for this time of community.',
|
||||
tagColor: 'bg-green-100 text-green-700'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
const additionalInfo = [
|
||||
const additionalInfo = [
|
||||
{
|
||||
title: "Church Bus Service",
|
||||
description: "Transportation available for those who need assistance getting to church. Please contact us to arrange pickup.",
|
||||
icon: "🚌"
|
||||
title: 'Church Bus Service',
|
||||
description: 'Transportation is available for those who need assistance getting to church. Please contact us to arrange pickup.',
|
||||
icon: '🚌'
|
||||
},
|
||||
{
|
||||
title: "Family-Friendly Environment",
|
||||
description: "Children are welcome in all our services. We also offer age-appropriate activities and care during worship times.",
|
||||
icon: "👨👩👧👦"
|
||||
title: 'Family-Friendly Environment',
|
||||
description: 'Children are welcome in all our services. We also offer age-appropriate activities and care during worship times.',
|
||||
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.",
|
||||
icon: "🤝"
|
||||
icon: '🤝'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
export default function Services(){
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<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 name="keywords" content="worship services, Sabbath School, Divine Worship, fellowship, church services, Annaville SDA" />
|
||||
<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
|
||||
name="keywords"
|
||||
content="worship services, Sabbath School, Divine Worship, fellowship, church services, Annaville SDA"
|
||||
/>
|
||||
</Helmet>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-primary to-primaryDeep text-white py-20">
|
||||
<div className="container">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="font-heading text-h1 mb-6">Worship Services</h1>
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h1 className="font-heading text-h1">Worship Services</h1>
|
||||
<p className="text-xl text-white/90 leading-relaxed">
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Services Section */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="font-heading text-h2 mb-6">Weekly Services</h2>
|
||||
<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
|
||||
in learning more about practical Christian living from the Word of God.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-start">
|
||||
{/* Services Cards */}
|
||||
<div className="space-y-8">
|
||||
{services.map((service, index) => (
|
||||
<div key={index} className="group">
|
||||
<div className="bg-white p-8 rounded-xl shadow-sm border border-subtle hover:shadow-md transition-all duration-300">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`text-4xl p-3 rounded-lg bg-gradient-to-br ${service.color} text-white`}>
|
||||
{service.icon}
|
||||
{services.map(service => (
|
||||
<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="flex gap-6 items-start">
|
||||
<div className="w-28 h-28 rounded-xl overflow-hidden border border-subtle shadow-sm">
|
||||
<img
|
||||
src={service.image}
|
||||
alt={`${service.title} illustration`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-heading text-h3 text-ink mb-3">{service.title}</h3>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<span className="inline-flex items-center px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<h3 className="font-heading text-h3 text-ink mb-2">{service.title}</h3>
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-muted text-sm">{service.day}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Image Section */}
|
||||
<div className="relative">
|
||||
<div className="sticky top-8">
|
||||
<img
|
||||
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"
|
||||
/>
|
||||
<div className="mt-6 p-6 bg-sand rounded-xl">
|
||||
<h3 className="font-heading text-h3 mb-4 text-primary">Plan Your Visit</h3>
|
||||
<p className="text-body text-muted mb-4">
|
||||
We're located at 2710 Violet Road in Corpus Christi.
|
||||
Parking is available on-site, and our greeters will help you find your way.
|
||||
We're located at 2710 Violet Road in Corpus Christi. Parking is available on-site, and our greeters will help you find your way.
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="btn w-full"
|
||||
>
|
||||
<a href="/contact" className="btn w-full">
|
||||
Get Directions
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -136,7 +137,6 @@ export default function Services() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Additional Information Section */}
|
||||
<section className="section bg-sand">
|
||||
<div className="container">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
|
@ -148,13 +148,13 @@ export default function Services() {
|
|||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{additionalInfo.map((info, index) => (
|
||||
<div key={index} className="text-center">
|
||||
{additionalInfo.map(item => (
|
||||
<div key={item.title} className="text-center">
|
||||
<div className="bg-white p-8 rounded-xl shadow-sm border border-subtle">
|
||||
<div className="text-4xl mb-4">{info.icon}</div>
|
||||
<h3 className="font-heading text-h3 mb-4 text-primary">{info.title}</h3>
|
||||
<div className="text-3xl mb-4" aria-hidden="true">{item.icon}</div>
|
||||
<h3 className="font-heading text-h3 mb-4 text-primary">{item.title}</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
{info.description}
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -164,27 +164,19 @@ export default function Services() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action Section */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<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>
|
||||
<p className="text-body text-muted mb-8">
|
||||
We'd love to welcome you this Saturday! Have questions about our services or need assistance?
|
||||
Don't hesitate to reach out.
|
||||
We'd love to welcome you this Saturday! Have questions about our services or need assistance? Don't hesitate to reach out.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/contact"
|
||||
className="btn"
|
||||
>
|
||||
<a href="/contact" className="btn">
|
||||
Contact Us
|
||||
</a>
|
||||
<a
|
||||
href="/visit"
|
||||
className="btn-outline"
|
||||
>
|
||||
<a href="/visit" className="btn-outline">
|
||||
Plan Your Visit
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||