Files
prayer-app/src/app/page.tsx
T
nmemmert 9f799397c8 Implement cross-device prayer synchronization
- Replace localStorage with Firestore for prayer persistence
- Prayers now sync across all devices when logged in
- Add userId to prayers for proper user association
- Update archive/unarchive to work with Firestore
- Update Firestore rules for user-specific prayer access
- Remove localStorage dependencies
2025-10-22 14:27:39 -04:00

630 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import Login from '@/components/Login';
import Admin from '@/components/Admin';
import { db } from '@/lib/firebase';
import { doc, setDoc, collection, addDoc, getDocs, updateDoc, deleteDoc, query, where } from 'firebase/firestore';
interface Prayer {
id: string;
userId?: string;
date: string;
type: 'prayer' | 'praise';
text: string;
journal: string;
email: string;
prayFor: string;
archived?: boolean;
reminderFrequency?: 'daily' | 'weekly' | 'monthly' | 'never';
includeActiveSummary?: boolean;
}
export default function Home() {
const [prayers, setPrayers] = useState<Prayer[]>([]);
const [form, setForm] = useState({
date: new Date().toISOString().split('T')[0],
type: 'prayer' as 'prayer' | 'praise',
text: '',
journal: '',
email: '',
prayFor: '',
reminderFrequency: 'never' as 'daily' | 'weekly' | 'monthly' | 'never',
includeActiveSummary: false,
});
const [showAdmin, setShowAdmin] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [activeTab, setActiveTab] = useState<'add' | 'active' | 'archived' | 'search'>('add');
const itemsPerPage = 10;
const { user, loading, logout, isAdmin } = useAuth();
const [showLoadingScreen, setShowLoadingScreen] = useState(true);
// Ensure loading screen shows for at least 5 seconds
useEffect(() => {
const timer = setTimeout(() => {
setShowLoadingScreen(false);
}, 5000); // 5 seconds minimum
return () => clearTimeout(timer);
}, []);
// Load prayers from Firestore
const loadPrayers = async () => {
if (!user) return;
try {
const prayersRef = collection(db, 'prayers');
const q = query(prayersRef, where('userId', '==', user.uid));
const querySnapshot = await getDocs(q);
const loadedPrayers: Prayer[] = [];
querySnapshot.forEach((doc) => {
loadedPrayers.push({ id: doc.id, ...doc.data() } as Prayer);
});
setPrayers(loadedPrayers);
} catch (error) {
console.error('Error loading prayers:', error);
}
};
// Load prayers when user changes
useEffect(() => {
if (user) {
loadPrayers();
} else {
setPrayers([]); // Clear prayers when user logs out
}
}, [user]);
// Auto-populate email field with user's email
useEffect(() => {
if (user?.email) {
setForm(prev => ({ ...prev, email: user.email! }));
}
}, [user]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
alert('Please log in to save prayers');
return;
}
try {
const newPrayerData = {
userId: user.uid,
date: form.date,
type: form.type,
text: form.text,
journal: form.journal,
email: form.email,
prayFor: form.prayFor,
reminderFrequency: form.reminderFrequency,
includeActiveSummary: form.includeActiveSummary,
archived: false,
};
// Save to Firestore
const docRef = await addDoc(collection(db, 'prayers'), newPrayerData);
// Add to local state with Firestore ID
const newPrayer: Prayer = {
id: docRef.id,
...newPrayerData,
};
setPrayers(prev => [...prev, newPrayer]);
// Store user reminder preferences in Firestore
if (form.reminderFrequency !== 'never') {
await setDoc(doc(db, 'users', user.uid), {
email: form.email,
reminderFrequency: form.reminderFrequency,
includeActiveSummary: form.includeActiveSummary,
lastEmailSent: null,
}, { merge: true });
console.log('✅ Reminder preferences saved');
}
// Reset form
setForm({
date: new Date().toISOString().split('T')[0],
type: 'prayer',
text: '',
journal: '',
email: user.email || '',
prayFor: '',
reminderFrequency: 'never',
includeActiveSummary: false,
});
} catch (error) {
console.error('Error saving prayer:', error);
alert('Failed to save prayer. Please try again.');
}
};
const activePrayers = prayers.filter(prayer => !prayer.archived);
const archivedPrayers = prayers.filter(prayer => prayer.archived);
// Filter prayers based on search term
const filteredActivePrayers = activePrayers.filter(prayer =>
prayer.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.journal.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.type.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredArchivedPrayers = archivedPrayers.filter(prayer =>
prayer.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.journal.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.type.toLowerCase().includes(searchTerm.toLowerCase())
);
// For search tab, combine all filtered prayers
const allPrayers = [...activePrayers, ...archivedPrayers];
const filteredSearchPrayers = allPrayers.filter(prayer =>
prayer.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.journal.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.type.toLowerCase().includes(searchTerm.toLowerCase())
);
// Pagination logic
const totalActivePages = Math.ceil(filteredActivePrayers.length / itemsPerPage);
const totalArchivedPages = Math.ceil(filteredArchivedPrayers.length / itemsPerPage);
const totalSearchPages = Math.ceil(filteredSearchPrayers.length / itemsPerPage);
const paginatedActivePrayers = filteredActivePrayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const paginatedArchivedPrayers = filteredArchivedPrayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const paginatedSearchPrayers = filteredSearchPrayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const currentPrayers = activeTab === 'active' ? paginatedActivePrayers :
activeTab === 'archived' ? paginatedArchivedPrayers :
paginatedSearchPrayers;
const currentTotalPages = activeTab === 'active' ? totalActivePages :
activeTab === 'archived' ? totalArchivedPages :
totalSearchPages;
const groupedPrayers = currentPrayers.reduce((acc, prayer) => {
if (!acc[prayer.date]) acc[prayer.date] = [];
acc[prayer.date].push(prayer);
return acc;
}, {} as Record<string, Prayer[]>);
const sharePrayer = async (prayer: Prayer) => {
const shareText = `${prayer.type.toUpperCase()}: ${prayer.text}${prayer.journal ? `\n\nJournal: ${prayer.journal}` : ''}`;
if (navigator.share) {
try {
await navigator.share({
title: `Shared ${prayer.type}`,
text: shareText,
});
// Show success feedback
const button = document.activeElement as HTMLElement;
if (button) {
const originalText = button.textContent;
button.textContent = '✅ Shared!';
button.classList.add('bg-green-600', 'hover:bg-green-700');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-600', 'hover:bg-green-700');
}, 2000);
}
} catch (err) {
console.log('Share cancelled');
}
} else {
// Fallback: copy to clipboard
try {
await navigator.clipboard.writeText(shareText);
// Show success feedback
const button = document.activeElement as HTMLElement;
if (button) {
const originalText = button.textContent;
button.textContent = '📋 Copied!';
button.classList.add('bg-green-600', 'hover:bg-green-700');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-600', 'hover:bg-green-700');
}, 2000);
}
} catch (clipboardErr) {
alert('Prayer copied to clipboard!');
}
}
};
// Archive/unarchive prayers in Firestore
const toggleArchivePrayer = async (prayerId: string, archived: boolean) => {
try {
await updateDoc(doc(db, 'prayers', prayerId), {
archived: archived
});
// Update local state
setPrayers(prev => prev.map(p =>
p.id === prayerId ? { ...p, archived: archived } : p
));
} catch (error) {
console.error('Error updating prayer archive status:', error);
alert('Failed to update prayer. Please try again.');
}
};
if (loading || showLoadingScreen) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-black to-gray-800 flex items-center justify-center p-4">
<div className="text-center">
{/* Animated prayer icon */}
<div className="mb-8 relative">
<div className="w-24 h-24 mx-auto relative">
{/* Outer ring */}
<div className="absolute inset-0 border-4 border-blue-500/30 rounded-full animate-spin"></div>
{/* Inner ring */}
<div className="absolute inset-2 border-4 border-transparent border-t-blue-400 rounded-full animate-spin" style={{animationDirection: 'reverse', animationDuration: '1.5s'}}></div>
{/* Center icon */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-3xl animate-pulse">🙏</div>
</div>
</div>
{/* Floating particles */}
<div className="absolute -top-2 -right-2 w-3 h-3 bg-blue-400 rounded-full animate-bounce" style={{animationDelay: '0s'}}></div>
<div className="absolute -bottom-1 -left-3 w-2 h-2 bg-purple-400 rounded-full animate-bounce" style={{animationDelay: '0.5s'}}></div>
<div className="absolute top-1/2 -right-4 w-2 h-2 bg-green-400 rounded-full animate-bounce" style={{animationDelay: '1s'}}></div>
</div>
{/* Loading text */}
<h1 className="text-2xl font-bold text-white mb-2 animate-pulse">
Prayer App
</h1>
<p className="text-gray-400 text-lg mb-4">
Preparing your spiritual journey...
</p>
{/* Inspirational quote */}
<div className="max-w-xs mx-auto">
<blockquote className="text-gray-300 italic text-sm leading-relaxed">
"Be anxious for nothing, but in everything by prayer and supplication, with thanksgiving, let your requests be made known to God."
</blockquote>
<cite className="text-gray-500 text-xs mt-2 block">
— Philippians 4:6
</cite>
</div>
{/* Progress dots */}
<div className="flex justify-center space-x-2 mt-8">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{animationDelay: '0s'}}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{animationDelay: '0.2s'}}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{animationDelay: '0.4s'}}></div>
</div>
</div>
</div>
);
}
if (!user) {
return <Login />;
}
return (
<div className="min-h-screen bg-black text-white p-4">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">Prayer App</h1>
<p className="text-gray-400 text-sm mt-1">
{activeTab === 'add' ? 'Add New Prayer or Praise' :
activeTab === 'search' ? 'Search Results' :
`${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} Prayers`}
</p>
</div>
<div className="flex gap-4">
{isAdmin && (
<button
onClick={() => setShowAdmin(!showAdmin)}
className="px-4 py-3 bg-purple-600 hover:bg-purple-700 rounded text-base font-medium"
>
{showAdmin ? 'Hide Admin' : 'Admin'}
</button>
)}
<button onClick={() => logout()} className="px-4 py-3 bg-red-600 hover:bg-red-700 rounded text-base font-medium">
Logout
</button>
</div>
</div>
{showAdmin && <Admin />}
{activeTab === 'add' && (
<form onSubmit={handleSubmit} className="max-w-md mx-auto mb-8 space-y-6">
<div>
<label className="block mb-2 text-lg">Date</label>
<input
type="date"
value={form.date}
onChange={e => setForm(prev => ({ ...prev, date: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
required
/>
</div>
<div>
<label className="block mb-2 text-lg">Type</label>
<select
value={form.type}
onChange={e => setForm(prev => ({ ...prev, type: e.target.value as 'prayer' | 'praise' }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
>
<option value="prayer">Prayer</option>
<option value="praise">Praise</option>
</select>
</div>
<div>
<label className="block mb-2 text-lg">Pray For (Person)</label>
<input
type="text"
value={form.prayFor}
onChange={e => setForm(prev => ({ ...prev, prayFor: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
placeholder="Name of person to pray for..."
/>
</div>
<div>
<label className="block mb-2 text-lg">Text</label>
<textarea
value={form.text}
onChange={e => setForm(prev => ({ ...prev, text: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base min-h-[100px]"
placeholder="Enter your prayer or praise..."
required
/>
</div>
<div>
<label className="block mb-2 text-lg">Journal</label>
<textarea
value={form.journal}
onChange={e => setForm(prev => ({ ...prev, journal: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base min-h-[100px]"
placeholder="Optional journal entry..."
/>
</div>
<div>
<label className="block mb-2 text-lg">Email (for reminders)</label>
<input
type="email"
value={form.email}
onChange={e => setForm(prev => ({ ...prev, email: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
placeholder="your@email.com"
/>
</div>
<div>
<label className="block mb-2 text-lg">Reminder Frequency</label>
<select
value={form.reminderFrequency}
onChange={e => setForm(prev => ({ ...prev, reminderFrequency: e.target.value as 'daily' | 'weekly' | 'monthly' | 'never' }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
>
<option value="never">Never</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div className="flex items-center mb-4">
<input
type="checkbox"
checked={form.includeActiveSummary}
onChange={e => setForm(prev => ({ ...prev, includeActiveSummary: e.target.checked }))}
className="w-6 h-6 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label className="ml-3 text-base font-medium" onClick={() => setForm(prev => ({ ...prev, includeActiveSummary: !prev.includeActiveSummary }))}>
Include active prayers summary in reminders
</label>
</div>
<button type="submit" className="w-full p-4 bg-blue-600 hover:bg-blue-700 rounded text-lg font-semibold mt-6">
Add Entry
</button>
</form>
)}
{activeTab !== 'add' && (
<div className="max-w-4xl mx-auto">
{activeTab === 'search' && (
<div className="mb-6">
<input
type="text"
placeholder="Search all prayers..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1); // Reset to first page when searching
}}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
{searchTerm && (
<p className="text-gray-400 text-sm mt-2">
Found {filteredSearchPrayers.length} prayer{filteredSearchPrayers.length !== 1 ? 's' : ''}
</p>
)}
</div>
)}
{currentPrayers.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-500">
{activeTab === 'search' && searchTerm ? 'No prayers found matching your search.' : 'No prayers or praises found.'}
</p>
</div>
)}
{Object.keys(groupedPrayers).sort().reverse().map(date => (
<div key={date} className="mb-8">
<h2 className="text-xl font-semibold mb-4">{new Date(date).toLocaleDateString()}</h2>
{groupedPrayers[date].map(prayer => (
<div key={prayer.id} className={`bg-gray-800 p-4 rounded mb-4 ${prayer.archived || activeTab === 'search' ? 'opacity-75 border border-gray-600' : ''}`}>
<div className="flex justify-between items-start mb-3">
<span className="font-medium capitalize text-lg">{prayer.type}</span>
<button
onClick={() => sharePrayer(prayer)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg flex items-center gap-2 transition-all duration-200 shadow-md hover:shadow-lg"
title="Share this prayer"
>
📤 Share
</button>
</div>
<p className="mb-2">{prayer.text}</p>
{prayer.journal && (
<div className="border-t border-gray-600 pt-2">
<p className="text-sm text-gray-400">Journal: {prayer.journal}</p>
</div>
)}
{prayer.prayFor && (
<div className="border-t border-gray-600 pt-2">
<p className="text-sm text-gray-400">Pray For: {prayer.prayFor}</p>
</div>
)}
{activeTab !== 'search' && (
<div className="flex justify-end mt-3 pt-2 border-t border-gray-600">
{prayer.archived ? (
<button
onClick={() => toggleArchivePrayer(prayer.id, false)}
className="px-3 py-1 text-green-500 hover:text-green-400 text-sm font-medium hover:bg-gray-700 rounded transition-colors"
title="Restore prayer"
>
↩️ Restore
</button>
) : (
<button
onClick={() => toggleArchivePrayer(prayer.id, true)}
className="px-3 py-1 text-red-500 hover:text-red-400 text-sm font-medium hover:bg-gray-700 rounded transition-colors"
title="Archive prayer"
>
📂 Archive
</button>
)}
</div>
)}
</div>
))}
</div>
))}
{/* Pagination */}
{currentTotalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8 mb-6">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed rounded text-white"
>
Previous
</button>
<span className="text-gray-300">
Page {currentPage} of {currentTotalPages}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(currentTotalPages, prev + 1))}
disabled={currentPage === currentTotalPages}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed rounded text-white"
>
Next
</button>
</div>
)}
</div>
)}
{/* Bottom Navigation Tabs */}
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-gray-900 via-gray-900 to-gray-800 border-t border-gray-700 shadow-2xl">
<div className="grid grid-cols-4 px-2 py-2">
<button
onClick={() => {
setActiveTab('add');
setCurrentPage(1);
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'add'
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">➕</span>
<span className="text-xs leading-tight">Add</span>
</button>
<button
onClick={() => {
setActiveTab('active');
setCurrentPage(1);
setSearchTerm(''); // Clear search when switching to active
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'active'
? 'bg-gradient-to-br from-green-600 to-green-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">📖</span>
<span className="text-xs leading-tight">Active</span>
</button>
<button
onClick={() => {
setActiveTab('archived');
setCurrentPage(1);
setSearchTerm(''); // Clear search when switching to archived
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'archived'
? 'bg-gradient-to-br from-purple-600 to-purple-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">📂</span>
<span className="text-xs leading-tight">Archived</span>
</button>
<button
onClick={() => {
setActiveTab('search');
setCurrentPage(1);
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'search'
? 'bg-gradient-to-br from-orange-600 to-orange-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">🔍</span>
<span className="text-xs leading-tight">Search</span>
</button>
</div>
</div>
{/* Add padding to account for fixed bottom nav */}
<div className="pb-20"></div>
</div>
);
}