Add enhanced search/filtering and push notifications for prayer reminders

This commit is contained in:
nmemmert committed 2025-11-03 08:04:19 -05:00
1 parent aa3f42479f
commit 1a160135d7
5 files changed
+294 -38

No files matched your search

+169 -36
View File
@@ -6,7 +6,7 @@ import Login from '@/components/Login';
import Admin from '@/components/Admin';
import Profile from '@/components/Profile';
import { db } from '@/lib/firebase';
import { doc, setDoc, collection, addDoc, getDocs, updateDoc, query, where } from 'firebase/firestore';
import { doc, setDoc, collection, addDoc, getDocs, updateDoc, query, where, getDoc } from 'firebase/firestore';
import emailjs from '@emailjs/browser';
import jsPDF from 'jspdf';
import { CSVLink } from 'react-csv';
@@ -23,6 +23,8 @@ interface Prayer {
archived?: boolean;
reminderFrequency?: 'daily' | 'weekly' | 'monthly' | 'never';
includeActiveSummary?: boolean;
answered?: boolean;
answeredDate?: string;
}
export default function Home() {
@@ -47,6 +49,10 @@ export default function Home() {
const { user, loading, logout, isAdmin } = useAuth();
const [showLoadingScreen, setShowLoadingScreen] = useState(true);
const [editingPrayer, setEditingPrayer] = useState<Prayer | null>(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'prayer' | 'praise'>('all');
const [userProfile, setUserProfile] = useState<any>({});
// Ensure loading screen shows for at least 5 seconds
useEffect(() => {
@@ -108,6 +114,23 @@ export default function Home() {
loadSharedPrayers();
}, []);
// Load user profile
useEffect(() => {
const loadUserProfile = async () => {
if (!user) return;
try {
const docRef = doc(db, 'users', user.uid);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
setUserProfile(docSnap.data());
}
} catch (error) {
console.error('Error loading user profile:', error);
}
};
loadUserProfile();
}, [user]);
// Export functions
const exportToPDF = (prayerList: Prayer[], title: string) => {
const doc = new jsPDF();
@@ -291,26 +314,37 @@ export default function Home() {
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())
);
// Enhanced filtering function
const applyFilters = (prayerList: Prayer[]) => {
return prayerList.filter(prayer => {
// Keyword search
const matchesSearch = !searchTerm ||
prayer.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.journal.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.type.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.prayFor.toLowerCase().includes(searchTerm.toLowerCase());
// Date range filter
const prayerDate = new Date(prayer.date);
const fromDate = dateFrom ? new Date(dateFrom) : null;
const toDateFilter = dateTo ? new Date(dateTo) : null;
const matchesDateFrom = !fromDate || prayerDate >= fromDate;
const matchesDateTo = !toDateFilter || prayerDate <= toDateFilter;
// Type filter
const matchesType = typeFilter === 'all' || prayer.type === typeFilter;
return matchesSearch && matchesDateFrom && matchesDateTo && matchesType;
});
};
// Apply filters to each prayer list
const filteredActivePrayers = applyFilters(activePrayers);
const filteredArchivedPrayers = applyFilters(archivedPrayers);
// 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())
);
const filteredSearchPrayers = applyFilters(allPrayers);
// Pagination logic
const totalActivePages = Math.ceil(filteredActivePrayers.length / itemsPerPage);
@@ -422,6 +456,34 @@ export default function Home() {
}
};
// Mark prayer as answered/unanswered
const toggleAnsweredPrayer = async (prayerId: string, answered: boolean) => {
try {
const updateData: any = { answered };
if (answered) {
updateData.answeredDate = new Date().toISOString().split('T')[0];
} else {
updateData.answeredDate = null;
}
await updateDoc(doc(db, 'prayers', prayerId), updateData);
// Update local state
setPrayers(prev => prev.map(p =>
p.id === prayerId ? { ...p, answered, answeredDate: answered ? new Date().toISOString().split('T')[0] : undefined } : p
));
// Send notification if answered and notifications are enabled
if (answered && userProfile.notificationsEnabled) {
// This would trigger a server-side notification via Firebase Functions
console.log('Prayer answered - notification would be sent');
}
} catch (error) {
console.error('Error updating prayer answered status:', error);
alert('Failed to update prayer. Please try again.');
}
};
// Start editing a prayer
const startEditing = (prayer: Prayer) => {
setEditingPrayer(prayer);
@@ -686,23 +748,75 @@ export default function Home() {
{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 className="mb-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<input
type="text"
placeholder="Search keywords..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="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"
/>
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as 'all' | 'prayer' | 'praise');
setCurrentPage(1);
}}
className="p-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Types</option>
<option value="prayer">Prayers Only</option>
<option value="praise">Praises Only</option>
</select>
<input
type="date"
placeholder="From date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setCurrentPage(1);
}}
className="p-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="date"
placeholder="To date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setCurrentPage(1);
}}
className="p-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-between items-center">
<button
onClick={() => {
setSearchTerm('');
setTypeFilter('all');
setDateFrom('');
setDateTo('');
setCurrentPage(1);
}}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg"
>
Clear Filters
</button>
{(searchTerm || typeFilter !== 'all' || dateFrom || dateTo) && (
<p className="text-gray-400 text-sm">
Found {filteredSearchPrayers.length} prayer{filteredSearchPrayers.length !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
)}
@@ -757,7 +871,26 @@ export default function Home() {
</div>
)}
{activeTab !== 'search' && (
<div className="flex justify-end mt-3 pt-2 border-t border-gray-600">
<div className="flex justify-between items-center mt-3 pt-2 border-t border-gray-600">
<div className="flex items-center gap-2">
{prayer.answered && (
<span className="text-green-400 text-sm font-medium flex items-center gap-1">
✅ Answered {prayer.answeredDate && `on ${new Date(prayer.answeredDate).toLocaleDateString()}`}
</span>
)}
<button
onClick={() => toggleAnsweredPrayer(prayer.id, !prayer.answered)}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
prayer.answered
? 'text-yellow-500 hover:text-yellow-400 hover:bg-gray-700'
: 'text-green-500 hover:text-green-400 hover:bg-gray-700'
}`}
title={prayer.answered ? 'Mark as unanswered' : 'Mark as answered'}
>
{prayer.answered ? '↩️ Unmark' : '✅ Mark Answered'}
</button>
</div>
{prayer.archived ? (
<button
onClick={() => toggleArchivePrayer(prayer.id, false)}
+84 -1
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/context/AuthContext';
import { storage, db } from '@/lib/firebase';
import { storage, db, requestNotificationPermission, onMessageListener } from '@/lib/firebase';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { doc, getDoc, updateDoc } from 'firebase/firestore';
import { useTheme } from 'next-themes';
@@ -12,6 +12,8 @@ interface UserProfile {
avatarUrl?: string;
theme?: string;
categories?: string[];
notificationsEnabled?: boolean;
fcmToken?: string;
}
export default function Profile() {
@@ -20,6 +22,7 @@ export default function Profile() {
const [profile, setProfile] = useState<UserProfile>({});
const [uploading, setUploading] = useState(false);
const [newCategory, setNewCategory] = useState('');
const [notificationsEnabled, setNotificationsEnabled] = useState(false);
const loadProfile = useCallback(async () => {
if (!user) return;
@@ -28,6 +31,7 @@ export default function Profile() {
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
setProfile(docSnap.data() as UserProfile);
setNotificationsEnabled(docSnap.data().notificationsEnabled);
}
} catch (error) {
console.error('Error loading profile:', error);
@@ -40,6 +44,27 @@ export default function Profile() {
}
}, [user, loadProfile]);
// Load notification settings
useEffect(() => {
if (profile.notificationsEnabled !== undefined) {
setNotificationsEnabled(profile.notificationsEnabled);
}
}, [profile.notificationsEnabled]);
// Listen for FCM messages
useEffect(() => {
if (notificationsEnabled) {
onMessageListener().then((payload: any) => {
console.log('Message received:', payload);
// Handle incoming messages (e.g., show notification)
new Notification(payload.notification.title, {
body: payload.notification.body,
icon: '/icon-192x192.png'
});
}).catch(err => console.log('Failed to listen for messages:', err));
}
}, [notificationsEnabled]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
@@ -66,6 +91,35 @@ export default function Profile() {
}
};
const handleNotificationToggle = async (enabled: boolean) => {
if (!user) return;
try {
if (enabled) {
const token = await requestNotificationPermission();
if (token) {
await updateDoc(doc(db, 'users', user.uid), {
notificationsEnabled: true,
fcmToken: token
});
setProfile(prev => ({ ...prev, notificationsEnabled: true, fcmToken: token }));
setNotificationsEnabled(true);
} else {
alert('Notification permission denied. Please enable notifications in your browser settings.');
}
} else {
await updateDoc(doc(db, 'users', user.uid), {
notificationsEnabled: false,
fcmToken: null
});
setProfile(prev => ({ ...prev, notificationsEnabled: false, fcmToken: undefined }));
setNotificationsEnabled(false);
}
} catch (error) {
console.error('Error updating notification settings:', error);
}
};
const addCategory = async () => {
if (!newCategory.trim() || !user) return;
const updatedCategories = [...(profile.categories || []), newCategory.trim()];
@@ -81,6 +135,16 @@ export default function Profile() {
setProfile(prev => ({ ...prev, categories: updatedCategories }));
};
const toggleNotifications = async () => {
if (!user) return;
const newStatus = !notificationsEnabled;
setNotificationsEnabled(newStatus);
await updateDoc(doc(db, 'users', user.uid), { notificationsEnabled: newStatus });
if (newStatus) {
requestNotificationPermission();
}
};
return (
<div className="max-w-md mx-auto mb-8 p-6 bg-gray-800 rounded-lg">
<h2 className="text-2xl font-bold mb-6">Profile</h2>
@@ -115,6 +179,25 @@ export default function Profile() {
</select>
</div>
{/* Notifications */}
<div className="mb-6">
<label className="block mb-2 text-lg">Notifications</label>
<div className="flex items-center mb-4">
<input
type="checkbox"
checked={notificationsEnabled}
onChange={(e) => handleNotificationToggle(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">
Enable push notifications for prayer reminders
</label>
</div>
<p className="text-sm text-gray-400">
Get notified about your prayer schedule and when prayers are answered.
</p>
</div>
{/* Categories */}
<div className="mb-6">
<label className="block mb-2 text-lg">Prayer Categories</label>
+38
View File
@@ -4,6 +4,7 @@ import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
import { getAnalytics } from 'firebase/analytics';
import { getMessaging, onMessage, getToken } from 'firebase/messaging';
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
@@ -30,6 +31,16 @@ export const db = getFirestore(app);
// Initialize Firebase Storage and get a reference to the service
export const storage = getStorage(app);
// Initialize Firebase Cloud Messaging
export let messaging: any = null;
if (typeof window !== 'undefined') {
try {
messaging = getMessaging(app);
} catch (error) {
console.warn('FCM not supported in this environment:', error);
}
}
// Initialize Firebase Analytics and get a reference to the service
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let analytics: any = null;
@@ -45,4 +56,31 @@ if (typeof window !== 'undefined') {
export { analytics };
// FCM utility functions
export const requestNotificationPermission = async () => {
if (!messaging) return null;
try {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
const token = await getToken(messaging, {
vapidKey: process.env.NEXT_PUBLIC_FCM_VAPID_KEY || 'your-vapid-key-here'
});
return token;
}
} catch (error) {
console.error('Error requesting notification permission:', error);
}
return null;
};
export const onMessageListener = () =>
new Promise((resolve) => {
if (messaging) {
onMessage(messaging, (payload) => {
resolve(payload);
});
}
});
export { app };