fertiges design
This commit is contained in:
64
src/components/AddToCalendar.jsx
Normal file
64
src/components/AddToCalendar.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import { googleCalendarUrl, downloadICS } from '../utils/calendar'
|
||||
import { track, events as ga } from '../utils/analytics'
|
||||
|
||||
export default function AddToCalendar() {
|
||||
const handleAppleCalendar = () => {
|
||||
const now = new Date()
|
||||
const nextSat = new Date(now)
|
||||
const day = now.getDay()
|
||||
const diff = (6 - day + 7) % 7 || 7
|
||||
nextSat.setDate(now.getDate() + diff)
|
||||
const start = new Date(Date.UTC(nextSat.getFullYear(), nextSat.getMonth(), nextSat.getDate(), 14, 30)) // approx 9:30 CT
|
||||
const end = new Date(Date.UTC(nextSat.getFullYear(), nextSat.getMonth(), nextSat.getDate(), 16, 0))
|
||||
|
||||
downloadICS({
|
||||
title: 'Sabbath School',
|
||||
details: 'Weekly Sabbath School at Annaville SDA Church.',
|
||||
location: '2710 Violet Rd, Corpus Christi, TX 78410',
|
||||
start,
|
||||
end,
|
||||
filename: 'sabbath-school.ics'
|
||||
})
|
||||
|
||||
track(ga.CTA_CLICK, { label: 'add_to_apple_calendar' })
|
||||
}
|
||||
|
||||
const handleGoogleCalendar = () => {
|
||||
const nextSaturday = new Date()
|
||||
const day = nextSaturday.getDay()
|
||||
const diff = (6 - day + 7) % 7 || 7
|
||||
nextSaturday.setDate(nextSaturday.getDate() + diff)
|
||||
const end = new Date(nextSaturday.getTime() + 90 * 60 * 1000) // 90 minutes later
|
||||
|
||||
const url = googleCalendarUrl({
|
||||
title: 'Sabbath School',
|
||||
details: 'Weekly Sabbath School at Annaville SDA Church.',
|
||||
location: '2710 Violet Rd, Corpus Christi, TX 78410',
|
||||
start: nextSaturday,
|
||||
end: end
|
||||
})
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
track(ga.CTA_CLICK, { label: 'add_to_google_calendar' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<button
|
||||
className="chip bg-primary text-white hover:bg-primaryHover focus-ring"
|
||||
onClick={handleAppleCalendar}
|
||||
aria-label="Add Sabbath School to Apple Calendar"
|
||||
>
|
||||
📅 Add to Apple Calendar
|
||||
</button>
|
||||
<button
|
||||
className="chip bg-primaryDeep text-white hover:bg-primaryHover focus-ring"
|
||||
onClick={handleGoogleCalendar}
|
||||
aria-label="Add Sabbath School to Google Calendar"
|
||||
>
|
||||
📅 Add to Google Calendar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
src/components/Cards.jsx
Normal file
159
src/components/Cards.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
|
||||
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()
|
||||
|
||||
// Map specific event names to images
|
||||
const getEventImage = (title) => {
|
||||
const lowerTitle = title.toLowerCase()
|
||||
if (lowerTitle.includes('community sabbath lunch')) {
|
||||
return '/assets/potluck.png'
|
||||
}
|
||||
if (lowerTitle.includes('youth vespers')) {
|
||||
return '/assets/youth_vespers.png'
|
||||
}
|
||||
if (lowerTitle.includes('neighborhood food drive')) {
|
||||
return '/assets/family_entry.png'
|
||||
}
|
||||
if (lowerTitle.includes('potluck') || lowerTitle.includes('lunch') || lowerTitle.includes('dinner')) {
|
||||
return '/assets/potluck.png'
|
||||
}
|
||||
if (lowerTitle.includes('family') || lowerTitle.includes('community')) {
|
||||
return '/assets/family_entry.png'
|
||||
}
|
||||
if (lowerTitle.includes('welcome') || lowerTitle.includes('committee')) {
|
||||
return '/assets/welcome_commite.png'
|
||||
}
|
||||
// Default event image
|
||||
return '/assets/potluck.png'
|
||||
}
|
||||
|
||||
return (
|
||||
<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)}
|
||||
alt={`${e.title} event`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
<p className="mt-6 text-body mb-8">{e.description}</p>
|
||||
<div className="mt-auto pt-8">
|
||||
<Link
|
||||
to={`/events/${e.slug}`}
|
||||
className="btn-outline"
|
||||
aria-label={`Event details: ${e.title} on ${mon} ${day}`}
|
||||
>
|
||||
Details — {e.title}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function MinistryCard({ m }){
|
||||
// Map specific ministry names to images
|
||||
const getMinistryImage = (name) => {
|
||||
const lowerName = name.toLowerCase()
|
||||
if (lowerName === 'children\'s ministry') {
|
||||
return '/assets/children_ministry_craft.png'
|
||||
}
|
||||
if (lowerName === 'youth ministry') {
|
||||
return '/assets/youth_vespers.png'
|
||||
}
|
||||
if (lowerName === 'adult sabbath school') {
|
||||
return '/assets/speeking.png'
|
||||
}
|
||||
if (lowerName === 'women\'s ministry') {
|
||||
return '/assets/pray_heart.png'
|
||||
}
|
||||
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'
|
||||
}
|
||||
if (lowerName.includes('youth') || lowerName.includes('vespers')) {
|
||||
return '/assets/youth_vespers.png'
|
||||
}
|
||||
if (lowerName.includes('welcome') || lowerName.includes('committee')) {
|
||||
return '/assets/welcome_commite.png'
|
||||
}
|
||||
if (lowerName.includes('prayer') || lowerName.includes('pray')) {
|
||||
return '/assets/pray_heart.png'
|
||||
}
|
||||
// Default ministry image
|
||||
return '/assets/welcome_commite.png'
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="card p-10 flex flex-col">
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={getMinistryImage(m.name)}
|
||||
alt={`${m.name} ministry`}
|
||||
className="w-full h-80 object-cover rounded-lg mb-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h3 className="font-heading text-h3">{m.name}</h3>
|
||||
</div>
|
||||
<div className="text-muted text-small mb-3">{m.meeting}</div>
|
||||
<div className="text-small mb-8">Leader: {m.leader}</div>
|
||||
<div className="mt-auto pt-8">
|
||||
<Link
|
||||
to={`/ministries/${m.slug}`}
|
||||
className="btn-outline"
|
||||
aria-label={`Explore ${m.name} ministry`}
|
||||
>
|
||||
Explore {m.name}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function SermonCard({ s }){
|
||||
return (
|
||||
<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
|
||||
src="/assets/speeking.png"
|
||||
alt={`Sermon by ${s.speaker}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
<p className="mt-6 text-body mb-8">{s.summary}</p>
|
||||
<div className="mt-auto pt-8">
|
||||
<Link
|
||||
to={`/sermons/${s.slug}`}
|
||||
className="btn-outline"
|
||||
aria-label={`Watch or listen to ${s.title} by ${s.speaker}`}
|
||||
>
|
||||
Watch/Listen — {s.title}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
95
src/components/Footer.jsx
Normal file
95
src/components/Footer.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Footer(){
|
||||
const year = new Date().getFullYear()
|
||||
return (
|
||||
<footer id="footer" role="contentinfo" className="bg-sand border-t border-subtle mt-12">
|
||||
<div className="container grid md:grid-cols-3 gap-8 py-16">
|
||||
<div>
|
||||
<h3 className="font-heading text-h3 mb-4">Annaville Seventh-day Adventist Church</h3>
|
||||
<p className="text-body mb-2">2710 Violet Rd<br/>Corpus Christi, TX 78410</p>
|
||||
<p className="mb-2">
|
||||
<a href="tel:+13612415501" className="text-primary underline hover:text-primaryHover">
|
||||
Call (361) 241-5501
|
||||
</a>
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<a href="https://maps.google.com/?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410"
|
||||
className="text-primary underline hover:text-primaryHover">
|
||||
Get Directions
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-muted text-small">Sabbath School 9:30 AM • Divine Worship 11:00 AM</p>
|
||||
|
||||
{/* Leadership Information */}
|
||||
<div className="mt-6 text-small text-muted">
|
||||
<p><strong>Pastor:</strong> Matt McMearty</p>
|
||||
<p><strong>Head Elder:</strong> Regena Simms</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-heading text-h3 mb-4">Quick Links</h3>
|
||||
<ul className="space-y-3">
|
||||
<li><Link to="/about" className="text-body hover:text-primary transition-colors">About Us</Link></li>
|
||||
<li><Link to="/services" className="text-body hover:text-primary transition-colors">Services</Link></li>
|
||||
<li><Link to="/resources" className="text-body hover:text-primary transition-colors">Resources</Link></li>
|
||||
<li><Link to="/prayer-requests" className="text-body hover:text-primary transition-colors">Prayer Requests</Link></li>
|
||||
<li><Link to="/calendar" className="text-body hover:text-primary transition-colors">Calendar</Link></li>
|
||||
<li><Link to="/beliefs" className="text-body hover:text-primary transition-colors">Our Beliefs</Link></li>
|
||||
<li><Link to="/contact" className="text-body hover:text-primary transition-colors">Contact</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-heading text-h3 mb-4">Newsletter</h3>
|
||||
<p className="text-body mb-4">
|
||||
If you would like to receive our newsletter please fill out the form below.
|
||||
</p>
|
||||
<form className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="footer-newsletter-name" className="block text-sm font-medium text-ink mb-2">
|
||||
Name:
|
||||
</label>
|
||||
<input
|
||||
id="footer-newsletter-name"
|
||||
type="text"
|
||||
className="w-full border border-subtle rounded-lg px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="footer-newsletter-email" className="block text-sm font-medium text-ink mb-2">
|
||||
Email Address:
|
||||
</label>
|
||||
<input
|
||||
id="footer-newsletter-email"
|
||||
type="email"
|
||||
className="w-full border border-subtle rounded-lg px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn w-full">
|
||||
Subscribe to Newsletter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-subtle py-6">
|
||||
<div className="container flex flex-wrap items-center gap-4 justify-between">
|
||||
<div className="text-small text-muted">
|
||||
© {year} Annaville Seventh-day Adventist Church. All rights reserved.
|
||||
</div>
|
||||
<div className="text-small flex gap-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
93
src/components/Header.jsx
Normal file
93
src/components/Header.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import { track, events } from '../utils/analytics'
|
||||
|
||||
const navItems = [
|
||||
{ to:'/about', label:'ABOUT US' },
|
||||
{ to:'/services', label:'SERVICES' },
|
||||
{ to:'/resources', label:'RESOURCES' },
|
||||
{ to:'/prayer-requests', label:'PRAYER REQUESTS' },
|
||||
{ to:'/calendar', label:'CALENDAR' },
|
||||
{ to:'/beliefs', label:'OUR BELIEFS' },
|
||||
]
|
||||
|
||||
export default function Header(){
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<header role="banner" className="z-50 bg-white/90 backdrop-blur border-b border-subtle">
|
||||
<nav id="navigation" aria-label="Main navigation" className="container flex items-center justify-between h-20">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="flex items-center gap-4 font-heading text-lg font-semibold tracking-tight text-ink leading-tight">
|
||||
<img
|
||||
src="/assets/favicon.ico.gif"
|
||||
alt="Annaville SDA Church Logo"
|
||||
className="w-20 h-20 rounded-lg"
|
||||
/>
|
||||
<span>
|
||||
Annaville Seventh-day<br />Adventist Church
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({isActive})=>`text-sm font-medium transition-colors ${isActive?'text-primary font-semibold':'text-ink hover:text-primary'}`}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<Link
|
||||
to="/contact"
|
||||
className="btn text-sm px-4 py-2"
|
||||
onClick={()=>track(events.CTA_CLICK,{label:'contact'})}
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Open menu"
|
||||
className="md:hidden btn-ghost"
|
||||
onClick={()=>setOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="text-3xl">☰</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{open && (
|
||||
<div role="dialog" aria-modal="true" className="md:hidden fixed inset-0 bg-black/30" onClick={()=>setOpen(false)}>
|
||||
<div className="absolute top-0 right-0 w-[80%] h-full bg-white shadow-level1 p-8" onClick={e=>e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center mb-12">
|
||||
<span className="font-heading text-h2">Menu</span>
|
||||
<button className="btn-ghost" onClick={()=>setOpen(false)} aria-label="Close menu">✕</button>
|
||||
</div>
|
||||
<ul className="space-y-6">
|
||||
{navItems.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
onClick={()=>setOpen(false)}
|
||||
className={({isActive})=>`block py-5 text-body ${isActive?'text-primary font-semibold':'text-ink hover:text-primary'}`}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="btn w-full"
|
||||
onClick={()=>{setOpen(false); track(events.CTA_CLICK,{label:'contact'})}}
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
95
src/components/Hero.jsx
Normal file
95
src/components/Hero.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { track, events } from '../utils/analytics'
|
||||
import LazyImage from './LazyImage'
|
||||
|
||||
export function TextHero(){
|
||||
const scrollToServiceTimes = () => {
|
||||
const element = document.getElementById('service-times')
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
track(events.CTA_CLICK, { label: 'service_times' })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative bg-gradient-to-br from-sand to-surface">
|
||||
<div className="container py-20">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Content on the Left */}
|
||||
<div className="order-1 lg:order-1">
|
||||
<h1 className="font-heading text-h1 mb-8">Welcome to Annaville Seventh-day Adventist Church</h1>
|
||||
<p className="text-ink text-body mb-10">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.</p>
|
||||
<div className="mb-10 text-muted text-small">Sabbath School 9:30 AM • Divine Worship 11:00 AM • 2710 Violet Rd</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Link
|
||||
to="/visit"
|
||||
className="btn"
|
||||
onClick={() => track(events.CTA_CLICK, { label: 'plan_visit' })}
|
||||
data-cta="plan_visit"
|
||||
>
|
||||
Plan Your Visit
|
||||
</Link>
|
||||
<button
|
||||
className="btn-outline"
|
||||
onClick={scrollToServiceTimes}
|
||||
data-cta="service_times"
|
||||
>
|
||||
See Service Times
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Hero Image on the Right */}
|
||||
<div className="order-2 lg:order-2">
|
||||
<LazyImage
|
||||
src="/assets/hero_golden_hour.png"
|
||||
alt="Annaville SDA Church building during golden hour with people walking on the path"
|
||||
className="w-full h-[400px] object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Safe photo hero variant with dim overlay; H1 remains text-first
|
||||
export function PhotoHeroSafe(){
|
||||
const scrollToServiceTimes = () => {
|
||||
const element = document.getElementById('service-times')
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
track(events.CTA_CLICK, { label: 'service_times' })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative">
|
||||
<LazyImage src="/assets/hero_golden_hour.png" alt="Annaville SDA Church building during golden hour" className="absolute right-0 top-8 w-1/4 h-full" />
|
||||
<div className="absolute right-0 top-8 w-1/4 h-full bg-black/40" aria-hidden="true"></div>
|
||||
<div className="relative container py-40 text-white">
|
||||
<h1 className="font-heading text-h1 mb-8">Welcome to Annaville Seventh-day Adventist Church</h1>
|
||||
<p className="text-white text-body mb-10">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.</p>
|
||||
<div className="mb-10 text-white/90 text-small">Sabbath School 9:30 AM • Divine Worship 11:00 AM • 2710 Violet Rd</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Link
|
||||
to="/visit"
|
||||
className="btn"
|
||||
data-cta="plan_visit"
|
||||
>
|
||||
Plan Your Visit
|
||||
</Link>
|
||||
<button
|
||||
className="btn-outline text-white border-white hover:bg-white/10"
|
||||
onClick={scrollToServiceTimes}
|
||||
data-cta="service_times"
|
||||
>
|
||||
See Service Times
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
82
src/components/LazyImage.jsx
Normal file
82
src/components/LazyImage.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
|
||||
export default function LazyImage({
|
||||
src,
|
||||
alt,
|
||||
className = "",
|
||||
placeholder = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Crect width='400' height='300' fill='%23f3f4f6'/%3E%3C/svg%3E",
|
||||
...props
|
||||
}) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const imgRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '50px 0px',
|
||||
threshold: 0.01
|
||||
}
|
||||
)
|
||||
|
||||
if (imgRef.current) {
|
||||
observer.observe(imgRef.current)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoaded(true)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
// Fallback to placeholder if image fails to load
|
||||
setIsLoaded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={imgRef}
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{/* Placeholder */}
|
||||
<img
|
||||
src={placeholder}
|
||||
alt=""
|
||||
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
||||
isLoaded ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Actual image */}
|
||||
{isInView && (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
loading="lazy"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{!isLoaded && isInView && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/components/MobileStickyBar.jsx
Normal file
43
src/components/MobileStickyBar.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { track, events } from '../utils/analytics'
|
||||
|
||||
export default function MobileStickyBar(){
|
||||
const scrollToVisitForm = () => {
|
||||
const element = document.getElementById('visit-form')
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
track(events.CTA_CLICK, { label: 'plan_visit' })
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 flex justify-around border-t bg-surface p-2 md:hidden">
|
||||
<a
|
||||
href="tel:+13612415501"
|
||||
className="flex flex-col items-center gap-1 text-sm focus-ring"
|
||||
aria-label="Call the church"
|
||||
onClick={() => track(events.CLICK_TO_CALL, { tel: '3612415501' })}
|
||||
data-cta="click_to_call"
|
||||
>
|
||||
📞 Call
|
||||
</a>
|
||||
<a
|
||||
href="https://maps.apple.com/?q=2710+Violet+Rd+Corpus+Christi"
|
||||
className="flex flex-col items-center gap-1 text-sm focus-ring"
|
||||
onClick={() => track(events.OPEN_DIRECTIONS, { provider: 'maps' })}
|
||||
data-cta="open_directions"
|
||||
>
|
||||
🧭 Directions
|
||||
</a>
|
||||
<button
|
||||
className="flex flex-col items-center gap-1 text-sm focus-ring"
|
||||
onClick={scrollToVisitForm}
|
||||
data-cta="plan_visit"
|
||||
>
|
||||
📝 Plan Visit
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
103
src/components/SEOHead.jsx
Normal file
103
src/components/SEOHead.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
export default function SEOHead({
|
||||
title,
|
||||
description,
|
||||
keywords = '',
|
||||
image = '/assets/og-image.jpg',
|
||||
url = '',
|
||||
type = 'website',
|
||||
publishedAt = '',
|
||||
modifiedAt = '',
|
||||
author = 'Annaville Seventh-day Adventist Church'
|
||||
}) {
|
||||
const siteUrl = 'https://annavillesda.org' // Replace with your actual domain
|
||||
const fullUrl = url ? `${siteUrl}${url}` : siteUrl
|
||||
const fullImageUrl = image.startsWith('http') ? image : `${siteUrl}${image}`
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Basic Meta Tags */}
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords} />
|
||||
<meta name="author" content={author} />
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={fullUrl} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={fullImageUrl} />
|
||||
<meta property="og:site_name" content="Annaville Seventh-day Adventist Church" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content={fullUrl} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={fullImageUrl} />
|
||||
|
||||
{/* Additional Meta Tags */}
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#7E0F14" />
|
||||
|
||||
{/* Canonical URL */}
|
||||
<link rel="canonical" href={fullUrl} />
|
||||
|
||||
{/* Favicon */}
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
|
||||
{/* Manifest */}
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
{/* Article specific meta tags */}
|
||||
{type === 'article' && (
|
||||
<>
|
||||
<meta property="article:published_time" content={publishedAt} />
|
||||
<meta property="article:modified_time" content={modifiedAt} />
|
||||
<meta property="article:author" content={author} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Structured Data */}
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": type === 'article' ? 'Article' : 'WebPage',
|
||||
"name": title,
|
||||
"description": description,
|
||||
"url": fullUrl,
|
||||
"image": fullImageUrl,
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Annaville Seventh-day Adventist Church",
|
||||
"url": siteUrl,
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": `${siteUrl}/logo.png`
|
||||
}
|
||||
},
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage",
|
||||
"@id": fullUrl
|
||||
},
|
||||
...(type === 'article' && {
|
||||
"datePublished": publishedAt,
|
||||
"dateModified": modifiedAt,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": author
|
||||
}
|
||||
})
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
43
src/components/SidebarNav.jsx
Normal file
43
src/components/SidebarNav.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
const navItems = [
|
||||
{ to:'/about', label:'ABOUT US' },
|
||||
{ to:'/services', label:'SERVICES' },
|
||||
{ to:'/resources', label:'RESOURCES' },
|
||||
{ to:'/prayer-requests', label:'PRAYER REQUESTS' },
|
||||
{ to:'/calendar', label:'CALENDAR' },
|
||||
{ to:'/beliefs', label:'OUR BELIEFS' },
|
||||
]
|
||||
|
||||
export default function SidebarNav() {
|
||||
return (
|
||||
<div className="bg-yellow-100 p-4 min-h-screen w-64">
|
||||
<nav className="space-y-2">
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({isActive}) => `
|
||||
block w-full text-left px-4 py-3 text-sm font-bold uppercase
|
||||
border border-gray-300 bg-gray-200 hover:bg-gray-300
|
||||
${isActive ? 'bg-gray-400' : ''}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6">
|
||||
<button className="w-full px-4 py-2 text-sm font-medium text-red-600 bg-yellow-100 border border-red-600 rounded-full hover:bg-red-50">
|
||||
Subscribe to our Newsletter >>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<a href="#" className="text-blue-600 underline text-sm">Message Center</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/StaticMap.jsx
Normal file
31
src/components/StaticMap.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import { track, events as ga } from '../utils/analytics'
|
||||
|
||||
export default function StaticMap() {
|
||||
return (
|
||||
<section aria-labelledby="mapTitle">
|
||||
<h2 id="mapTitle" className="sr-only">Location Map</h2>
|
||||
<div className="rounded-xl shadow-card bg-surface p-8 max-w-xl">
|
||||
<iframe
|
||||
title="Google Map"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="w-full h-[320px] rounded-lg"
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3474.1234567890123!2d-97.3964!3d27.8006!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x8668516b6c8c8c8c%3A0x8c8c8c8c8c8c8c8c!2s2710%20Violet%20Rd%2C%20Corpus%20Christi%2C%20TX%2078410!5e0!3m2!1sen!2sus!4v1234567890123"
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<a
|
||||
className="btn-outline"
|
||||
href="https://maps.google.com/?q=2710+Violet+Rd,+Corpus+Christi,+TX+78410"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => track(ga.CTA_CLICK, { label: 'open_google_maps' })}
|
||||
data-cta="open_google_maps"
|
||||
>
|
||||
Open in Google Maps
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
240
src/components/VisitForm.jsx
Normal file
240
src/components/VisitForm.jsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useState } from 'react'
|
||||
import { track, events as ga } from '../utils/analytics'
|
||||
import { sendVisitRequest } from '../utils/email'
|
||||
|
||||
export default function VisitForm() {
|
||||
const [errors, setErrors] = useState({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitStatus, setSubmitStatus] = useState(null)
|
||||
|
||||
const showError = (field, show) => {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[field]: show
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setSubmitStatus(null)
|
||||
|
||||
const form = e.target
|
||||
let isValid = true
|
||||
|
||||
// Validate name
|
||||
if (!form.name.value.trim()) {
|
||||
showError('name', true)
|
||||
isValid = false
|
||||
} else {
|
||||
showError('name', false)
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if (!form.email.validity.valid) {
|
||||
showError('email', true)
|
||||
isValid = false
|
||||
} else {
|
||||
showError('email', false)
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!form.message.value.trim()) {
|
||||
showError('message', true)
|
||||
isValid = false
|
||||
} else {
|
||||
showError('message', false)
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setIsSubmitting(false)
|
||||
return false
|
||||
}
|
||||
|
||||
// Track form submission
|
||||
track(ga.FORM_SUBMIT, { form_id: 'visit_home' })
|
||||
|
||||
// Prepare form data
|
||||
const formData = {
|
||||
name: form.name.value.trim(),
|
||||
email: form.email.value.trim(),
|
||||
date: form.date.value || null,
|
||||
message: form.message.value.trim(),
|
||||
consent: form.consent.checked
|
||||
}
|
||||
|
||||
try {
|
||||
// Send email
|
||||
const result = await sendVisitRequest(formData)
|
||||
|
||||
if (result.success) {
|
||||
setSubmitStatus('success')
|
||||
form.reset()
|
||||
setErrors({})
|
||||
|
||||
// Show success toast
|
||||
const toast = document.createElement('div')
|
||||
toast.setAttribute('role', 'status')
|
||||
toast.className = 'fixed bottom-6 right-6 bg-green-600 text-white px-4 py-3 rounded-lg shadow z-50'
|
||||
toast.textContent = 'Thanks! We\'ve received your request and will email you soon.'
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast)
|
||||
}
|
||||
}, 5000)
|
||||
} else {
|
||||
setSubmitStatus('error')
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
setSubmitStatus('error')
|
||||
|
||||
// Show error toast
|
||||
const toast = document.createElement('div')
|
||||
toast.setAttribute('role', 'status')
|
||||
toast.className = 'fixed bottom-6 right-6 bg-red-600 text-white px-4 py-3 rounded-lg shadow z-50'
|
||||
toast.textContent = 'Sorry, there was an error. Please try again or contact us directly.'
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast)
|
||||
}
|
||||
}, 5000)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
// Track form start (only once)
|
||||
if (!window.formStartTracked) {
|
||||
track(ga.FORM_START, { form_id: 'visit_home' })
|
||||
window.formStartTracked = true
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id="visit-form"
|
||||
noValidate
|
||||
onSubmit={handleSubmit}
|
||||
aria-describedby="visit-help"
|
||||
onFocus={handleFocus}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-ink mb-3">
|
||||
Your Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
aria-describedby="name-err"
|
||||
className={`w-full border rounded-lg px-6 py-4 focus:ring-2 focus:ring-focus focus:border-transparent ${
|
||||
errors.name ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p id="name-err" className="text-red-600 text-sm mt-3">
|
||||
Please enter your name.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-ink mb-3">
|
||||
Your Email *
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
aria-describedby="email-err"
|
||||
className={`w-full border rounded-lg px-6 py-4 focus:ring-2 focus:ring-focus focus:border-transparent ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Your Email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p id="email-err" className="text-red-600 text-sm mt-3">
|
||||
Please enter a valid email.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-ink mb-3">
|
||||
Visit Date (optional)
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
type="date"
|
||||
name="date"
|
||||
className="w-full border border-gray-300 rounded-lg px-6 py-4 focus:ring-2 focus:ring-focus focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="msg" className="block text-sm font-medium text-ink mb-3">
|
||||
Your Message *
|
||||
</label>
|
||||
<textarea
|
||||
id="msg"
|
||||
name="message"
|
||||
required
|
||||
aria-describedby="msg-err"
|
||||
rows={6}
|
||||
className={`w-full border rounded-lg px-6 py-4 focus:ring-2 focus:ring-focus focus:border-transparent ${
|
||||
errors.message ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Your Message"
|
||||
/>
|
||||
{errors.message && (
|
||||
<p id="msg-err" className="text-red-600 text-sm mt-3">
|
||||
Please add a short message.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
id="consent"
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
className="mt-1 rounded focus:ring-2 focus:ring-focus"
|
||||
/>
|
||||
<label htmlFor="consent" className="text-sm text-ink">
|
||||
I consent to be contacted about my visit.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn w-full"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Submit'}
|
||||
</button>
|
||||
|
||||
{submitStatus === 'error' && (
|
||||
<p className="text-red-600 text-sm">
|
||||
There was an error sending your request. Please try again or contact us directly.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p id="visit-help" className="text-sm text-muted">
|
||||
We'll email directions and parking info within 24 hours.
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user