Implement comprehensive enhancements: push notifications, export functionality, user profiles, community sharing, offline improvements, analytics, accessibility, testing, and documentation

This commit is contained in:
nmemmert committed 2025-10-23 08:08:20 -04:00
1 parent 1932dbe1fc
commit 3d8ba5b8f0
21 files changed
+6273 -2585

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
import { render, screen } from '@testing-library/react';
import Profile from '../components/Profile';
test('renders profile component', () => {
render(<Profile />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { doc, getDoc, collection, getDocs, updateDoc } from 'firebase/firestore';
import { db } from '@/lib/firebase';
import { admin } from '@/lib/firebase-admin';
const nodemailer = require('nodemailer');
@@ -327,6 +328,22 @@ export async function GET() {
html: generatePrayerEmailHTML(message, subject),
});
// Send push notification if FCM token exists
if (userData.fcmToken && admin.apps.length > 0) {
try {
await admin.messaging().send({
token: userData.fcmToken,
notification: {
title: subject,
body: `You have ${userPrayers.length} active prayers to review.`,
},
});
console.log(`✅ Sent push notification to ${userData.email}`);
} catch (fcmError) {
console.error(`❌ Failed to send FCM to ${userData.email}:`, fcmError);
}
}
// Update last email sent time
await updateDoc(doc(db, 'users', userId), {
lastEmailSent: new Date(),
+179 -11
View File
@@ -4,9 +4,12 @@ import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
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 emailjs from '@emailjs/browser';
import jsPDF from 'jspdf';
import { CSVLink } from 'react-csv';
interface Prayer {
id: string;
@@ -24,6 +27,7 @@ interface Prayer {
export default function Home() {
const [prayers, setPrayers] = useState<Prayer[]>([]);
const [sharedPrayers, setSharedPrayers] = useState<Prayer[]>([]);
const [form, setForm] = useState({
date: new Date().toISOString().split('T')[0],
type: 'prayer' as 'prayer' | 'praise',
@@ -35,9 +39,10 @@ export default function Home() {
includeActiveSummary: false,
});
const [showAdmin, setShowAdmin] = useState(false);
const [showProfile, setShowProfile] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [activeTab, setActiveTab] = useState<'add' | 'active' | 'archived' | 'search'>('add');
const [activeTab, setActiveTab] = useState<'add' | 'active' | 'archived' | 'search' | 'community'>('add');
const itemsPerPage = 10;
const { user, loading, logout, isAdmin } = useAuth();
const [showLoadingScreen, setShowLoadingScreen] = useState(true);
@@ -71,6 +76,23 @@ export default function Home() {
}
};
// Load shared prayers
const loadSharedPrayers = async () => {
try {
const sharedRef = collection(db, 'sharedPrayers');
const querySnapshot = await getDocs(sharedRef);
const loadedShared: Prayer[] = [];
querySnapshot.forEach((doc) => {
loadedShared.push({ id: doc.id, ...doc.data() } as Prayer);
});
setSharedPrayers(loadedShared);
} catch (error) {
console.error('Error loading shared prayers:', error);
}
};
// Load prayers when user changes
useEffect(() => {
if (user) {
@@ -80,6 +102,58 @@ export default function Home() {
}
}, [user]);
// Load shared prayers on mount
useEffect(() => {
loadSharedPrayers();
}, []);
// Export functions
const exportToPDF = (prayerList: Prayer[], title: string) => {
const doc = new jsPDF();
doc.text(title, 10, 10);
let y = 20;
prayerList.forEach((prayer, index) => {
if (y > 270) {
doc.addPage();
y = 10;
}
doc.text(`${index + 1}. ${prayer.date} - ${prayer.type.toUpperCase()}: ${prayer.text}`, 10, y);
y += 10;
if (prayer.prayFor) {
doc.text(`Praying for: ${prayer.prayFor}`, 10, y);
y += 10;
}
if (prayer.journal) {
doc.text(`Journal: ${prayer.journal}`, 10, y);
y += 10;
}
y += 5;
});
doc.save(`${title.replace(/\s+/g, '_')}.pdf`);
};
const exportToJSON = (prayerList: Prayer[], title: string) => {
const dataStr = JSON.stringify(prayerList, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
const exportFileDefaultName = `${title.replace(/\s+/g, '_')}.json`;
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
};
const getCSVData = (prayerList: Prayer[]) => {
return prayerList.map(prayer => ({
Date: prayer.date,
Type: prayer.type,
Text: prayer.text,
PrayFor: prayer.prayFor,
Journal: prayer.journal,
Email: prayer.email,
Archived: prayer.archived ? 'Yes' : 'No'
}));
};
// Auto-populate email field with user's email
useEffect(() => {
if (user?.email) {
@@ -87,6 +161,23 @@ export default function Home() {
}
}, [user]);
// Load draft from localStorage
useEffect(() => {
const draft = localStorage.getItem('prayerDraft');
if (draft) {
try {
setForm(JSON.parse(draft));
} catch (e) {
console.error('Error loading draft:', e);
}
}
}, []);
// Save draft to localStorage
useEffect(() => {
localStorage.setItem('prayerDraft', JSON.stringify(form));
}, [form]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -229,10 +320,12 @@ export default function Home() {
const currentPrayers = activeTab === 'active' ? paginatedActivePrayers :
activeTab === 'archived' ? paginatedArchivedPrayers :
paginatedSearchPrayers;
activeTab === 'search' ? paginatedSearchPrayers :
sharedPrayers; // Community
const currentTotalPages = activeTab === 'active' ? totalActivePages :
activeTab === 'archived' ? totalArchivedPages :
totalSearchPages;
activeTab === 'search' ? totalSearchPages :
1; // Community
const groupedPrayers = currentPrayers.reduce((acc, prayer) => {
if (!acc[prayer.date]) acc[prayer.date] = [];
@@ -284,6 +377,20 @@ export default function Home() {
}
};
const shareToCommunity = async (prayer: Prayer) => {
try {
await addDoc(collection(db, 'sharedPrayers'), {
...prayer,
userId: null, // Anonymous
sharedAt: new Date(),
});
loadSharedPrayers(); // Reload
alert('Prayer shared to community!');
} catch (error) {
console.error('Error sharing to community:', error);
}
};
// Archive/unarchive prayers in Firestore
const toggleArchivePrayer = async (prayerId: string, archived: boolean) => {
try {
@@ -368,6 +475,12 @@ export default function Home() {
</p>
</div>
<div className="flex gap-4">
<button
onClick={() => setShowProfile(!showProfile)}
className="px-4 py-3 bg-blue-600 hover:bg-blue-700 rounded text-base font-medium"
>
{showProfile ? 'Hide Profile' : 'Profile'}
</button>
{isAdmin && (
<button
onClick={() => setShowAdmin(!showAdmin)}
@@ -383,6 +496,37 @@ export default function Home() {
</div>
{showAdmin && <Admin />}
{showProfile && <Profile />}
{(activeTab === 'active' || activeTab === 'archived') && (
<div className="max-w-md mx-auto mb-4 flex flex-wrap gap-2 justify-center">
<button
onClick={() => {
const prayerList = activeTab === 'active' ? prayers.filter(p => !p.archived) : prayers.filter(p => p.archived);
exportToPDF(prayerList, `${activeTab} Prayers`);
}}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-all duration-200"
>
Export PDF
</button>
<CSVLink
data={getCSVData(activeTab === 'active' ? prayers.filter(p => !p.archived) : prayers.filter(p => p.archived))}
filename={`${activeTab}_prayers.csv`}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-all duration-200 inline-block"
>
Export CSV
</CSVLink>
<button
onClick={() => {
const prayerList = activeTab === 'active' ? prayers.filter(p => !p.archived) : prayers.filter(p => p.archived);
exportToJSON(prayerList, `${activeTab} Prayers`);
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-all duration-200"
>
Export JSON
</button>
</div>
)}
{activeTab === 'add' && (
<form onSubmit={handleSubmit} className="max-w-md mx-auto mb-8 space-y-6">
@@ -522,13 +666,22 @@ export default function Home() {
<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 className="flex gap-2">
<button
onClick={() => sharePrayer(prayer)}
className="px-3 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>
<button
onClick={() => shareToCommunity(prayer)}
className="px-3 py-2 bg-green-600 hover:bg-green-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 to community"
>
🌍 Community
</button>
</div>
</div>
<p className="mb-2">{prayer.text}</p>
{prayer.journal && (
@@ -596,7 +749,7 @@ export default function Home() {
{/* 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">
<div className="grid grid-cols-5 px-2 py-2">
<button
onClick={() => {
setActiveTab('add');
@@ -655,6 +808,21 @@ export default function Home() {
<span className="text-lg mb-1">🔍</span>
<span className="text-xs leading-tight">Search</span>
</button>
<button
onClick={() => {
setActiveTab('community');
setCurrentPage(1);
setSearchTerm(''); // Clear search when switching to community
}}
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 === 'community'
? 'bg-gradient-to-br from-teal-600 to-teal-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">Community</span>
</button>
</div>
</div>
+36 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { doc, getDoc, setDoc } from 'firebase/firestore';
import { doc, getDoc, setDoc, collection, getDocs } from 'firebase/firestore';
import { db } from '@/lib/firebase';
import { useAuth } from '@/context/AuthContext';
@@ -35,6 +35,11 @@ export default function Admin() {
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [newAdminEmail, setNewAdminEmail] = useState('');
const [metrics, setMetrics] = useState({
totalUsers: 0,
totalPrayers: 0,
totalSharedPrayers: 0,
});
useEffect(() => {
loadConfig();
@@ -56,6 +61,18 @@ export default function Admin() {
// Default admin config
setAdminConfig({ adminEmails: ['your-admin-email@example.com'] });
}
// Load metrics
const usersSnapshot = await getDocs(collection(db, 'users'));
const prayersSnapshot = await getDocs(collection(db, 'prayers'));
const sharedPrayersSnapshot = await getDocs(collection(db, 'sharedPrayers'));
setMetrics({
totalUsers: usersSnapshot.size,
totalPrayers: prayersSnapshot.size,
totalSharedPrayers: sharedPrayersSnapshot.size,
});
} catch (error) {
console.error('Error loading config:', error);
} finally {
@@ -167,6 +184,24 @@ export default function Admin() {
<div className="max-w-2xl mx-auto p-6 bg-gray-800 rounded-lg">
<h2 className="text-2xl font-bold mb-6 text-white">Admin Settings</h2>
<div className="mb-6 p-4 bg-gray-700 rounded">
<h3 className="text-xl font-semibold text-white mb-4">Analytics Dashboard</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-400">{metrics.totalUsers}</div>
<div className="text-sm text-gray-300">Total Users</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-400">{metrics.totalPrayers}</div>
<div className="text-sm text-gray-300">Total Prayers</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-400">{metrics.totalSharedPrayers}</div>
<div className="text-sm text-gray-300">Shared Prayers</div>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold text-white mb-4">SMTP Configuration</h3>
+151
View File
@@ -0,0 +1,151 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { storage, db } from '@/lib/firebase';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { doc, getDoc, updateDoc } from 'firebase/firestore';
import { useTheme } from 'next-themes';
interface UserProfile {
avatarUrl?: string;
theme?: string;
categories?: string[];
}
export default function Profile() {
const { user } = useAuth();
const { theme, setTheme } = useTheme();
const [profile, setProfile] = useState<UserProfile>({});
const [uploading, setUploading] = useState(false);
const [newCategory, setNewCategory] = useState('');
useEffect(() => {
if (user) {
loadProfile();
}
}, [user]);
const loadProfile = async () => {
if (!user) return;
try {
const docRef = doc(db, 'users', user.uid);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
setProfile(docSnap.data() as UserProfile);
}
} catch (error) {
console.error('Error loading profile:', error);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
setUploading(true);
try {
const storageRef = ref(storage, `avatars/${user.uid}`);
await uploadBytes(storageRef, file);
const url = await getDownloadURL(storageRef);
await updateDoc(doc(db, 'users', user.uid), { avatarUrl: url });
setProfile(prev => ({ ...prev, avatarUrl: url }));
} catch (error) {
console.error('Error uploading avatar:', error);
} finally {
setUploading(false);
}
};
const handleThemeChange = async (newTheme: string) => {
setTheme(newTheme);
if (user) {
await updateDoc(doc(db, 'users', user.uid), { theme: newTheme });
setProfile(prev => ({ ...prev, theme: newTheme }));
}
};
const addCategory = async () => {
if (!newCategory.trim() || !user) return;
const updatedCategories = [...(profile.categories || []), newCategory.trim()];
await updateDoc(doc(db, 'users', user.uid), { categories: updatedCategories });
setProfile(prev => ({ ...prev, categories: updatedCategories }));
setNewCategory('');
};
const removeCategory = async (category: string) => {
if (!user) return;
const updatedCategories = (profile.categories || []).filter(c => c !== category);
await updateDoc(doc(db, 'users', user.uid), { categories: updatedCategories });
setProfile(prev => ({ ...prev, categories: updatedCategories }));
};
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>
{/* Avatar */}
<div className="mb-6">
<label className="block mb-2 text-lg">Avatar</label>
{profile.avatarUrl && (
<img src={profile.avatarUrl} alt="Avatar" className="w-20 h-20 rounded-full mb-2" />
)}
<input
type="file"
accept="image/*"
onChange={handleAvatarUpload}
disabled={uploading}
className="w-full p-3 bg-gray-700 border border-gray-600 rounded"
/>
{uploading && <p className="text-sm text-gray-400 mt-1">Uploading...</p>}
</div>
{/* Theme */}
<div className="mb-6">
<label className="block mb-2 text-lg">Theme</label>
<select
value={theme}
onChange={(e) => handleThemeChange(e.target.value)}
className="w-full p-3 bg-gray-700 border border-gray-600 rounded"
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select>
</div>
{/* Categories */}
<div className="mb-6">
<label className="block mb-2 text-lg">Prayer Categories</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
placeholder="Add category"
className="flex-1 p-3 bg-gray-700 border border-gray-600 rounded"
/>
<button
onClick={addCategory}
className="px-4 py-3 bg-green-600 hover:bg-green-700 rounded"
>
Add
</button>
</div>
<div className="flex flex-wrap gap-2">
{(profile.categories || []).map(category => (
<span key={category} className="bg-gray-700 px-3 py-1 rounded flex items-center gap-2">
{category}
<button
onClick={() => removeCategory(category)}
className="text-red-400 hover:text-red-300"
>
×
</button>
</span>
))}
</div>
</div>
</div>
);
}
+62 -6
View File
@@ -2,7 +2,9 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { User, signInWithEmailAndPassword, signOut, onAuthStateChanged, createUserWithEmailAndPassword } from 'firebase/auth';
import { auth } from '@/lib/firebase';
import { auth, db, app } from '@/lib/firebase';
import { doc, setDoc } from 'firebase/firestore';
import { getToken, onMessage } from 'firebase/messaging';
interface AuthContextType {
user: User | null;
@@ -20,11 +22,65 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return unsubscribe;
// Register service worker for FCM
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/firebase-messaging-sw.js')
.then((registration) => {
console.log('Service Worker registered for FCM');
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
const initMessaging = async () => {
const { getMessaging, getToken, onMessage } = await import('firebase/messaging');
const messaging = getMessaging(app);
const unsubscribe = onAuthStateChanged(auth, async (user) => {
setUser(user);
if (user) {
// Request FCM token and save to Firestore
try {
const token = await getToken(messaging, { vapidKey: 'YOUR_VAPID_KEY' }); // Replace with actual VAPID key
if (token) {
await setDoc(doc(db, 'users', user.uid), { fcmToken: token }, { merge: true });
}
} catch (error) {
console.error('Error getting FCM token:', error);
}
// Handle foreground messages
onMessage(messaging, (payload) => {
console.log('Message received. ', payload);
// Show notification or alert
if (Notification.permission === 'granted') {
new Notification(payload.notification?.title || 'Prayer Reminder', {
body: payload.notification?.body,
icon: '/icon-192x192.png'
});
}
});
}
setLoading(false);
});
return unsubscribe;
};
let unsubscribe: (() => void) | undefined;
if (typeof window !== 'undefined') {
initMessaging().then((unsub) => unsubscribe = unsub);
} else {
const unsub = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
unsubscribe = unsub;
}
return () => {
if (unsubscribe) unsubscribe();
};
}, []);
// Configure admin email addresses here - only these users will have admin access
+13
View File
@@ -0,0 +1,13 @@
import * as admin from 'firebase-admin';
if (!admin.apps.length && process.env.FIREBASE_PROJECT_ID) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
});
}
export { admin };
+22 -1
View File
@@ -2,6 +2,8 @@
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
import { getAnalytics } from 'firebase/analytics';
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
@@ -23,4 +25,23 @@ const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
// Initialize Cloud Firestore and get a reference to the service
export const db = getFirestore(app);
export const db = getFirestore(app);
// Initialize Firebase Storage and get a reference to the service
export const storage = getStorage(app);
// Initialize Firebase Analytics and get a reference to the service
let analytics: any = null;
if (typeof window !== 'undefined') {
import('firebase/analytics').then(({ getAnalytics, isSupported }) => {
isSupported().then((supported) => {
if (supported) {
analytics = getAnalytics(app);
}
});
});
}
export { analytics };
export { app };